Compare commits
12 Commits
bd9cfaa6e4
...
b35a5ae216
| Author | SHA1 | Date | |
|---|---|---|---|
| b35a5ae216 | |||
| 8c07cf36ee | |||
| 7d2661a8e8 | |||
| 0fdee610ed | |||
| f1c470a8a3 | |||
| 736656bde8 | |||
| e441f59bff | |||
| c3eb5a62c4 | |||
| 79e748b470 | |||
| 88c4201f80 | |||
| 6e1cc62065 | |||
| 76cc81a385 |
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo - Projekt-Status
|
# Shinkan Jinkendo - Projekt-Status
|
||||||
|
|
||||||
**Stand:** 2026-05-12
|
**Stand:** 2026-05-14
|
||||||
**Version (Code):** 0.8.110 (`backend/version.py`, APP_VERSION)
|
**Version (Code):** 0.8.140 (`backend/version.py`, APP_VERSION)
|
||||||
**DB-Schema-Version:** `20260512057` (`backend/version.py`, DB_SCHEMA_VERSION)
|
**DB-Schema-Version:** `20260515063` (`backend/version.py`, DB_SCHEMA_VERSION)
|
||||||
**Branch:** develop
|
**Branch:** develop
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
||||||
|
|
||||||
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
|
|
@ -36,7 +36,8 @@
|
||||||
1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
|
1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
|
||||||
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
|
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
|
||||||
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**).
|
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**).
|
||||||
4. **Kombinationsübungen / Coach (Fachspez § 10.6):** Coach **Stufe B/C** (archetypgesteuerte Durchführung); **Archetyp-Verwaltung** jenseits Code-Konstanten; **Massen-Vorbelegung** aller Slot-Zeit/Anzahl-Felder; **serverseitige** Validierung Profil ↔ Archetyp — siehe `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Pakete **4e–4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`.
|
4. **Breakout / Coaching (Arbeitspaket):** Backend-Konsistenz `phases`↔`sections`, Run-UI vs. Spec (Stream-Tabs), Vorlagen phasenfähig, E2E-Smoke — siehe **`docs/HANDOVER.md`** (Tabelle „Coaching & Breakout“).
|
||||||
|
5. **Kombinationsübungen / Coach (Fachspez § 10.6):** Coach **Stufe B/C** (archetypgesteuerte Durchführung); **Archetyp-Verwaltung** jenseits Code-Konstanten; **Massen-Vorbelegung** aller Slot-Zeit/Anzahl-Felder; **serverseitige** Validierung Profil ↔ Archetyp — siehe `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Pakete **4e–4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -92,6 +93,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
||||||
- [x] **Optionale Zuordnung einer Übungsvariante** pro Eintrag (`exercise_variant_id`)
|
- [x] **Optionale Zuordnung einer Übungsvariante** pro Eintrag (`exercise_variant_id`)
|
||||||
- [x] **Trainingsrahmenprogramm Bibliothek** (Ziele, Slots, Kontext) + **Slot‑Blueprints** in `training_units` (036–037)
|
- [x] **Trainingsrahmenprogramm Bibliothek** (Ziele, Slots, Kontext) + **Slot‑Blueprints** in `training_units` (036–037)
|
||||||
- [x] **Materialisierung** aus Rahmen‑Slot (`POST …/training-units/from-framework-slot`; UI‑Anbindung optional)
|
- [x] **Materialisierung** aus Rahmen‑Slot (`POST …/training-units/from-framework-slot`; UI‑Anbindung optional)
|
||||||
|
- [x] **Phasenmodell & parallele Streams** pro Einheit (Migration **063**): `training_unit_phases`, `training_unit_parallel_streams`; GET mit **`phases`** + flachen **`sections`**; PUT mit **`phases`** (App **0.8.137–0.8.140**)
|
||||||
|
- [x] **Coaching-Modus** für Breakout: Timeline mit Split-Wahl, Rejoin vor Ganzgruppe/nächstem Split, Nachbereitung speichern → Plan & Ablauf (`TrainingCoachPage`, `trainingPlanUtils.js`)
|
||||||
- [ ] Kalender-View / erweiterte Roadmap (Backlog)
|
- [ ] Kalender-View / erweiterte Roadmap (Backlog)
|
||||||
|
|
||||||
**MediaWiki Import:**
|
**MediaWiki Import:**
|
||||||
|
|
@ -155,18 +158,19 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
||||||
|
|
||||||
| Dokument | Pfad | Stand | Status |
|
| Dokument | Pfad | Stand | Status |
|
||||||
|----------|------|-------|--------|
|
|----------|------|-------|--------|
|
||||||
| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-12 | neu, Ist-Überblick |
|
| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-14 | Phasen/Coach/Rejoin |
|
||||||
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-12 | Verweis Version siehe `version.py` |
|
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-14 | §11a Breakout |
|
||||||
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
|
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
|
||||||
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-05-12 | Verweis Nutzerüberblick |
|
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-05-12 | Verweis Nutzerüberblick |
|
||||||
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040–046 Medien (Kurz) |
|
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040–046 Medien (Kurz) |
|
||||||
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-12 | Version 0.4.5, Verweis Nutzerüberblick |
|
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-14 | Parallele Streams Ist 063 |
|
||||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-05-08 | ✅ Medien/Inline-Workflow ergänzt |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-08 | ✅ Ist-Changelog + §11 Inline erweitert |
|
||||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-12 | auf 0.8.96 + P-13/P-01 + Nutzerüberblick |
|
| Parallele Streams (Fach/Technik) | `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | 2026-05-14 | Ist-Stand P1 teils |
|
||||||
|
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-14 | Keyset, KPIs, Breakout/Coach Kurzverweis |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -177,4 +181,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-12 (Version 0.8.96, Executive Summary P-13/P-01, `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
|
**Letzte Aktualisierung:** 2026-05-14 (Version 0.8.140, DB 063, Handover Coaching/Breakout)
|
||||||
|
|
|
||||||
|
|
@ -474,25 +474,23 @@ skill_level_definitions (
|
||||||
|
|
||||||
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
||||||
|
|
||||||
### Parallele Trainingsstreams (Breakout, Entwurf)
|
### Parallele Trainingsstreams (Breakout)
|
||||||
|
|
||||||
**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
|
**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
|
||||||
|
|
||||||
**Sonderfall Stationen:** Rotation kann **innerhalb** einer Stream‑Planung über **Kombinationsübungen** (Methodenprofil/Archetyp) abgebildet werden; hallenweit **synchron** getaktete Rotation ist eine **erweiterte** Ausbaustufe (siehe Fachkonzept).
|
**Sonderfall Stationen:** Rotation kann **innerhalb** einer Stream‑Planung über **Kombinationsübungen** (Methodenprofil/Archetyp) abgebildet werden; hallenweit **synchron** getaktete Rotation ist eine **erweiterte** Ausbaustufe (siehe Fachkonzept).
|
||||||
|
|
||||||
|
**Umsetzung (2026-05, Migration 063, App 0.8.137 ff.):** Tabellen **`training_unit_phases`** und **`training_unit_parallel_streams`**; **`training_unit_sections`** mit **`phase_id`** und **`parallel_stream_id`** (exakt eine Zuordnung pro Sektion). **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und flache **`sections`**. **Coaching** und **Durchführung** nutzen dieselbe Phasenlogik im Frontend (`trainingPlanUtils.js`).
|
||||||
|
|
||||||
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||||
|
|
||||||
**Schema-Hinweis (2026-05):** Tabelle `training_unit_sections` hat **`UNIQUE (training_unit_id, order_index)`** (Migration 031). Damit sind **zwei gleichzeitige „Spuren“ mit jeweils eigener Sektion auf derselben `order_index`** nicht abbildbar — Voraussetzung für Parallele Streams ist eine **geplante Migrations-/Constraint-Anpassung** (partielle Uniques pro Phase/Stream); siehe Arbeitsdokument `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`. **Keine invasive Migration ohne explizite Freigabe.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
||||||
|
|
||||||
- **`media_assets`:** Zentrale Datei-/Asset-Zeile (technisch u. a. SHA‑Dedupe, Sichtbarkeit, `club_id`, Lifecycle, Copyright, Speicherreferenz unter `library/…`). Siehe **`DATABASE_SCHEMA.md`**, **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
|
- **`media_assets`:** Zentrale Datei-/Asset-Zeile (technisch u. a. SHA‑Dedupe, Sichtbarkeit, `club_id`, Lifecycle, Copyright, Speicherreferenz unter `library/…`). Siehe **`DATABASE_SCHEMA.md`**, **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
|
||||||
- **`exercise_media`:** Verknüpfung **Übung ↔ Asset** (`media_asset_id`) oder **Embed** ohne Asset; Felder wie `context` (`ablauf` \| `detail` \| `trainer_hint`), Sortierung, Primär-Medium.
|
- **`exercise_media`:** Verknüpfung **Übung ↔ Asset** (`media_asset_id`) oder **Embed** ohne Asset; Felder wie `context` (`ablauf` \| `detail` \| `trainer_hint`), Sortierung, Primär-Medium.
|
||||||
- **`platform_media_storage`:** Konfiguration effektiver Medienwurzel (Superadmin, relativ zu `MEDIA_ROOT`).
|
- **`platform_media_storage`:** Konfiguration effektiver Medienwurzel (Superadmin, relativ zu `MEDIA_ROOT`).
|
||||||
- **Produkt:** Medienbibliothek **`/media`**; in der Übungsbearbeitung Upload, Entfernen der Verknüpfung, **Aus Archiv verknüpfen**; Governance **`official`** und Copyright-Regeln wie in der Norm beschrieben.
|
- **Produkt:** Medienbibliothek **`/media`**; in der Übungsbearbeitung Upload, Entfernen der Verknüpfung, **Aus Archiv verknüpfen**; Governance **`official`** und Copyright-Regeln wie in der Norm beschrieben.
|
||||||
- **Geplant:** **Inline-Verweise** in Fließtextfeldern auf dieselbe Verknüpfung (`exercise_media.id`) — **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**, **`docs/HANDOVER.md`** §5.
|
- **Inline-Verweise** in Fließtextfeldern: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**, **`docs/HANDOVER.md`** §5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Parallele Trainingsstreams (Breakout) — Fachkonzept
|
# Parallele Trainingsstreams (Breakout) — Fachkonzept
|
||||||
|
|
||||||
**Status:** Entwurf zur Abstimmung · **Stand:** 2026-05-14
|
**Status:** MVP-Umsetzung **teilweise** (Code) · **Stand:** 2026-05-14
|
||||||
**Ziel:** Planung und Durchführung von Training mit **phasenweise gemeinsamem** Ablauf und **beliebig vielen parallelen Teilstrecken** (Breakout-Sessions), inkl. Sonderfall **rotierende Stationen**.
|
**Ziel:** Planung und Durchführung von Training mit **phasenweise gemeinsamem** Ablauf und **beliebig vielen parallelen Teilstrecken** (Breakout-Sessions), inkl. Sonderfall **rotierende Stationen**.
|
||||||
|
|
||||||
**Technische Ausarbeitung:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
**Technische Ausarbeitung:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||||
|
|
@ -95,7 +95,14 @@ Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächste
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Verwandte Dokumente
|
## 9. Umsetzungsstand (kurz, 2026-05-14)
|
||||||
|
|
||||||
|
- **Erreicht:** Datenmodell Phasen/Streams (**063**), API **GET/PUT** mit **`phases`**, Planungs-Breakout-UI, Durchführung und Coach nutzen dieselbe Phasen-/Stream-Logik im Frontend (`trainingPlanUtils.js`). **Synchronisationspunkt** fachlich umgesetzt: vor nächster Ganzgruppenphase oder nächstem Split erscheint im Coach die **Rejoin-Karte** (mehrere Streams), sofern nicht am absoluten Planende.
|
||||||
|
- **Noch offen:** vollständige **Persistenz-Konsistenz** bei nachträglich geänderten Sektionen, **Vorlagen** mit Phasen, **Trainer pro Stream** in der UI, ggf. **Stream-Tabs** in der Durchführungsansicht wie in §5.2 skizziert — siehe **`docs/HANDOVER.md`** (Arbeitspaket-Tabelle).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Verwandte Dokumente
|
||||||
|
|
||||||
| Dokument | Bezug |
|
| Dokument | Bezug |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
|
@ -103,4 +110,5 @@ Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächste
|
||||||
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
|
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
|
||||||
| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
|
| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
|
||||||
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
|
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
|
||||||
|
| `docs/HANDOVER.md` | Ist-Stand Coach, offene Breakout-Punkte |
|
||||||
| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen |
|
| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen |
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,16 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Medien-Archiv & Medienbibliothek (Migration **045** ff., App ca. **0.8.41–0.8.64**)
|
## 12. Trainingsplan: Phasen & parallele Streams (DB **063**, App **0.8.137–0.8.140**)
|
||||||
|
|
||||||
|
- **063:** `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` / `parallel_stream_id`; Default-Ganzgruppenphase für Bestand.
|
||||||
|
- **API:** `GET /api/training-units/:id` mit **`phases`** + **`sections`**; `PUT`/`POST` mit **`phases`** für Breakout-Einheiten (**0.8.138**); Rahmen-Slot-Materialisierung kopiert Phasen (**0.8.138**).
|
||||||
|
- **Frontend:** Planung Breakout-Panel (**0.8.139–0.8.140**); **`trainingPlanUtils.js`** — `sectionsWithPlanLocForDisplay`, `flattenPlanTimeline`, `buildCoachSavePlanPayload`, Split-Rejoin (`coachShouldPromptSplitRejoinTransition`); **`TrainingCoachPage`**, **`TrainingUnitRunPage`**.
|
||||||
|
- **Doku:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`, `docs/HANDOVER.md` §3, Arbeitspaket „offen“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Medien-Archiv & Medienbibliothek (Migration **045** ff., App ca. **0.8.41–0.8.64**)
|
||||||
|
|
||||||
Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick geliefert:
|
Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick geliefert:
|
||||||
|
|
||||||
|
|
@ -150,7 +159,7 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Nächste sinnvolle Schritte (nicht Lieferstand)
|
## 14. Nächste sinnvolle Schritte (nicht Lieferstand)
|
||||||
|
|
||||||
- Trainingsplanung: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** später).
|
- Trainingsplanung: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** später).
|
||||||
- Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden).
|
- Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden).
|
||||||
|
|
@ -160,7 +169,7 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Verweise
|
## 15. Verweise
|
||||||
|
|
||||||
| Thema | Dokument |
|
| Thema | Dokument |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
|
|
@ -170,5 +179,7 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
|
||||||
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
|
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
|
||||||
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
|
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
|
||||||
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||||
|
| Parallele Phasen/Streams | `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` |
|
||||||
|
| Coaching/Breakout-Handover | `docs/HANDOVER.md` |
|
||||||
| Fachlicher Nutzerüberblick | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` (Repo-Root) |
|
| Fachlicher Nutzerüberblick | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` (Repo-Root) |
|
||||||
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |
|
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung)
|
# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung)
|
||||||
|
|
||||||
**Status:** Entwurf · **Stand:** 2026-05-14
|
**Status:** Umsetzung **Phase 1 (teils)** · **Stand:** 2026-05-14
|
||||||
**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||||
|
|
||||||
Dieses Dokument beschreibt die **Umsetzung** auf Basis der **aktuellen Codebasis** (Stand Analyse 2026-05-14): eine `training_unit` mit **`training_unit_sections`** und **`training_unit_section_items`** (Übung/Notiz, optional `planning_method_profile` für Kombinationsübungen, Migration **057**); Rahmen-**Blueprint**-Einheiten mit `framework_slot_id` (**037**); Leitung **`lead_trainer_profile_id`** (**038**); Co-Trainer **`assistant_trainer_profile_ids`** JSONB (**042**); Durchführung **`TrainingUnitRunPage`** (sequentiell über Sektionen).
|
Dieses Dokument beschreibt die **Umsetzung** auf Basis der **aktuellen Codebasis** (Stand 2026-05-14): **`training_unit_phases` / `training_unit_parallel_streams`** (Migration **063**) und **`training_unit_sections`** mit Phasen-/Stream-Bezug; **`training_unit_section_items`** (Übung/Notiz, optional `planning_method_profile` für Kombinationsübungen, Migration **057**); Rahmen-**Blueprint**-Einheiten mit `framework_slot_id` (**037**); Leitung **`lead_trainer_profile_id`** (**038**); Co-Trainer **`assistant_trainer_profile_ids`** JSONB (**042**); Durchführung und Coaching über **`TrainingUnitRunPage`**, **`TrainingCoachPage`** und **`trainingPlanUtils.js`**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Ist-Stand (relevant)
|
## 1. Ist-Stand (Code, 2026-05-14)
|
||||||
|
|
||||||
| Bereich | Aktuell |
|
| Bereich | Aktuell |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| Planstruktur | **Eine** lineare Liste `training_unit_sections` je `training_unit_id`; Items in `training_unit_section_items`. |
|
| **Schema** | Migration **063:** `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` **oder** `parallel_stream_id`. |
|
||||||
| Rahmenprogramm | `training_framework_slots` verweisen auf **Blueprint**-`training_units` — Slots = **Serien-Spalten**, nicht simultane Breakouts in **einer** Halle. |
|
| **API** | `GET /api/training-units/:id` — **`phases`** (verschachtelt) + flache **`sections`**. `PUT/POST` mit **`phases`** für Breakout-Einheiten (**0.8.138**); höchstens eines von `phases`, `sections`, `exercises` pro Request (Planning-Router). Legacy-PUT mit nur `sections` erzeugt/ergänzt Ganzgruppen-Phase. |
|
||||||
| Kombinationsübung | Ein **Item** kann Kombi sein; `planning_method_profile` = Snapshot; Coaching-UI teilweise (`CombinationPlanBracket` in Run/Peek). |
|
| **Planung (UI)** | Breakout-Panel: Ganzgruppen-/parallele Phasen, Streams; Speichern phasenbasiert (`trainingUnitSectionsForm.js`, `TrainingPlanningPage`). |
|
||||||
| Trainer-Zuweisung | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am **`training_units`**-Kopf; **keine** Zuordnung zu „welcher parallelen Spur“. |
|
| **Durchführung** | `TrainingUnitRunPage.jsx` + `trainingPlanUtils.js` (`sectionsWithPlanLocForDisplay`, `buildPlanRunViewModelFromSections`) — Phasenfolge in „Plan & Ablauf“. |
|
||||||
| Run-Modus | `TrainingUnitRunPage`: sortierte Sektionen/Items, Checkliste, Fortschritt in `sessionStorage` pro Einheit. |
|
| **Coaching** | `TrainingCoachPage.jsx` + `flattenPlanTimeline`, Stream-Picks, Rejoin vor Ganzgruppe/nächstem Split (`coachShouldPromptSplitRejoinTransition`), Nachbereitung mit `buildCoachSavePlanPayload`, danach Navigation zu `/planning/run/:id`. |
|
||||||
|
| **Kombinationsübung** | Unverändert je Item; `planning_method_profile`, Coach-Kombi-Stufe A. |
|
||||||
|
| **Trainer-Zuweisung** | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am Einheitskopf; **Stream-**`assigned_trainer_profile_ids` im Schema — UI/Policy noch nicht vollständig (siehe **§8 offen**). |
|
||||||
|
| **Rahmenprogramm** | Blueprint-`training_units` können dieselbe Phasenstruktur tragen; Kopie aus Slot (`from-framework-slot`, **0.8.138**). |
|
||||||
|
|
||||||
**Konsequenz:** Parallele Streams erfordern ein **erweitertes konzeptionelles „Gefäß“** unterhalb der Einheit (Phasen und/oder Streams) und eine **Verknüpfung** bestehender Sektionen mit diesem Gefäß — oder eine **Migration** zu einem neuen Pflicht-Container (siehe §3).
|
**Hinweis:** Die frühere Planungsvariante „nur lineare `training_unit_sections` ohne Phasen“ gilt weiter für Alt-Daten; Migration **063** ordnet Bestand einer Default-Ganzgruppenphase zu.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -37,9 +40,11 @@ training_unit (Kalender-Einheit)
|
||||||
|
|
||||||
## 3. Datenmodell — Optionen
|
## 3. Datenmodell — Optionen
|
||||||
|
|
||||||
|
**Ist (063):** Die unten skizzierte **empfohlene** Normalform ist unter den genannten Tabellennamen produktiv; die Abschnitte 3.1/3.2 bleiben zur Einordnung erhalten.
|
||||||
|
|
||||||
### 3.1 Empfohlen: explizite Phasen + Streams (normalisiert)
|
### 3.1 Empfohlen: explizite Phasen + Streams (normalisiert)
|
||||||
|
|
||||||
Neue Tabellen (Namen bei Implementierung final festlegen):
|
Die Tabellen sind **umgesetzt** (Namen final):
|
||||||
|
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
|
|
@ -109,15 +114,15 @@ Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; P
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Implementierungsphasen (Vorschlag)
|
## 8. Implementierungsphasen (Abgleich)
|
||||||
|
|
||||||
| Phase | Inhalt |
|
| Phase | Inhalt | Stand 2026-05-14 |
|
||||||
|-------|--------|
|
|-------|--------|------------------|
|
||||||
| **P1** | Schema Phasen + Streams; Migration; GET/PATCH Einheit verschachtelt; Planungs-UI; Run-UI mit Stream-Tabs |
|
| **P1** | Schema Phasen + Streams; Migration **063**; GET/PUT verschachtelt; Planungs-UI; Run + Coach phasenbasiert | **Teilweise erledigt** — Run-UI nutzt Phasen-Timeline in der Anzeige; **Stream-Tabs** optional noch zu vereinheitlichen (§5.2) |
|
||||||
| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert |
|
| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert | **Offen** |
|
||||||
| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) |
|
| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) | **Offen** |
|
||||||
|
|
||||||
---
|
**Offene Punkte (kurz):** siehe **`docs/HANDOVER.md`** Tabelle „Coaching & Breakout“.
|
||||||
|
|
||||||
## 9. Verwandte Dokumente
|
## 9. Verwandte Dokumente
|
||||||
|
|
||||||
|
|
@ -127,4 +132,5 @@ Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; P
|
||||||
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität |
|
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität |
|
||||||
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` |
|
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` |
|
||||||
| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie |
|
| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie |
|
||||||
| `frontend/src/pages/TrainingPlanningPage.jsx`, `TrainingUnitRunPage.jsx`, `TrainingFrameworkProgramEditPage.jsx` | Ist-UI |
|
| `TrainingPlanningPage.jsx`, `TrainingUnitRunPage.jsx`, `TrainingFrameworkProgramEditPage.jsx` | Planung, Durchführung, Rahmen |
|
||||||
|
| `frontend/src/utils/trainingPlanUtils.js`, `TrainingCoachPage.jsx` | Phasen-Timeline, Rejoin, Coach-Speichern |
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ frontend/src/
|
||||||
|
|
||||||
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
|
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
|
||||||
|
|
||||||
Kurz (Stand 2026-05-12): App **0.8.96**, DB‑Schema‑Version 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 + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, Nutzerüberblick **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (Abschnitt 11 umgesetzt).
|
Kurz (Stand 2026-05-14): App- und DB-Version 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 **Phasen & parallelen Streams (Breakout, 063)**, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, Nutzerüberblick **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**, `PARALLEL_TRAINING_STREAMS_SPEC.md`, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (Abschnitt 11 umgesetzt).
|
||||||
|
|
||||||
### Log (Auszug)
|
### Log (Auszug)
|
||||||
|
|
||||||
|
|
|
||||||
8
backend/migrations/064_training_plan_template_phases.sql
Normal file
8
backend/migrations/064_training_plan_template_phases.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Vorlagen: Phasen/Parallel-Streams wie im Einheiten-Editor (planLoc-Abbild)
|
||||||
|
ALTER TABLE training_plan_template_sections
|
||||||
|
ADD COLUMN IF NOT EXISTS phase_kind VARCHAR(20) NOT NULL DEFAULT 'whole_group',
|
||||||
|
ADD COLUMN IF NOT EXISTS phase_order_index INT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS parallel_stream_order_index INT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN training_plan_template_sections.parallel_stream_order_index IS
|
||||||
|
'NULL = Ganzgruppen-Abschnitt; 0..n = Stream innerhalb paralleler Phase';
|
||||||
|
|
@ -21,6 +21,7 @@ from routers.training_planning import (
|
||||||
_hydrate_training_unit_payload,
|
_hydrate_training_unit_payload,
|
||||||
_optional_positive_int,
|
_optional_positive_int,
|
||||||
_insert_sections_from_legacy_exercises,
|
_insert_sections_from_legacy_exercises,
|
||||||
|
_replace_unit_phases,
|
||||||
_replace_unit_sections,
|
_replace_unit_sections,
|
||||||
_validate_variant_for_exercise,
|
_validate_variant_for_exercise,
|
||||||
)
|
)
|
||||||
|
|
@ -132,6 +133,7 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
row_b = cur.fetchone()
|
row_b = cur.fetchone()
|
||||||
if not row_b:
|
if not row_b:
|
||||||
s["blueprint_training_unit_id"] = None
|
s["blueprint_training_unit_id"] = None
|
||||||
|
s["phases"] = []
|
||||||
s["sections"] = []
|
s["sections"] = []
|
||||||
s["exercises"] = []
|
s["exercises"] = []
|
||||||
continue
|
continue
|
||||||
|
|
@ -139,6 +141,7 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
s["blueprint_training_unit_id"] = uid
|
s["blueprint_training_unit_id"] = uid
|
||||||
unit_min: Dict[str, Any] = {"id": uid}
|
unit_min: Dict[str, Any] = {"id": uid}
|
||||||
_hydrate_training_unit_payload(cur, unit_min)
|
_hydrate_training_unit_payload(cur, unit_min)
|
||||||
|
s["phases"] = unit_min.get("phases", [])
|
||||||
s["sections"] = unit_min.get("sections", [])
|
s["sections"] = unit_min.get("sections", [])
|
||||||
s["exercises"] = unit_min.get("exercises", [])
|
s["exercises"] = unit_min.get("exercises", [])
|
||||||
row["slots"] = slots
|
row["slots"] = slots
|
||||||
|
|
@ -250,6 +253,7 @@ def _insert_slots_and_blueprints(
|
||||||
framework_id: int,
|
framework_id: int,
|
||||||
slots_in: Optional[List[Any]],
|
slots_in: Optional[List[Any]],
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if slots_in is None:
|
if slots_in is None:
|
||||||
return
|
return
|
||||||
|
|
@ -296,10 +300,13 @@ def _insert_slots_and_blueprints(
|
||||||
)
|
)
|
||||||
bid = cur.fetchone()["id"]
|
bid = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
phases_in = slot.get("phases")
|
||||||
sections_in = slot.get("sections")
|
sections_in = slot.get("sections")
|
||||||
exercises_in = slot.get("exercises")
|
exercises_in = slot.get("exercises")
|
||||||
|
|
||||||
if sections_in is not None:
|
if phases_in is not None and isinstance(phases_in, list) and len(phases_in) > 0:
|
||||||
|
_replace_unit_phases(cur, bid, phases_in, profile_id, role, profile_id)
|
||||||
|
elif sections_in is not None:
|
||||||
if len(sections_in) == 0:
|
if len(sections_in) == 0:
|
||||||
_insert_default_blueprint_section(cur, bid)
|
_insert_default_blueprint_section(cur, bid)
|
||||||
else:
|
else:
|
||||||
|
|
@ -432,7 +439,7 @@ def create_training_framework_program(
|
||||||
)
|
)
|
||||||
fid = cur.fetchone()["id"]
|
fid = cur.fetchone()["id"]
|
||||||
_insert_goal_rows(cur, fid, goals_in)
|
_insert_goal_rows(cur, fid, goals_in)
|
||||||
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id)
|
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role)
|
||||||
_replace_training_types(cur, fid, tt_ids)
|
_replace_training_types(cur, fid, tt_ids)
|
||||||
_replace_target_groups(cur, fid, tg_ids)
|
_replace_target_groups(cur, fid, tg_ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -543,7 +550,9 @@ def update_training_framework_program(
|
||||||
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
|
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
|
||||||
(framework_id,),
|
(framework_id,),
|
||||||
)
|
)
|
||||||
_insert_slots_and_blueprints(cur, framework_id, data.get("slots") or [], profile_id)
|
_insert_slots_and_blueprints(
|
||||||
|
cur, framework_id, data.get("slots") or [], profile_id, role
|
||||||
|
)
|
||||||
|
|
||||||
if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data:
|
if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -1523,32 +1523,187 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List
|
||||||
_insert_section_items(cur, sid, filtered, start_order=0)
|
_insert_section_items(cur, sid, filtered, start_order=0)
|
||||||
|
|
||||||
|
|
||||||
def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict[str, Any]:
|
||||||
_clear_unit_plan_content(cur, unit_id)
|
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
||||||
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
order_ix = sec.get("order_index")
|
||||||
|
if order_ix is None:
|
||||||
|
order_ix = si
|
||||||
|
try:
|
||||||
|
order_ix = int(order_ix)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
order_ix = si
|
||||||
|
pk = str(sec.get("phase_kind") or "whole_group").strip().lower()
|
||||||
|
if pk not in ("whole_group", "parallel"):
|
||||||
|
pk = "whole_group"
|
||||||
|
try:
|
||||||
|
p_oi = int(sec.get("phase_order_index") if sec.get("phase_order_index") is not None else 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
p_oi = 0
|
||||||
|
p_so: Optional[int] = None
|
||||||
|
if pk == "parallel":
|
||||||
|
raw_so = sec.get("parallel_stream_order_index")
|
||||||
|
try:
|
||||||
|
p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
p_so = 0
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"order_index": order_ix,
|
||||||
|
"guidance_text": sec.get("guidance_text"),
|
||||||
|
"phase_kind": pk,
|
||||||
|
"phase_order_index": p_oi,
|
||||||
|
"parallel_stream_order_index": p_so,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_training_plan_template_sections(cur, template_id: int, sections_in: List[Any]) -> None:
|
||||||
|
for si, sec in enumerate(sections_in):
|
||||||
|
row = _normalize_training_plan_template_section_payload(sec, si)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_plan_template_sections (
|
||||||
|
template_id, order_index, title, guidance_text,
|
||||||
|
phase_kind, phase_order_index, parallel_stream_order_index
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
template_id,
|
||||||
|
row["order_index"],
|
||||||
|
row["title"],
|
||||||
|
row["guidance_text"],
|
||||||
|
row["phase_kind"],
|
||||||
|
row["phase_order_index"],
|
||||||
|
row["parallel_stream_order_index"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Flache Vorlagen-Sektionen → `phases`-Liste wie beim Training-Unit PUT (nur Gliederung, leere items)."""
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
phases_out: List[Dict[str, Any]] = []
|
||||||
|
i = 0
|
||||||
|
n = len(rows)
|
||||||
|
while i < n:
|
||||||
|
r0 = rows[i]
|
||||||
|
pk0 = str(r0.get("phase_kind") or "whole_group").strip().lower()
|
||||||
|
if pk0 not in ("whole_group", "parallel"):
|
||||||
|
pk0 = "whole_group"
|
||||||
|
try:
|
||||||
|
p_oix0 = int(r0.get("phase_order_index") if r0.get("phase_order_index") is not None else 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
p_oix0 = 0
|
||||||
|
run: List[Dict[str, Any]] = []
|
||||||
|
while i < n:
|
||||||
|
r = rows[i]
|
||||||
|
pk = str(r.get("phase_kind") or "whole_group").strip().lower()
|
||||||
|
if pk not in ("whole_group", "parallel"):
|
||||||
|
pk = "whole_group"
|
||||||
|
try:
|
||||||
|
p_oix = int(r.get("phase_order_index") if r.get("phase_order_index") is not None else 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
p_oix = 0
|
||||||
|
if pk != pk0 or p_oix != p_oix0:
|
||||||
|
break
|
||||||
|
run.append(r)
|
||||||
|
i += 1
|
||||||
|
if pk0 == "whole_group":
|
||||||
|
secs = []
|
||||||
|
for j, rr in enumerate(run):
|
||||||
|
tid = rr.get("id")
|
||||||
|
secs.append(
|
||||||
|
{
|
||||||
|
"title": rr.get("title"),
|
||||||
|
"order_index": j,
|
||||||
|
"guidance_notes": rr.get("guidance_text"),
|
||||||
|
"items": [],
|
||||||
|
**(
|
||||||
|
{"source_template_section_id": int(tid)}
|
||||||
|
if tid is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
phases_out.append(
|
||||||
|
{
|
||||||
|
"phase_kind": "whole_group",
|
||||||
|
"order_index": p_oix0,
|
||||||
|
"title": None,
|
||||||
|
"guidance_notes": None,
|
||||||
|
"sections": secs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
by_stream: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
|
for rr in run:
|
||||||
|
raw_so = rr.get("parallel_stream_order_index")
|
||||||
|
try:
|
||||||
|
so = int(raw_so) if raw_so is not None and raw_so != "" else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
so = 0
|
||||||
|
by_stream.setdefault(so, []).append(rr)
|
||||||
|
stream_order = sorted(by_stream.keys())
|
||||||
|
streams = []
|
||||||
|
for so in stream_order:
|
||||||
|
bucket = by_stream[so]
|
||||||
|
st: Dict[str, Any] = {
|
||||||
|
"order_index": so,
|
||||||
|
"title": None,
|
||||||
|
"notes": None,
|
||||||
|
"sections": [],
|
||||||
|
}
|
||||||
|
for j, rr in enumerate(bucket):
|
||||||
|
tid = rr.get("id")
|
||||||
|
st["sections"].append(
|
||||||
|
{
|
||||||
|
"title": rr.get("title"),
|
||||||
|
"order_index": j,
|
||||||
|
"guidance_notes": rr.get("guidance_text"),
|
||||||
|
"items": [],
|
||||||
|
**(
|
||||||
|
{"source_template_section_id": int(tid)}
|
||||||
|
if tid is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
streams.append(st)
|
||||||
|
phases_out.append(
|
||||||
|
{
|
||||||
|
"phase_kind": "parallel",
|
||||||
|
"order_index": p_oix0,
|
||||||
|
"title": None,
|
||||||
|
"guidance_notes": None,
|
||||||
|
"streams": streams,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return phases_out
|
||||||
|
|
||||||
|
|
||||||
|
def _instantiate_from_template(
|
||||||
|
cur,
|
||||||
|
unit_id: int,
|
||||||
|
template_id: int,
|
||||||
|
*,
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
unit_created_by: int,
|
||||||
|
) -> None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, title, guidance_text
|
SELECT id, title, guidance_text, order_index, phase_kind, phase_order_index, parallel_stream_order_index
|
||||||
FROM training_plan_template_sections
|
FROM training_plan_template_sections
|
||||||
WHERE template_id = %s
|
WHERE template_id = %s
|
||||||
ORDER BY order_index
|
ORDER BY order_index
|
||||||
""",
|
""",
|
||||||
(template_id,),
|
(template_id,),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows_raw = cur.fetchall()
|
||||||
for gi, row in enumerate(rows):
|
rows = [r2d(r) for r in rows_raw]
|
||||||
r = r2d(row)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO training_unit_sections (
|
|
||||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
|
||||||
) VALUES (%s, %s, NULL, %s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fallback: keine Sektionen in Vorlage → ein leerer Block
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
_clear_unit_plan_content(cur, unit_id)
|
||||||
|
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_unit_sections (
|
INSERT INTO training_unit_sections (
|
||||||
|
|
@ -1557,6 +1712,18 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
||||||
""",
|
""",
|
||||||
(unit_id, pid),
|
(unit_id, pid),
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
phases_payload = _template_rows_to_phases_payload(rows)
|
||||||
|
_clear_unit_plan_content(cur, unit_id)
|
||||||
|
_replace_unit_phases(
|
||||||
|
cur,
|
||||||
|
unit_id,
|
||||||
|
phases_payload,
|
||||||
|
profile_id,
|
||||||
|
role,
|
||||||
|
unit_created_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
|
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
|
||||||
|
|
@ -1674,18 +1841,7 @@ def create_training_plan_template(data: dict, tenant: TenantContext = Depends(ge
|
||||||
(club_id, profile_id, name, data.get("description"), visibility),
|
(club_id, profile_id, name, data.get("description"), visibility),
|
||||||
)
|
)
|
||||||
tid = cur.fetchone()["id"]
|
tid = cur.fetchone()["id"]
|
||||||
for si, sec in enumerate(sections_in):
|
_insert_training_plan_template_sections(cur, tid, sections_in)
|
||||||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
|
||||||
order_ix = sec.get("order_index")
|
|
||||||
if order_ix is None:
|
|
||||||
order_ix = si
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(tid, order_ix, title, sec.get("guidance_text")),
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return get_training_plan_template(tid, tenant)
|
return get_training_plan_template(tid, tenant)
|
||||||
|
|
||||||
|
|
@ -1743,18 +1899,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
|
||||||
"DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,)
|
"DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,)
|
||||||
)
|
)
|
||||||
sections_in = data["sections"] or []
|
sections_in = data["sections"] or []
|
||||||
for si, sec in enumerate(sections_in):
|
_insert_training_plan_template_sections(cur, template_id, sections_in)
|
||||||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
|
||||||
order_ix = sec.get("order_index")
|
|
||||||
if order_ix is None:
|
|
||||||
order_ix = si
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(template_id, order_ix, title, sec.get("guidance_text")),
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return get_training_plan_template(template_id, tenant)
|
return get_training_plan_template(template_id, tenant)
|
||||||
|
|
||||||
|
|
@ -2400,7 +2545,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
||||||
elif sections_in is not None:
|
elif sections_in is not None:
|
||||||
_replace_unit_sections(cur, unit_id, sections_in)
|
_replace_unit_sections(cur, unit_id, sections_in)
|
||||||
elif tpl_id_safe:
|
elif tpl_id_safe:
|
||||||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
_instantiate_from_template(
|
||||||
|
cur,
|
||||||
|
unit_id,
|
||||||
|
tpl_id_safe,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
unit_created_by=profile_id,
|
||||||
|
)
|
||||||
elif exercises_in is not None:
|
elif exercises_in is not None:
|
||||||
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
|
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
|
||||||
|
|
||||||
|
|
@ -2576,7 +2728,14 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
|
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
|
||||||
)
|
)
|
||||||
_instantiate_from_template(cur, unit_id, tid)
|
_instantiate_from_template(
|
||||||
|
cur,
|
||||||
|
unit_id,
|
||||||
|
tid,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
unit_created_by=int(unit_row.get("created_by") or profile_id),
|
||||||
|
)
|
||||||
content_handled = True
|
content_handled = True
|
||||||
|
|
||||||
_assert_single_plan_content_key_update(data)
|
_assert_single_plan_content_key_update(data)
|
||||||
|
|
@ -2783,7 +2942,14 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t
|
||||||
unit_id = cur.fetchone()["id"]
|
unit_id = cur.fetchone()["id"]
|
||||||
|
|
||||||
if tpl_id_safe:
|
if tpl_id_safe:
|
||||||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
_instantiate_from_template(
|
||||||
|
cur,
|
||||||
|
unit_id,
|
||||||
|
tpl_id_safe,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
unit_created_by=profile_id,
|
||||||
|
)
|
||||||
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
206
backend/tests/test_training_framework_phases_integration.py
Normal file
206
backend/tests/test_training_framework_phases_integration.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""
|
||||||
|
PostgreSQL-Integration: Rahmenprogramm-Slot mit verschachtelten `phases` (Blueprint-Unit).
|
||||||
|
|
||||||
|
Aktivierung:
|
||||||
|
- Lokal: TRAINING_PLANNING_INTEGRATION=1
|
||||||
|
- CI: .gitea/workflows/test.yml setzt die Variable beim pytest-Lauf.
|
||||||
|
|
||||||
|
Prüft `_insert_slots_and_blueprints` → `_replace_unit_phases` wie beim API-PUT mit Slot-Payload.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
from routers.training_framework_programs import _insert_slots_and_blueprints
|
||||||
|
from routers.training_planning import _fetch_phases_nested
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_enabled() -> bool:
|
||||||
|
return os.getenv("TRAINING_PLANNING_INTEGRATION", "").strip().lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.integration,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not _integration_enabled(),
|
||||||
|
reason="TRAINING_PLANNING_INTEGRATION=1 und PostgreSQL (DB_*) erforderlich",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _db_ping() -> bool:
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT 1 AS ok")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row is not None and row.get("ok") == 1
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def db_ready():
|
||||||
|
if not _db_ping():
|
||||||
|
pytest.skip("PostgreSQL nicht erreichbar (DB_HOST/DB_PORT/…)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_framework_blueprint_slot_phases_roundtrip(db_ready):
|
||||||
|
"""Ein Slot mit `phases` erzeugt eine Blueprint-Unit mit identischer Phasenstruktur."""
|
||||||
|
suffix = uuid.uuid4().hex[:12]
|
||||||
|
club_name = f"fw_ph_club_{suffix}"
|
||||||
|
email = f"fw_ph_{suffix}@test.local"
|
||||||
|
|
||||||
|
from auth import hash_pin
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO clubs (name, abbreviation, status) VALUES (%s, %s, %s) RETURNING id",
|
||||||
|
(club_name, "F", "active"),
|
||||||
|
)
|
||||||
|
club_id = int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profiles (email, pin_hash, name, role, active_club_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(email, hash_pin("x"), f"FWPH {suffix}", "trainer", club_id),
|
||||||
|
)
|
||||||
|
profile_id = int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO exercises (title, goal, execution, visibility, status, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(f"Übung FWPH {suffix}", "Ziel", "Ablauf", "private", "draft", profile_id),
|
||||||
|
)
|
||||||
|
ex_id = int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_framework_programs (
|
||||||
|
title, description,
|
||||||
|
planned_period_start, planned_period_end,
|
||||||
|
visibility, club_id, created_by,
|
||||||
|
focus_area_id, style_direction_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"Rahmen FWPH {suffix}",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"private",
|
||||||
|
club_id,
|
||||||
|
profile_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fw_id = int(cur.fetchone()["id"])
|
||||||
|
|
||||||
|
phases_in = [
|
||||||
|
{
|
||||||
|
"phase_kind": "whole_group",
|
||||||
|
"order_index": 0,
|
||||||
|
"title": "Aufwärmen",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Gemeinsam",
|
||||||
|
"order_index": 0,
|
||||||
|
"items": [
|
||||||
|
{"item_type": "note", "order_index": 0, "note_body": "Los"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"phase_kind": "parallel",
|
||||||
|
"order_index": 1,
|
||||||
|
"title": "Breakout",
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"order_index": 0,
|
||||||
|
"title": "Matte A",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Technik A",
|
||||||
|
"order_index": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_type": "exercise",
|
||||||
|
"order_index": 0,
|
||||||
|
"exercise_id": ex_id,
|
||||||
|
"planned_duration_min": 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
slots_in = [
|
||||||
|
{
|
||||||
|
"sort_order": 0,
|
||||||
|
"title": "Session 1",
|
||||||
|
"notes": None,
|
||||||
|
"phases": phases_in,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_insert_slots_and_blueprints(cur, fw_id, slots_in, profile_id, "trainer")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM training_framework_slots
|
||||||
|
WHERE framework_program_id = %s
|
||||||
|
ORDER BY sort_order
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(fw_id,),
|
||||||
|
)
|
||||||
|
slot_row = cur.fetchone()
|
||||||
|
assert slot_row is not None
|
||||||
|
slot_id = int(slot_row["id"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM training_units WHERE framework_slot_id = %s",
|
||||||
|
(slot_id,),
|
||||||
|
)
|
||||||
|
bu_row = cur.fetchone()
|
||||||
|
assert bu_row is not None
|
||||||
|
blueprint_unit_id = int(bu_row["id"])
|
||||||
|
|
||||||
|
nested = _fetch_phases_nested(cur, blueprint_unit_id)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert len(nested) == 2
|
||||||
|
assert nested[0]["phase_kind"] == "whole_group"
|
||||||
|
assert len(nested[0].get("sections") or []) == 1
|
||||||
|
assert nested[1]["phase_kind"] == "parallel"
|
||||||
|
streams = nested[1].get("streams") or []
|
||||||
|
assert len(streams) == 1
|
||||||
|
assert len(streams[0].get("sections") or []) == 1
|
||||||
|
assert streams[0]["sections"][0]["title"] == "Technik A"
|
||||||
|
assert int(streams[0]["sections"][0]["items"][0]["exercise_id"]) == ex_id
|
||||||
|
finally:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("DELETE FROM training_framework_programs WHERE id = %s", (fw_id,))
|
||||||
|
cur.execute("DELETE FROM exercises WHERE id = %s", (ex_id,))
|
||||||
|
cur.execute("DELETE FROM profiles WHERE id = %s", (profile_id,))
|
||||||
|
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.140"
|
APP_VERSION = "0.8.141"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-14"
|
||||||
DB_SCHEMA_VERSION = "20260515063"
|
DB_SCHEMA_VERSION = "20260515064"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -24,7 +24,7 @@ MODULE_VERSIONS = {
|
||||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||||
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
|
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index
|
"planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases
|
||||||
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
|
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
|
||||||
"training_modules": "1.0.0",
|
"training_modules": "1.0.0",
|
||||||
"import_wiki": "1.0.0",
|
"import_wiki": "1.0.0",
|
||||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.141",
|
||||||
|
"date": "2026-05-14",
|
||||||
|
"changes": [
|
||||||
|
"DB 064: Vorlagen-Sektionen mit phase_kind / phase_order_index / parallel_stream_order_index; Speichern und Anwenden behält Split-Sessions; Server: Vorlage → Einheit über Phasen-Replace.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.140",
|
"version": "0.8.140",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,12 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
|
||||||
### 4.4 Trainingsplanung
|
### 4.4 Trainingsplanung
|
||||||
|
|
||||||
- **Trainingseinheiten** als planbare Objekte mit **Sektionen** und **Einträgen** (Übungen, ggf. mit **Variante** und Metadaten wie Dauer).
|
- **Trainingseinheiten** als planbare Objekte mit **Sektionen** und **Einträgen** (Übungen, ggf. mit **Variante** und Metadaten wie Dauer).
|
||||||
- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden.
|
- **Phasen & parallele Streams (Breakout):** Eine Einheit kann aus abwechselnden **Ganzgruppenphasen** und **Parallelphasen** bestehen; in einer Parallelphase führen **mehrere Streams** (Teilstrecken) je eigene Abschnitte/Übungen. Planung über Breakout-UI; API liefert **`phases`** und flache **`sections`** (Migration **063**, siehe **`docs/HANDOVER.md`**). Technische Details: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||||
|
- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden (Phasen in Vorlagen: Ausbau siehe Handover „offen“).
|
||||||
- **Trainingsrahmenprogramm (Bibliothek):** übergeordnete Programme mit **Zielen** und **Slots**; Slot-Inhalt technisch als **Blueprint-Trainingsunit** abgebildet.
|
- **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).
|
- **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).
|
- **Durchführung („Plan & Ablauf“):** Ablauf anhand Phasen/Streams darstellen und abarbeiten (inkl. Split-Logik in der Anzeige).
|
||||||
|
- **Coaching-Modus:** eigener Ablauf mit Schritt-für-Schritt-Timeline, Stream-Wahl pro Parallelphase, Hinweis **„Parallelphase · Abschluss“** (Gruppen zusammenführen) vor der nächsten Ganzgruppenphase oder vor dem nächsten Split; **Nachbereitung** mit Ist-Minuten und Speichern wie in der Planung (inkl. **`phases`**). Nach erfolgreichem Speichern Wechsel zur **Plan- und Ablaufsicht** derselben Einheit. Bei **Kombinationsübungen** zusätzlich **Stations-/Kandidaten-Schicht und Archetyp-Hinweise** (Fachspez **Anhang A**; Ausbauschritte B/C).
|
||||||
|
|
||||||
### 4.5 Medienbibliothek und Archiv
|
### 4.5 Medienbibliothek und Archiv
|
||||||
|
|
||||||
|
|
@ -118,7 +120,8 @@ Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen**
|
||||||
- Kalender-UX: **„Aus Rahmen übernehmen“** flächendeckend und ggf. bulkfähig anbinden.
|
- Kalender-UX: **„Aus Rahmen übernehmen“** flächendeckend und ggf. bulkfähig anbinden.
|
||||||
- **Policies** für geteilte Rahmen (Wer darf Bibliotheks-Rahmen sehen/kopieren?).
|
- **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.
|
- **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 4b–d); **geführtes Erfassen** von `method_profile` im Übungseditor.
|
- **Coach / Kombination:** Kombi-spezifische **Archetyp-Stufen B/C** (Zeitleisten-Splitting, archetypnahe Timer — Fachspez § 10.4); **geführtes Erfassen** von `method_profile` im Übungseditor.
|
||||||
|
- **Breakout:** vollständige **Server-Spiegelung** neuer Abschnitte in **`phases`**; **Vorlagen** phasengleich mit Kalendereinheit; optional **Stream-Tabs** in der Durchführungsansicht laut technischer Spec — siehe **`docs/HANDOVER.md`** (Arbeitspaket „Coaching & Breakout“).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -126,5 +129,6 @@ Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen**
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
|
| 2026-05-14 | Trainingsplanung: Phasen/parallele Streams, Coaching (Rejoin, Nachbereitung → Planansicht); Lücken §5 ergänzt. Verweis `HANDOVER.md`. |
|
||||||
| 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 | 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`. |
|
| 2026-05-12 | Kombinationsübungen + Coaching Stufe A; Verweise auf Fachspezifikation (`…Kombinationsuebungen…` V3 Anhang A) und `TRAINING_MODULES_IMPLEMENTATION_PLAN.md`. |
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-13
|
**Stand:** 2026-05-14
|
||||||
**App-Version / DB-Schema:** App **0.8.120**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
|
**App-Version / DB-Schema:** App **`0.8.140`** (u. a. Planungs-Breakout-UI), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `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**.
|
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**.
|
||||||
|
|
||||||
|
|
@ -76,7 +76,32 @@ 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`**.
|
- **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`.
|
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
|
||||||
|
|
||||||
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.120**)
|
### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.137–0.8.140**)
|
||||||
|
|
||||||
|
- **Schema / API:** Migration **063** — `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` bzw. `parallel_stream_id`. **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und weiterhin flache **`sections`**. **`PUT/POST`** mit **`phases`** für Breakout-Einheiten (vgl. `CHANGELOG` **0.8.138**); Legacy: flache `sections` → implizite Ganzgruppen-Phase.
|
||||||
|
- **Planung (Frontend):** Breakout-Panel — neue Ganzgruppen-/parallele Phase, Streams in der Parallelphase; Speichern sendet `phases` bei phasierten Einheiten (`trainingUnitSectionsForm.js`, `TrainingPlanningPage`).
|
||||||
|
- **Durchführung „Plan & Ablauf“:** `TrainingUnitRunPage.jsx` nutzt **`sectionsWithPlanLocForDisplay`** / **`buildPlanRunViewModelFromSections`** aus **`frontend/src/utils/trainingPlanUtils.js`**, damit Anzeige mit Phasen/Streams konsistent ist (inkl. Normalisierung fehlender `planLoc` auf flachen Sektionen).
|
||||||
|
- **Coaching-Modus (`TrainingCoachPage.jsx`):**
|
||||||
|
- Flache Timeline aus Phasen (`flattenPlanTimeline`): **Split-Punkte** (`branch_gate`) bis Stream-Wahl, eine gewählte Spur pro Parallelphase, **Outline**, Timer, Ist-Minuten pro Item.
|
||||||
|
- **Rejoin nach Parallelphase:** Beim Übergang **Parallel → Ganzgruppe** (oder **Parallel → nächster Split** / `branch_gate`) erscheint die Karte „Parallelphase · Abschluss“ mit **„Gruppen zusammengeführt — weiter mit dem Plan“**, solange noch Einträge folgen; am Planende weiter **„Zur Nachbereitung“** (`coachShouldPromptSplitRejoinTransition` in `trainingPlanUtils.js`).
|
||||||
|
- **Nachbereitung / Speichern:** Payload über **`buildCoachSavePlanPayload`** (wie Planungseditor: **`phases`**, keine Zerstörung phasierter Struktur). Nach erfolgreichem Speichern: Coach-Session-Storage bereinigt, **`navigate('/planning/run/:unitId', { replace: true })`** (Plan & Ablauf).
|
||||||
|
- **Robustheit:** Abschnitte ohne Eintrag im `phases`-Baum, die nach Erben fälschlich **`parallel`** wären, werden für die **Anzeige** als **Ganzgruppenblock** nach der letzten bekannten Phasen-Ordnung ausgewiesen (`sectionsWithPlanLocForDisplay`).
|
||||||
|
- **Konzept / technische Spec:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||||
|
|
||||||
|
#### Arbeitspaket „Coaching & Breakout“ — noch offen
|
||||||
|
|
||||||
|
| # | Thema | Kurzbeschreibung |
|
||||||
|
|---|--------|------------------|
|
||||||
|
| 1 | **Backend / Datenkonsistenz** | Neue oder verschobene Sektionen konsistent in **`phases`** persistieren (nicht nur Client-Normalisierung). |
|
||||||
|
| 2 | **UX nach Speichern** | Optional: **im Coach bleiben** vs. **Planansicht** (aktuell: immer Plan & Ablauf). |
|
||||||
|
| 3 | **Kantenfälle Coach** | „Fertig“ bei abweichendem **Timer-Owner** vs. **Rejoin**/letzter Schritt prüfen. |
|
||||||
|
| 4 | **Tests** | Smoke: zwei Splitphasen, Ganzgruppe dazwischen/am Ende, Nachbereitung speichern, Rejoin. |
|
||||||
|
| 5 | **Run-UI vs. Spec** | Technische Spec §5.2 (Tabs pro Stream): Abgleich mit **`TrainingUnitRunPage`**. |
|
||||||
|
| 6 | **Trainer pro Stream** | UI und Policy zu **`assigned_trainer_profile_ids`** / Kopf-Co-Trainer offen. |
|
||||||
|
| 7 | **Vorlagen** | `training_plan_templates` phasen-/stream-kompatibel (Spec §5.3). |
|
||||||
|
| 8 | **Kombi-Coach B/C** | Fachspez **§ 10.4 / § 10.6**, `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` Phase **4**. |
|
||||||
|
|
||||||
|
### Trainingsmodule, Kombinationsübungen und Coach Stufe A
|
||||||
|
|
||||||
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
|
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich).
|
||||||
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
|
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
|
||||||
|
|
@ -155,17 +180,18 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
|
|
||||||
## 7. 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).
|
1. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle).
|
||||||
2. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
|
2. **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).
|
||||||
3. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
|
3. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
|
||||||
4. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
|
4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
|
||||||
5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
|
5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
|
||||||
6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
|
6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
|
||||||
7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
|
7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
|
||||||
8. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
|
8. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
|
||||||
9. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
|
9. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
|
||||||
10. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
|
10. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
|
||||||
11. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
|
11. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
|
||||||
|
12. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -174,7 +200,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
| Bereich | Einstieg |
|
| Bereich | Einstieg |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| 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` |
|
| 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 / Merge-Profil (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `CombinationPlanBracket.jsx`, `utils/comboPlanningMethodProfile.js`, `utils/combinationMethodProfileUi.js`, `constants/combinationArchetypes.js` |
|
| Coach, Plan-Timeline, PUT-Payload phasiert | `TrainingCoachPage.jsx`, **`frontend/src/utils/trainingPlanUtils.js`** (`flattenPlanTimeline`, `buildCoachSavePlanPayload`, `sectionsWithPlanLocForDisplay`, Split-Rejoin-Helfer), `TrainingUnitRunPage.jsx` |
|
||||||
|
| Coach-Kombination / Merge-Profil (Frontend) | `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `CombinationPlanBracket.jsx`, `utils/comboPlanningMethodProfile.js`, `utils/combinationMethodProfileUi.js`, `constants/combinationArchetypes.js` |
|
||||||
| Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
|
| Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
|
||||||
| Frontend API | `frontend/src/utils/api.js` |
|
| Frontend API | `frontend/src/utils/api.js` |
|
||||||
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |
|
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
||||||
- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 9–10**.
|
- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 9–10**.
|
||||||
- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; **Welle 3** `exercises.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...exercises`, `...planning`).
|
- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; **Welle 3** `exercises.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...exercises`, `...planning`).
|
||||||
|
- **Trainingsplan Breakout / Coach (2026-05-14):** Phasen + parallele Streams (**063**, Frontend **0.8.137–0.8.140**), Coach-Rejoin und Nachbereitung — siehe **`docs/HANDOVER.md`**, **`technical/PARALLEL_TRAINING_STREAMS_SPEC.md`**.
|
||||||
|
|
||||||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||||||
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number, toParallelStream?: { po: number, so: number } }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben
|
* @param {(p: { fromSlot: number, fromSectionIdx?: number, toSlot: number, toSectionIdx: number, toParallelStream?: { po: number, so: number }, parallelPhaseRunOrderIndex?: number, insertBeforeParallelInTarget?: number, firstInParallelStreamInTarget?: { po: number, so: number } }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt(e) zwischen Slots verschieben
|
||||||
*/
|
*/
|
||||||
export default function TrainingUnitSectionsEditor({
|
export default function TrainingUnitSectionsEditor({
|
||||||
sections,
|
sections,
|
||||||
|
|
@ -773,7 +773,8 @@ export default function TrainingUnitSectionsEditor({
|
||||||
if (
|
if (
|
||||||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||||
sectionToSlot >= 0 &&
|
sectionToSlot >= 0 &&
|
||||||
fromSlot >= 0
|
fromSlot >= 0 &&
|
||||||
|
fromSlot !== sectionToSlot
|
||||||
) {
|
) {
|
||||||
return { kind: 'crossSlot', fromSi, fromSlot }
|
return { kind: 'crossSlot', fromSi, fromSlot }
|
||||||
}
|
}
|
||||||
|
|
@ -806,6 +807,22 @@ export default function TrainingUnitSectionsEditor({
|
||||||
if (parsed.kind === 'phaseRun') {
|
if (parsed.kind === 'phaseRun') {
|
||||||
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
|
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
|
||||||
if (dragPo === targetPo) return
|
if (dragPo === targetPo) return
|
||||||
|
const fs = typeof parsed.fromSlot === 'number' ? parsed.fromSlot : -1
|
||||||
|
if (
|
||||||
|
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||||
|
sectionToSlot >= 0 &&
|
||||||
|
fs >= 0 &&
|
||||||
|
fs !== sectionToSlot
|
||||||
|
) {
|
||||||
|
const ins = indicesOfParallelPhase(list, targetPo)[0] ?? list.length
|
||||||
|
onMoveSectionsAcrossSlots({
|
||||||
|
fromSlot: fs,
|
||||||
|
toSlot: sectionToSlot,
|
||||||
|
toSectionIdx: ins,
|
||||||
|
parallelPhaseRunOrderIndex: dragPo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
patch((prev) => {
|
patch((prev) => {
|
||||||
const idxs = indicesOfParallelPhase(prev, targetPo)
|
const idxs = indicesOfParallelPhase(prev, targetPo)
|
||||||
const fg = idxs.length ? idxs[0] : -1
|
const fg = idxs.length ? idxs[0] : -1
|
||||||
|
|
@ -817,7 +834,17 @@ export default function TrainingUnitSectionsEditor({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === 'crossSlot') return
|
if (parsed.kind === 'crossSlot') {
|
||||||
|
if (typeof onMoveSectionsAcrossSlots !== 'function') return
|
||||||
|
onMoveSectionsAcrossSlots({
|
||||||
|
fromSlot: parsed.fromSlot,
|
||||||
|
fromSectionIdx: parsed.fromSi,
|
||||||
|
toSlot: sectionToSlot,
|
||||||
|
toSectionIdx: 0,
|
||||||
|
insertBeforeParallelInTarget: targetPo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { fromSi } = parsed
|
const { fromSi } = parsed
|
||||||
patch((prev) => {
|
patch((prev) => {
|
||||||
|
|
@ -853,6 +880,23 @@ export default function TrainingUnitSectionsEditor({
|
||||||
if (parsed.kind === 'phaseRun') {
|
if (parsed.kind === 'phaseRun') {
|
||||||
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
|
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
|
||||||
if (dragPo === targetPo) return
|
if (dragPo === targetPo) return
|
||||||
|
const fs = typeof parsed.fromSlot === 'number' ? parsed.fromSlot : -1
|
||||||
|
if (
|
||||||
|
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||||
|
sectionToSlot >= 0 &&
|
||||||
|
fs >= 0 &&
|
||||||
|
fs !== sectionToSlot
|
||||||
|
) {
|
||||||
|
const si = sectionIndicesForParallelStream(list, targetPo, targetSo)
|
||||||
|
const ins = si.length ? si[0] : indicesOfParallelPhase(list, targetPo)[0] ?? list.length
|
||||||
|
onMoveSectionsAcrossSlots({
|
||||||
|
fromSlot: fs,
|
||||||
|
toSlot: sectionToSlot,
|
||||||
|
toSectionIdx: ins,
|
||||||
|
parallelPhaseRunOrderIndex: dragPo,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
patch((prev) => {
|
patch((prev) => {
|
||||||
const idxs = indicesOfParallelPhase(prev, targetPo)
|
const idxs = indicesOfParallelPhase(prev, targetPo)
|
||||||
const fg = idxs.length ? idxs[0] : -1
|
const fg = idxs.length ? idxs[0] : -1
|
||||||
|
|
@ -864,7 +908,17 @@ export default function TrainingUnitSectionsEditor({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === 'crossSlot') return
|
if (parsed.kind === 'crossSlot') {
|
||||||
|
if (typeof onMoveSectionsAcrossSlots !== 'function') return
|
||||||
|
onMoveSectionsAcrossSlots({
|
||||||
|
fromSlot: parsed.fromSlot,
|
||||||
|
fromSectionIdx: parsed.fromSi,
|
||||||
|
toSlot: sectionToSlot,
|
||||||
|
toSectionIdx: 0,
|
||||||
|
firstInParallelStreamInTarget: { po: targetPo, so: targetSo },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { fromSi } = parsed
|
const { fromSi } = parsed
|
||||||
patch((prev) => {
|
patch((prev) => {
|
||||||
|
|
@ -897,9 +951,24 @@ export default function TrainingUnitSectionsEditor({
|
||||||
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
|
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
|
||||||
|
|
||||||
if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) {
|
if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) {
|
||||||
|
const po = Number(phaseRunMove.phaseOrderIndex) || 0
|
||||||
|
if (
|
||||||
|
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||||
|
sectionToSlot >= 0 &&
|
||||||
|
fromSlot >= 0 &&
|
||||||
|
fromSlot !== sectionToSlot
|
||||||
|
) {
|
||||||
|
onMoveSectionsAcrossSlots({
|
||||||
|
fromSlot,
|
||||||
|
toSlot: sectionToSlot,
|
||||||
|
toSectionIdx: insertBeforeIdx,
|
||||||
|
parallelPhaseRunOrderIndex: po,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
patch((prev) => {
|
patch((prev) => {
|
||||||
const po = Number(phaseRunMove.phaseOrderIndex) || 0
|
const poLocal = Number(phaseRunMove.phaseOrderIndex) || 0
|
||||||
let next = moveParallelPhaseRunToInsertBefore(prev, po, insertBeforeIdx)
|
let next = moveParallelPhaseRunToInsertBefore(prev, poLocal, insertBeforeIdx)
|
||||||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
@ -927,17 +996,11 @@ export default function TrainingUnitSectionsEditor({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
enableParallelPhaseControls &&
|
|
||||||
(insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||||
sectionToSlot >= 0 &&
|
sectionToSlot >= 0 &&
|
||||||
fromSlot >= 0
|
fromSlot >= 0 &&
|
||||||
|
fromSlot !== sectionToSlot
|
||||||
) {
|
) {
|
||||||
onMoveSectionsAcrossSlots({
|
onMoveSectionsAcrossSlots({
|
||||||
fromSlot,
|
fromSlot,
|
||||||
|
|
@ -1011,7 +1074,8 @@ export default function TrainingUnitSectionsEditor({
|
||||||
if (
|
if (
|
||||||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||||
sectionToSlot >= 0 &&
|
sectionToSlot >= 0 &&
|
||||||
fromSlot >= 0
|
fromSlot >= 0 &&
|
||||||
|
fromSlot !== sectionToSlot
|
||||||
) {
|
) {
|
||||||
onMoveSectionsAcrossSlots({
|
onMoveSectionsAcrossSlots({
|
||||||
fromSlot,
|
fromSlot,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
buildPlanPayloadForSave,
|
buildPlanPayloadForSave,
|
||||||
hydrateExercisePlanningRow,
|
hydrateExercisePlanningRow,
|
||||||
insertTrainingModuleIntoPlanningSections,
|
insertTrainingModuleIntoPlanningSections,
|
||||||
|
templateSectionsPayloadFromFormSections,
|
||||||
|
formSectionsFromPlanTemplateRows,
|
||||||
} from '../../utils/trainingUnitSectionsForm'
|
} from '../../utils/trainingUnitSectionsForm'
|
||||||
import {
|
import {
|
||||||
addDaysIsoDate,
|
addDaysIsoDate,
|
||||||
|
|
@ -549,12 +551,8 @@ function TrainingPlanningPageRoot() {
|
||||||
setFormData((fd) => ({
|
setFormData((fd) => ({
|
||||||
...fd,
|
...fd,
|
||||||
sections: (tpl.sections || []).length
|
sections: (tpl.sections || []).length
|
||||||
? tpl.sections.map((s) => ({
|
? formSectionsFromPlanTemplateRows(tpl.sections)
|
||||||
title: s.title,
|
: [defaultSection()],
|
||||||
guidance_notes: s.guidance_text || '',
|
|
||||||
items: []
|
|
||||||
}))
|
|
||||||
: [defaultSection()]
|
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Vorlage laden: ' + err.message)
|
toast.error('Vorlage laden: ' + err.message)
|
||||||
|
|
@ -651,10 +649,7 @@ function TrainingPlanningPageRoot() {
|
||||||
try {
|
try {
|
||||||
await api.createTrainingPlanTemplate({
|
await api.createTrainingPlanTemplate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
sections: formData.sections.map((s) => ({
|
sections: templateSectionsPayloadFromFormSections(formData.sections),
|
||||||
title: s.title || 'Abschnitt',
|
|
||||||
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
await loadPlanTemplates()
|
await loadPlanTemplates()
|
||||||
toast.success('Vorlage gespeichert.')
|
toast.success('Vorlage gespeichert.')
|
||||||
|
|
@ -663,6 +658,29 @@ function TrainingPlanningPageRoot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeletePlanTemplate = useCallback(
|
||||||
|
async (tpl) => {
|
||||||
|
if (!tpl?.id) return
|
||||||
|
const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}`
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Trainingsvorlage „${label}“ wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteTrainingPlanTemplate(tpl.id)
|
||||||
|
setDraftPlanTemplateId((prev) => (String(prev) === String(tpl.id) ? '' : prev))
|
||||||
|
await loadPlanTemplates()
|
||||||
|
toast.success('Vorlage gelöscht.')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Löschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadPlanTemplates, toast]
|
||||||
|
)
|
||||||
|
|
||||||
const openModuleApplyModal = useCallback(async (placement) => {
|
const openModuleApplyModal = useCallback(async (placement) => {
|
||||||
setModuleApplyErr('')
|
setModuleApplyErr('')
|
||||||
setModuleApplySearchQuery('')
|
setModuleApplySearchQuery('')
|
||||||
|
|
@ -1929,6 +1947,7 @@ function TrainingPlanningPageRoot() {
|
||||||
draftPlanTemplateId={draftPlanTemplateId}
|
draftPlanTemplateId={draftPlanTemplateId}
|
||||||
onDraftTemplateSelect={applyTemplateFromSelect}
|
onDraftTemplateSelect={applyTemplateFromSelect}
|
||||||
planTemplates={planTemplates}
|
planTemplates={planTemplates}
|
||||||
|
onDeletePlanTemplate={handleDeletePlanTemplate}
|
||||||
clubDirectory={clubDirectory}
|
clubDirectory={clubDirectory}
|
||||||
clubDirectoryForCo={clubDirectoryForCo}
|
clubDirectoryForCo={clubDirectoryForCo}
|
||||||
planningModalClubId={planningModalClubId}
|
planningModalClubId={planningModalClubId}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
draftPlanTemplateId,
|
draftPlanTemplateId,
|
||||||
onDraftTemplateSelect,
|
onDraftTemplateSelect,
|
||||||
planTemplates,
|
planTemplates,
|
||||||
|
onDeletePlanTemplate,
|
||||||
clubDirectory,
|
clubDirectory,
|
||||||
clubDirectoryForCo,
|
clubDirectoryForCo,
|
||||||
planningModalClubId,
|
planningModalClubId,
|
||||||
|
|
@ -129,6 +130,70 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? (
|
||||||
|
<details
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1.35rem',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text1)' }}>
|
||||||
|
Gespeicherte Vorlagen löschen
|
||||||
|
</summary>
|
||||||
|
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||||
|
Du kannst eigene Vorlagen entfernen. Plattform-Admins dürfen auch fremde Vorlagen löschen. Einheiten, die
|
||||||
|
noch auf eine Vorlage verweisen, behalten ihren Ablauf; die Verknüpfung zur Vorlage wird vom Server
|
||||||
|
entfernt.
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{planTemplates.map((t, ti) => {
|
||||||
|
const roleLc = String(user?.role || '').toLowerCase()
|
||||||
|
const isPlatformAdmin = roleLc === 'admin' || roleLc === 'superadmin'
|
||||||
|
const canDel =
|
||||||
|
user &&
|
||||||
|
(isPlatformAdmin || Number(t.created_by) === Number(user.id))
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderTop: ti === 0 ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
|
||||||
|
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
|
||||||
|
{typeof t.sections_count === 'number' ? (
|
||||||
|
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
|
||||||
|
· {t.sections_count} Abschn.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{canDel ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
style={{ flexShrink: 0, padding: '6px 12px', fontSize: '0.82rem' }}
|
||||||
|
onClick={() => onDeletePlanTemplate(t)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', flexShrink: 0 }}>nur Lesen</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
|
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
normalizeUnitToForm,
|
normalizeUnitToForm,
|
||||||
enrichSectionsWithVariants,
|
enrichSectionsWithVariants,
|
||||||
buildSectionsPayload,
|
buildPlanPayloadForSave,
|
||||||
hydrateExercisePlanningRow,
|
hydrateExercisePlanningRow,
|
||||||
reorderBlockIntoParallelStreamEnd,
|
reorderBlockIntoParallelStreamEnd,
|
||||||
|
indicesOfParallelPhase,
|
||||||
|
reorderSectionBeforeParallelRunAsWholeGroup,
|
||||||
|
reorderSectionAsFirstInParallelStream,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
|
|
||||||
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
||||||
|
|
@ -48,10 +51,10 @@ function emptySlot() {
|
||||||
async function enrichFrameworkSlotSections(slots) {
|
async function enrichFrameworkSlotSections(slots) {
|
||||||
const out = []
|
const out = []
|
||||||
for (const s of slots || []) {
|
for (const s of slots || []) {
|
||||||
const sec = normalizeUnitToForm({ sections: s.sections, exercises: s.exercises })
|
const baseSecs = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')]
|
||||||
out.push({
|
out.push({
|
||||||
...s,
|
...s,
|
||||||
sections: await enrichSectionsWithVariants(sec),
|
sections: await enrichSectionsWithVariants(baseSecs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
@ -132,7 +135,11 @@ function serverFrameworkToForm(fw) {
|
||||||
slots: (fw.slots || []).map((s) => ({
|
slots: (fw.slots || []).map((s) => ({
|
||||||
title: s.title || '',
|
title: s.title || '',
|
||||||
notes: s.notes || '',
|
notes: s.notes || '',
|
||||||
sections: normalizeUnitToForm({ sections: s.sections, exercises: s.exercises }),
|
sections: normalizeUnitToForm({
|
||||||
|
sections: s.sections,
|
||||||
|
exercises: s.exercises,
|
||||||
|
phases: s.phases,
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,13 +158,16 @@ function buildApiPayload(form) {
|
||||||
|
|
||||||
const slots = (form.slots || []).map((s, si) => {
|
const slots = (form.slots || []).map((s, si) => {
|
||||||
const secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')]
|
const secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')]
|
||||||
const sectionsPayload = buildSectionsPayload(secList)
|
const plan = buildPlanPayloadForSave(secList)
|
||||||
return {
|
const base = {
|
||||||
sort_order: si,
|
sort_order: si,
|
||||||
title: (s.title || '').trim() || null,
|
title: (s.title || '').trim() || null,
|
||||||
notes: (s.notes || '').trim() || null,
|
notes: (s.notes || '').trim() || null,
|
||||||
sections: sectionsPayload,
|
|
||||||
}
|
}
|
||||||
|
if (plan.phases) {
|
||||||
|
return { ...base, phases: plan.phases }
|
||||||
|
}
|
||||||
|
return { ...base, sections: plan.sections }
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusAreaId =
|
const focusAreaId =
|
||||||
|
|
@ -554,7 +564,17 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveSectionsAcrossFrameworkSlots = useCallback(
|
const moveSectionsAcrossFrameworkSlots = useCallback(
|
||||||
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx, toParallelStream }) => {
|
(payload) => {
|
||||||
|
const {
|
||||||
|
fromSlot,
|
||||||
|
fromSectionIdx,
|
||||||
|
toSlot,
|
||||||
|
toSectionIdx,
|
||||||
|
toParallelStream,
|
||||||
|
parallelPhaseRunOrderIndex,
|
||||||
|
insertBeforeParallelInTarget,
|
||||||
|
firstInParallelStreamInTarget,
|
||||||
|
} = payload
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const slots = prev.slots.map((sl) => ({
|
const slots = prev.slots.map((sl) => ({
|
||||||
...sl,
|
...sl,
|
||||||
|
|
@ -572,15 +592,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromSecs = slots[fromSlot].sections
|
const fromSecs = slots[fromSlot].sections
|
||||||
if (
|
const toSecs = slots[toSlot].sections
|
||||||
typeof fromSectionIdx !== 'number' ||
|
|
||||||
fromSectionIdx < 0 ||
|
|
||||||
fromSectionIdx >= fromSecs.length
|
|
||||||
) {
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
|
|
||||||
const [block] = fromSecs.splice(fromSectionIdx, 1)
|
|
||||||
|
|
||||||
const applyParallelStreamEnd =
|
const applyParallelStreamEnd =
|
||||||
toParallelStream != null && toParallelStream.po != null && toParallelStream.so != null
|
toParallelStream != null && toParallelStream.po != null && toParallelStream.so != null
|
||||||
|
|
@ -591,6 +603,43 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
/** Gesamten Parallel-Lauf aus fromSlot an toSectionIdx in toSlot legen */
|
||||||
|
if (parallelPhaseRunOrderIndex != null && parallelPhaseRunOrderIndex !== '') {
|
||||||
|
const po = Number(parallelPhaseRunOrderIndex) || 0
|
||||||
|
const idxs = indicesOfParallelPhase(fromSecs, po)
|
||||||
|
if (!idxs.length) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const blocks = idxs.map((i) => fromSecs[i])
|
||||||
|
for (const i of [...idxs].sort((a, b) => b - a)) {
|
||||||
|
fromSecs.splice(i, 1)
|
||||||
|
}
|
||||||
|
if (fromSlot === toSlot) {
|
||||||
|
let insertAt = Number(toSectionIdx) || 0
|
||||||
|
for (const i of idxs) {
|
||||||
|
if (i < insertAt) insertAt -= 1
|
||||||
|
}
|
||||||
|
insertAt = Math.max(0, Math.min(insertAt, fromSecs.length))
|
||||||
|
fromSecs.splice(insertAt, 0, ...blocks)
|
||||||
|
slots[fromSlot].sections = fromSecs
|
||||||
|
return { ...prev, slots }
|
||||||
|
}
|
||||||
|
const insertAt = Math.max(0, Math.min(Number(toSectionIdx) || 0, toSecs.length))
|
||||||
|
toSecs.splice(insertAt, 0, ...blocks)
|
||||||
|
slots[toSlot].sections = toSecs
|
||||||
|
return { ...prev, slots }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof fromSectionIdx !== 'number' ||
|
||||||
|
fromSectionIdx < 0 ||
|
||||||
|
fromSectionIdx >= fromSecs.length
|
||||||
|
) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
const [block] = fromSecs.splice(fromSectionIdx, 1)
|
||||||
|
|
||||||
if (fromSlot === toSlot) {
|
if (fromSlot === toSlot) {
|
||||||
let insertAt = toSectionIdx
|
let insertAt = toSectionIdx
|
||||||
if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1
|
if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1
|
||||||
|
|
@ -598,15 +647,45 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
fromSecs.splice(insertAt, 0, block)
|
fromSecs.splice(insertAt, 0, block)
|
||||||
if (applyParallelStreamEnd) {
|
if (applyParallelStreamEnd) {
|
||||||
slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt)
|
slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt)
|
||||||
|
} else {
|
||||||
|
slots[fromSlot].sections = fromSecs
|
||||||
}
|
}
|
||||||
return { ...prev, slots }
|
return { ...prev, slots }
|
||||||
}
|
}
|
||||||
|
|
||||||
const toSecs = slots[toSlot].sections
|
if (insertBeforeParallelInTarget != null && insertBeforeParallelInTarget !== '') {
|
||||||
|
const tpo = Number(insertBeforeParallelInTarget) || 0
|
||||||
|
const pIdxs = indicesOfParallelPhase(toSecs, tpo)
|
||||||
|
const ins = pIdxs.length ? pIdxs[0] : toSecs.length
|
||||||
|
toSecs.splice(ins, 0, block)
|
||||||
|
slots[toSlot].sections = reorderSectionBeforeParallelRunAsWholeGroup(toSecs, ins, tpo)
|
||||||
|
return { ...prev, slots }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
firstInParallelStreamInTarget != null &&
|
||||||
|
firstInParallelStreamInTarget.po != null &&
|
||||||
|
firstInParallelStreamInTarget.so != null
|
||||||
|
) {
|
||||||
|
const fpo = Number(firstInParallelStreamInTarget.po) || 0
|
||||||
|
const fso = Number(firstInParallelStreamInTarget.so) || 0
|
||||||
|
const nextSecs = [...toSecs, block]
|
||||||
|
const movedFromI = nextSecs.length - 1
|
||||||
|
slots[toSlot].sections = reorderSectionAsFirstInParallelStream(
|
||||||
|
nextSecs,
|
||||||
|
movedFromI,
|
||||||
|
fpo,
|
||||||
|
fso
|
||||||
|
)
|
||||||
|
return { ...prev, slots }
|
||||||
|
}
|
||||||
|
|
||||||
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
|
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
|
||||||
toSecs.splice(ia, 0, block)
|
toSecs.splice(ia, 0, block)
|
||||||
if (applyParallelStreamEnd) {
|
if (applyParallelStreamEnd) {
|
||||||
slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia)
|
slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia)
|
||||||
|
} else {
|
||||||
|
slots[toSlot].sections = toSecs
|
||||||
}
|
}
|
||||||
return { ...prev, slots }
|
return { ...prev, slots }
|
||||||
})
|
})
|
||||||
|
|
@ -710,6 +789,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
showExecutionExtras={false}
|
showExecutionExtras={false}
|
||||||
wideExerciseGrid
|
wideExerciseGrid
|
||||||
slotIndex={si}
|
slotIndex={si}
|
||||||
|
enableParallelPhaseControls
|
||||||
onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots}
|
onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots}
|
||||||
onSectionsChange={(updater) => {
|
onSectionsChange={(updater) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
|
|
@ -774,7 +854,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
||||||
Zielen und Session‑Slots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
|
Zielen und Session‑Slots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
|
||||||
<strong>Gruppen‑Planung</strong> („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
|
<strong>Gruppen‑Planung</strong> („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
|
||||||
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>.
|
<strong>Abschnitte</strong>, optional <strong>Ganzgruppen- und parallele Phasen (Breakout)</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>. Abschnitte kannst du per Ziehen auch in eine andere Session legen.
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -721,20 +721,47 @@ function stripPlanLoc(sec) {
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inheritPlanLocForPhasedSave(sections) {
|
function phaseIndexToInt(v, fallback = 0) {
|
||||||
let prev = {
|
if (v === null || v === undefined || v === '') return fallback
|
||||||
phaseKind: 'whole_group',
|
const n = typeof v === 'number' ? v : Number(v)
|
||||||
phaseOrderIndex: 0,
|
return Number.isFinite(n) ? n : fallback
|
||||||
parallelStreamOrderIndex: null,
|
}
|
||||||
phaseTitle: null,
|
|
||||||
phaseGuidanceNotes: null,
|
/**
|
||||||
streamTitle: null,
|
* planLoc für Phasen-PUT kanonisieren (Großschreibung, numerische Indizes).
|
||||||
streamNotes: null,
|
* Verhindert u. a. abgebrochene Phasen-Runs bei "0" !== 0 und falsche whole_group-Zweige.
|
||||||
streamAssignedTrainerProfileIds: null,
|
*/
|
||||||
|
function canonicalPlanLocForPhasedSave(pl) {
|
||||||
|
if (!pl || typeof pl !== 'object') return null
|
||||||
|
const rawKind = String(pl.phaseKind || '').toLowerCase().trim()
|
||||||
|
let kind = rawKind === 'parallel' || rawKind === 'whole_group' ? rawKind : null
|
||||||
|
if (
|
||||||
|
!kind &&
|
||||||
|
pl.parallelStreamOrderIndex != null &&
|
||||||
|
pl.parallelStreamOrderIndex !== ''
|
||||||
|
) {
|
||||||
|
kind = 'parallel'
|
||||||
}
|
}
|
||||||
|
if (!kind) return null
|
||||||
|
const phaseOrderIndex = phaseIndexToInt(pl.phaseOrderIndex, 0)
|
||||||
|
let parallelStreamOrderIndex = null
|
||||||
|
if (kind === 'parallel') {
|
||||||
|
parallelStreamOrderIndex = phaseIndexToInt(pl.parallelStreamOrderIndex, 0)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...pl,
|
||||||
|
phaseKind: kind,
|
||||||
|
phaseOrderIndex,
|
||||||
|
parallelStreamOrderIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inheritPlanLocForPhasedSave(sections) {
|
||||||
|
let prev = { ...defaultPlanLocWholeGroup(0) }
|
||||||
return (sections || []).map((s) => {
|
return (sections || []).map((s) => {
|
||||||
if (s?.planLoc && s.planLoc.phaseKind) {
|
const canon = canonicalPlanLocForPhasedSave(s?.planLoc)
|
||||||
prev = { ...s.planLoc }
|
if (canon) {
|
||||||
|
prev = { ...canon }
|
||||||
return { ...s, planLoc: prev }
|
return { ...s, planLoc: prev }
|
||||||
}
|
}
|
||||||
return { ...s, planLoc: { ...prev } }
|
return { ...s, planLoc: { ...prev } }
|
||||||
|
|
@ -1127,13 +1154,61 @@ function buildPhasesPayloadFromFlat(sections) {
|
||||||
*/
|
*/
|
||||||
export function buildPlanPayloadForSave(sections) {
|
export function buildPlanPayloadForSave(sections) {
|
||||||
const list = Array.isArray(sections) ? sections : []
|
const list = Array.isArray(sections) ? sections : []
|
||||||
const anyPhased = list.some((s) => s && s.planLoc && s.planLoc.phaseKind)
|
const anyPhased = list.some((s) => s && canonicalPlanLocForPhasedSave(s.planLoc) != null)
|
||||||
if (!anyPhased) {
|
if (!anyPhased) {
|
||||||
return { sections: buildSectionsPayload(list) }
|
return { sections: buildSectionsPayload(list) }
|
||||||
}
|
}
|
||||||
return buildPhasesPayloadFromFlat(list)
|
return buildPhasesPayloadFromFlat(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Payload-Zeilen für POST/PUT /api/training-plan-templates (inkl. Split-/Phasen-Metadaten). */
|
||||||
|
export function templateSectionsPayloadFromFormSections(sections) {
|
||||||
|
const norm = inheritPlanLocForPhasedSave(Array.isArray(sections) ? sections : [])
|
||||||
|
return norm.map((s, si) => {
|
||||||
|
const canon = canonicalPlanLocForPhasedSave(s.planLoc)
|
||||||
|
const pk = canon?.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
||||||
|
const poi = canon?.phaseOrderIndex ?? 0
|
||||||
|
const pso = canon?.phaseKind === 'parallel' ? (canon.parallelStreamOrderIndex ?? 0) : null
|
||||||
|
return {
|
||||||
|
order_index: si,
|
||||||
|
title: (s.title || '').trim() || `Abschnitt ${si + 1}`,
|
||||||
|
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null,
|
||||||
|
phase_kind: pk,
|
||||||
|
phase_order_index: poi,
|
||||||
|
parallel_stream_order_index: pso,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET-Vorlage → Editor-Abschnitte mit planLoc (Split-Sessions). */
|
||||||
|
export function formSectionsFromPlanTemplateRows(templateSections) {
|
||||||
|
const rows = Array.isArray(templateSections) ? [...templateSections] : []
|
||||||
|
rows.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||||
|
if (!rows.length) return [defaultSection()]
|
||||||
|
return rows.map((s) => {
|
||||||
|
const pk = String(s.phase_kind || 'whole_group').toLowerCase().trim()
|
||||||
|
const poiRaw = s.phase_order_index
|
||||||
|
const poi = poiRaw == null || poiRaw === '' ? 0 : Number(poiRaw)
|
||||||
|
const phaseOrderIndex = Number.isFinite(poi) ? poi : 0
|
||||||
|
const soRaw = s.parallel_stream_order_index
|
||||||
|
let planLoc
|
||||||
|
if (pk === 'parallel') {
|
||||||
|
const so = soRaw == null || soRaw === '' ? 0 : Number(soRaw)
|
||||||
|
planLoc = {
|
||||||
|
...defaultPlanLocParallel(phaseOrderIndex, Number.isFinite(so) ? so : 0),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
planLoc = { ...defaultPlanLocWholeGroup(phaseOrderIndex) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: s.title || 'Abschnitt',
|
||||||
|
guidance_notes: s.guidance_text || '',
|
||||||
|
items: [],
|
||||||
|
planLoc,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||||||
export async function insertTrainingModuleIntoPlanningSections({
|
export async function insertTrainingModuleIntoPlanningSections({
|
||||||
sections,
|
sections,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user