diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 0b48c3f..58e388d 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -1,8 +1,8 @@ # Shinkan Jinkendo - Projekt-Status -**Stand:** 2026-05-12 -**Version (Code):** 0.8.110 (`backend/version.py`, APP_VERSION) -**DB-Schema-Version:** `20260512057` (`backend/version.py`, DB_SCHEMA_VERSION) +**Stand:** 2026-05-14 +**Version (Code):** 0.8.140 (`backend/version.py`, APP_VERSION) +**DB-Schema-Version:** `20260515063` (`backend/version.py`, DB_SCHEMA_VERSION) **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). -**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) @@ -36,7 +36,8 @@ 1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk. 2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API). 3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**). -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] **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] **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) **MediaWiki Import:** @@ -155,18 +158,19 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes | Dokument | Pfad | Stand | Status | |----------|------|-------|--------| -| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-12 | neu, Ist-Überblick | -| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-12 | Verweis Version siehe `version.py` | +| 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-14 | §11a Breakout | | Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint | | 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) | -| 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 | | Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise | | Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) | | Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-05-07 | ✅ Verweis Archiv/Inline | | Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-08 | ✅ Ist-Changelog + §11 Inline erweitert | -| Projektstatus | `PROJECT_STATUS.md` | 2026-05-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) diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index 5bc9006..2c4ae68 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -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**). -### 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. **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`. -**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) - **`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. - **`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. -- **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. --- diff --git a/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md b/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md index 0023cf8..5a76b5c 100644 --- a/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md +++ b/.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md @@ -1,6 +1,6 @@ # 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**. **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 | |----------|--------| @@ -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** | | `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi | | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick | +| `docs/HANDOVER.md` | Ist-Stand Coach, offene Breakout-Punkte | | `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen | diff --git a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md index e19ad1b..995588b 100644 --- a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md +++ b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md @@ -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: @@ -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). - 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 | |--------|----------| @@ -170,5 +179,7 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel | Datenbank Überblick | `technical/DATABASE_SCHEMA.md` | | Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` | | Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | +| 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) | | Projektstatus-Kachel | `../PROJECT_STATUS.md` | diff --git a/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md b/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md index 5d894e3..0d3b0cf 100644 --- a/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md +++ b/.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md @@ -1,23 +1,26 @@ # 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` -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 | |---------|---------| -| Planstruktur | **Eine** lineare Liste `training_unit_sections` je `training_unit_id`; Items in `training_unit_section_items`. | -| Rahmenprogramm | `training_framework_slots` verweisen auf **Blueprint**-`training_units` — Slots = **Serien-Spalten**, nicht simultane Breakouts in **einer** Halle. | -| Kombinationsübung | Ein **Item** kann Kombi sein; `planning_method_profile` = Snapshot; Coaching-UI teilweise (`CombinationPlanBracket` in Run/Peek). | -| Trainer-Zuweisung | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am **`training_units`**-Kopf; **keine** Zuordnung zu „welcher parallelen Spur“. | -| Run-Modus | `TrainingUnitRunPage`: sortierte Sektionen/Items, Checkliste, Fortschritt in `sessionStorage` pro Einheit. | +| **Schema** | Migration **063:** `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` **oder** `parallel_stream_id`. | +| **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. | +| **Planung (UI)** | Breakout-Panel: Ganzgruppen-/parallele Phasen, Streams; Speichern phasenbasiert (`trainingUnitSectionsForm.js`, `TrainingPlanningPage`). | +| **Durchführung** | `TrainingUnitRunPage.jsx` + `trainingPlanUtils.js` (`sectionsWithPlanLocForDisplay`, `buildPlanRunViewModelFromSections`) — Phasenfolge in „Plan & Ablauf“. | +| **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 +**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) -Neue Tabellen (Namen bei Implementierung final festlegen): +Die Tabellen sind **umgesetzt** (Namen final): | 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 | -|-------|--------| -| **P1** | Schema Phasen + Streams; Migration; GET/PATCH Einheit verschachtelt; Planungs-UI; Run-UI mit Stream-Tabs | -| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert | -| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) | +| Phase | Inhalt | Stand 2026-05-14 | +|-------|--------|------------------| +| **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 | **Offen** | +| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) | **Offen** | ---- +**Offene Punkte (kurz):** siehe **`docs/HANDOVER.md`** Tabelle „Coaching & Breakout“. ## 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_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` | | `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 | diff --git a/CLAUDE.md b/CLAUDE.md index f7bb836..0a4901d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ frontend/src/ **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) diff --git a/backend/migrations/064_training_plan_template_phases.sql b/backend/migrations/064_training_plan_template_phases.sql new file mode 100644 index 0000000..da7b72b --- /dev/null +++ b/backend/migrations/064_training_plan_template_phases.sql @@ -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'; diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 9855463..2b7e11c 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -21,6 +21,7 @@ from routers.training_planning import ( _hydrate_training_unit_payload, _optional_positive_int, _insert_sections_from_legacy_exercises, + _replace_unit_phases, _replace_unit_sections, _validate_variant_for_exercise, ) @@ -132,6 +133,7 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: row_b = cur.fetchone() if not row_b: s["blueprint_training_unit_id"] = None + s["phases"] = [] s["sections"] = [] s["exercises"] = [] continue @@ -139,6 +141,7 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: s["blueprint_training_unit_id"] = uid unit_min: Dict[str, Any] = {"id": uid} _hydrate_training_unit_payload(cur, unit_min) + s["phases"] = unit_min.get("phases", []) s["sections"] = unit_min.get("sections", []) s["exercises"] = unit_min.get("exercises", []) row["slots"] = slots @@ -250,6 +253,7 @@ def _insert_slots_and_blueprints( framework_id: int, slots_in: Optional[List[Any]], profile_id: int, + role: str, ) -> None: if slots_in is None: return @@ -296,10 +300,13 @@ def _insert_slots_and_blueprints( ) bid = cur.fetchone()["id"] + phases_in = slot.get("phases") sections_in = slot.get("sections") 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: _insert_default_blueprint_section(cur, bid) else: @@ -432,7 +439,7 @@ def create_training_framework_program( ) fid = cur.fetchone()["id"] _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_target_groups(cur, fid, tg_ids) conn.commit() @@ -543,7 +550,9 @@ def update_training_framework_program( "DELETE FROM training_framework_slots WHERE framework_program_id = %s", (framework_id,), ) - _insert_slots_and_blueprints(cur, framework_id, data.get("slots") or [], profile_id) + _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: cur.execute( diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index acd9449..af32f85 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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) -def _instantiate_from_template(cur, unit_id: int, template_id: int): - _clear_unit_plan_content(cur, unit_id) - pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) +def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict[str, Any]: + 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 + 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( """ - 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 WHERE template_id = %s ORDER BY order_index """, (template_id,), ) - rows = cur.fetchall() - for gi, row in enumerate(rows): - 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 + rows_raw = cur.fetchall() + rows = [r2d(r) for r in rows_raw] if not rows: + _clear_unit_plan_content(cur, unit_id) + pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) cur.execute( """ INSERT INTO training_unit_sections ( @@ -1557,6 +1712,18 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int): """, (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]: @@ -1674,18 +1841,7 @@ def create_training_plan_template(data: dict, tenant: TenantContext = Depends(ge (club_id, profile_id, name, data.get("description"), visibility), ) tid = cur.fetchone()["id"] - for si, sec in enumerate(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")), - ) + _insert_training_plan_template_sections(cur, tid, sections_in) conn.commit() 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,) ) sections_in = data["sections"] or [] - for si, sec in enumerate(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")), - ) + _insert_training_plan_template_sections(cur, template_id, sections_in) conn.commit() 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: _replace_unit_sections(cur, unit_id, sections_in) 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: _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( "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 _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"] 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) conn.commit() diff --git a/backend/tests/test_training_framework_phases_integration.py b/backend/tests/test_training_framework_phases_integration.py new file mode 100644 index 0000000..fd9a333 --- /dev/null +++ b/backend/tests/test_training_framework_phases_integration.py @@ -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() diff --git a/backend/version.py b/backend/version.py index aaf4f4d..1c29c8d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.140" -BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260515063" +APP_VERSION = "0.8.141" +BUILD_DATE = "2026-05-14" +DB_SCHEMA_VERSION = "20260515064" MODULE_VERSIONS = { "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 "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", - "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) "training_modules": "1.0.0", "import_wiki": "1.0.0", @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } 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", "date": "2026-05-14", diff --git a/docs/FACHLICHE_NUTZERFUNKTIONEN.md b/docs/FACHLICHE_NUTZERFUNKTIONEN.md index 45d3162..96d247f 100644 --- a/docs/FACHLICHE_NUTZERFUNKTIONEN.md +++ b/docs/FACHLICHE_NUTZERFUNKTIONEN.md @@ -75,10 +75,12 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter ### 4.4 Trainingsplanung - **Trainingseinheiten** als planbare Objekte mit **Sektionen** und **Einträgen** (Übungen, ggf. mit **Variante** und Metadaten wie Dauer). -- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden. +- **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. - **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 @@ -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. - **Policies** für geteilte Rahmen (Wer darf Bibliotheks-Rahmen sehen/kopieren?). - **Skill-Kategorie-Admin-UI**, **Dark Mode/Responsive/PWA-Ausbau**, **KI-Suche** über Volltext hinaus – je nach Backlog. -- **Coach / Kombination:** nächste Stufen **Zeitleisten-Splitting** und **Archetyp-Timer** (Fachspez § 10.4 Stufe B/C; Umsetzungsplan Phase 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 | |-------|----------| +| 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 | Kombinationsübungen + Coaching Stufe A; Verweise auf Fachspezifikation (`…Kombinationsuebungen…` V3 Anhang A) und `TRAINING_MODULES_IMPLEMENTATION_PLAN.md`. | diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index e52a4ef..f0e2f7d 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-13 -**App-Version / DB-Schema:** App **0.8.120**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**Stand:** 2026-05-14 +**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**. @@ -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`**. - **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). - **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 -1. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log). -2. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt. -3. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik. -4. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben. -5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt. -6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien). -7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen. -8. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift). -9. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**). -10. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`). -11. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**). +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. **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. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt. +4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik. +5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben. +6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt. +7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien). +8. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen. +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. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**). +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 | |---------|----------| | 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) | | Frontend API | `frontend/src/utils/api.js` | | Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` | diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index 558d42c..b63ed74 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -9,6 +9,7 @@ - **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 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**. **Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md). diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index c0cf4c3..a58fe7f 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -245,7 +245,7 @@ function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) { /** * @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({ sections, @@ -773,7 +773,8 @@ export default function TrainingUnitSectionsEditor({ if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && - fromSlot >= 0 + fromSlot >= 0 && + fromSlot !== sectionToSlot ) { return { kind: 'crossSlot', fromSi, fromSlot } } @@ -806,6 +807,22 @@ export default function TrainingUnitSectionsEditor({ if (parsed.kind === 'phaseRun') { const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 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) => { const idxs = indicesOfParallelPhase(prev, targetPo) const fg = idxs.length ? idxs[0] : -1 @@ -817,7 +834,17 @@ export default function TrainingUnitSectionsEditor({ 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 patch((prev) => { @@ -853,6 +880,23 @@ export default function TrainingUnitSectionsEditor({ if (parsed.kind === 'phaseRun') { const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 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) => { const idxs = indicesOfParallelPhase(prev, targetPo) const fg = idxs.length ? idxs[0] : -1 @@ -864,7 +908,17 @@ export default function TrainingUnitSectionsEditor({ 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 patch((prev) => { @@ -897,9 +951,24 @@ export default function TrainingUnitSectionsEditor({ const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 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) => { - const po = Number(phaseRunMove.phaseOrderIndex) || 0 - let next = moveParallelPhaseRunToInsertBefore(prev, po, insertBeforeIdx) + const poLocal = Number(phaseRunMove.phaseOrderIndex) || 0 + let next = moveParallelPhaseRunToInsertBefore(prev, poLocal, insertBeforeIdx) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) @@ -927,17 +996,11 @@ export default function TrainingUnitSectionsEditor({ } } - if ( - enableParallelPhaseControls && - (insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1) - ) { - return - } - if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && - fromSlot >= 0 + fromSlot >= 0 && + fromSlot !== sectionToSlot ) { onMoveSectionsAcrossSlots({ fromSlot, @@ -1011,7 +1074,8 @@ export default function TrainingUnitSectionsEditor({ if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && - fromSlot >= 0 + fromSlot >= 0 && + fromSlot !== sectionToSlot ) { onMoveSectionsAcrossSlots({ fromSlot, diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 1ce61ec..5a2680e 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -19,6 +19,8 @@ import { buildPlanPayloadForSave, hydrateExercisePlanningRow, insertTrainingModuleIntoPlanningSections, + templateSectionsPayloadFromFormSections, + formSectionsFromPlanTemplateRows, } from '../../utils/trainingUnitSectionsForm' import { addDaysIsoDate, @@ -549,12 +551,8 @@ function TrainingPlanningPageRoot() { setFormData((fd) => ({ ...fd, sections: (tpl.sections || []).length - ? tpl.sections.map((s) => ({ - title: s.title, - guidance_notes: s.guidance_text || '', - items: [] - })) - : [defaultSection()] + ? formSectionsFromPlanTemplateRows(tpl.sections) + : [defaultSection()], })) } catch (err) { toast.error('Vorlage laden: ' + err.message) @@ -651,10 +649,7 @@ function TrainingPlanningPageRoot() { try { await api.createTrainingPlanTemplate({ name: name.trim(), - sections: formData.sections.map((s) => ({ - title: s.title || 'Abschnitt', - guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null - })) + sections: templateSectionsPayloadFromFormSections(formData.sections), }) await loadPlanTemplates() 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) => { setModuleApplyErr('') setModuleApplySearchQuery('') @@ -1929,6 +1947,7 @@ function TrainingPlanningPageRoot() { draftPlanTemplateId={draftPlanTemplateId} onDraftTemplateSelect={applyTemplateFromSelect} planTemplates={planTemplates} + onDeletePlanTemplate={handleDeletePlanTemplate} clubDirectory={clubDirectory} clubDirectoryForCo={clubDirectoryForCo} planningModalClubId={planningModalClubId} diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index eaec781..f119029 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -18,6 +18,7 @@ export default function TrainingPlanningUnitFormModal({ draftPlanTemplateId, onDraftTemplateSelect, planTemplates, + onDeletePlanTemplate, clubDirectory, clubDirectoryForCo, planningModalClubId, @@ -129,6 +130,70 @@ export default function TrainingPlanningUnitFormModal({ )} + {planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? ( +
+ + Gespeicherte Vorlagen löschen + +

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

+ +
+ ) : null} +

Planung

diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 588a6cb..3b226f9 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -12,9 +12,12 @@ import { defaultSection, normalizeUnitToForm, enrichSectionsWithVariants, - buildSectionsPayload, + buildPlanPayloadForSave, hydrateExercisePlanningRow, reorderBlockIntoParallelStreamEnd, + indicesOfParallelPhase, + reorderSectionBeforeParallelRunAsWholeGroup, + reorderSectionAsFirstInParallelStream, } from '../utils/trainingUnitSectionsForm' const DND_FW_SLOT = 'application/x-shinkan-framework-slot' @@ -48,10 +51,10 @@ function emptySlot() { async function enrichFrameworkSlotSections(slots) { const out = [] 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({ ...s, - sections: await enrichSectionsWithVariants(sec), + sections: await enrichSectionsWithVariants(baseSecs), }) } return out @@ -132,7 +135,11 @@ function serverFrameworkToForm(fw) { slots: (fw.slots || []).map((s) => ({ title: s.title || '', 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 secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')] - const sectionsPayload = buildSectionsPayload(secList) - return { + const plan = buildPlanPayloadForSave(secList) + const base = { sort_order: si, title: (s.title || '').trim() || null, notes: (s.notes || '').trim() || null, - sections: sectionsPayload, } + if (plan.phases) { + return { ...base, phases: plan.phases } + } + return { ...base, sections: plan.sections } }) const focusAreaId = @@ -554,7 +564,17 @@ export default function TrainingFrameworkProgramEditPage() { } const moveSectionsAcrossFrameworkSlots = useCallback( - ({ fromSlot, fromSectionIdx, toSlot, toSectionIdx, toParallelStream }) => { + (payload) => { + const { + fromSlot, + fromSectionIdx, + toSlot, + toSectionIdx, + toParallelStream, + parallelPhaseRunOrderIndex, + insertBeforeParallelInTarget, + firstInParallelStreamInTarget, + } = payload setForm((prev) => { const slots = prev.slots.map((sl) => ({ ...sl, @@ -572,15 +592,7 @@ export default function TrainingFrameworkProgramEditPage() { } const fromSecs = slots[fromSlot].sections - if ( - typeof fromSectionIdx !== 'number' || - fromSectionIdx < 0 || - fromSectionIdx >= fromSecs.length - ) { - return prev - } - - const [block] = fromSecs.splice(fromSectionIdx, 1) + const toSecs = slots[toSlot].sections const applyParallelStreamEnd = toParallelStream != null && toParallelStream.po != null && toParallelStream.so != null @@ -591,6 +603,43 @@ export default function TrainingFrameworkProgramEditPage() { } : 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) { let insertAt = toSectionIdx if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1 @@ -598,15 +647,45 @@ export default function TrainingFrameworkProgramEditPage() { fromSecs.splice(insertAt, 0, block) if (applyParallelStreamEnd) { slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt) + } else { + slots[fromSlot].sections = fromSecs } 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)) toSecs.splice(ia, 0, block) if (applyParallelStreamEnd) { slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia) + } else { + slots[toSlot].sections = toSecs } return { ...prev, slots } }) @@ -710,6 +789,7 @@ export default function TrainingFrameworkProgramEditPage() { showExecutionExtras={false} wideExerciseGrid slotIndex={si} + enableParallelPhaseControls onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots} onSectionsChange={(updater) => { setForm((prev) => ({ @@ -774,7 +854,7 @@ export default function TrainingFrameworkProgramEditPage() { Rahmenprogramm (Bibliothek): Wiederverwendbare Vorlage mit Zielen und Session‑Slots. Die Zuordnung zu Gruppe oder Kalendertermin erfolgt aus der{' '} Gruppen‑Planung („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '} - Abschnitte, Übungen mit Varianten und Dauer, Zwischen‑Anmerkungen. + Abschnitte, optional Ganzgruppen- und parallele Phasen (Breakout), Übungen mit Varianten und Dauer, Zwischen‑Anmerkungen. Abschnitte kannst du per Ziehen auch in eine andere Session legen. diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index f2ce62f..3f7050a 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -721,20 +721,47 @@ function stripPlanLoc(sec) { return rest } -export function inheritPlanLocForPhasedSave(sections) { - let prev = { - phaseKind: 'whole_group', - phaseOrderIndex: 0, - parallelStreamOrderIndex: null, - phaseTitle: null, - phaseGuidanceNotes: null, - streamTitle: null, - streamNotes: null, - streamAssignedTrainerProfileIds: null, +function phaseIndexToInt(v, fallback = 0) { + if (v === null || v === undefined || v === '') return fallback + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? n : fallback +} + +/** + * planLoc für Phasen-PUT kanonisieren (Großschreibung, numerische Indizes). + * Verhindert u. a. abgebrochene Phasen-Runs bei "0" !== 0 und falsche whole_group-Zweige. + */ +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) => { - if (s?.planLoc && s.planLoc.phaseKind) { - prev = { ...s.planLoc } + const canon = canonicalPlanLocForPhasedSave(s?.planLoc) + if (canon) { + prev = { ...canon } return { ...s, planLoc: prev } } return { ...s, planLoc: { ...prev } } @@ -1127,13 +1154,61 @@ function buildPhasesPayloadFromFlat(sections) { */ export function buildPlanPayloadForSave(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) { return { sections: buildSectionsPayload(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). */ export async function insertTrainingModuleIntoPlanningSections({ sections,