diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index f951050..5bc9006 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -482,6 +482,8 @@ skill_level_definitions ( **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) diff --git a/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md b/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6801b07 --- /dev/null +++ b/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md @@ -0,0 +1,125 @@ +# Parallele Trainingsstreams — Ist-Analyse und risikoarmer Umsetzungsplan + +**Status:** Stufe A (Analyse/Plan, ohne produktive Umsetzung in jener Session) +**Stand:** 2026-05-14 +**Verbindliche fachliche Basis:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md` + +Dieses Dokument **persistiert** die strukturierte Prüfung der realen Codebasis (`training_planning.py`, `training_framework_programs.py`, `training_unit_sections`/`items`, Frontend Planung/Run/Coach) und den empfohlenen Implementierungspfad. + +--- + +## 1. Zusammenfassung + +- Plan-Inhalt pro Einheit ist heute **eine flache Liste** `training_unit_sections` mit **`UNIQUE (training_unit_id, order_index)`** (Migration 031) und `training_unit_section_items`; zentral: **`_fetch_sections`**, **`_replace_unit_sections`**, **`_hydrate_training_unit_payload`** in `backend/routers/training_planning.py`. +- Parallele Phasen/Streams **passen** zu den Produktregeln (ein Kalendertermin, N Streams, je Miniplan), sind im Schema aber **nicht** abbildbar ohne Erweiterung und **ohne Auflösung** des globalen `order_index`-Modells. +- **Empfehlung:** **Normalisierte** Tabellen `training_unit_phases`, `training_unit_parallel_streams`, erweiterte `training_unit_sections` mit FK auf Phase bzw. Stream, **partielle Unique-Indizes** statt `UNIQUE (training_unit_id, order_index)` für alle Sektionen. +- **Blocker im Code:** u. a. `POST /api/training-units/{id}/apply-training-module` mit **`section_order_index` global pro Einheit** (`_resolve_training_unit_section_id`). +- **Nicht persistiert an anderer Stelle:** Erste Fassung existierte nur als Chat-Antwort; dieses File ist die **kanonische** Arbeitskopie im Repo. + +--- + +## 2. Ist-Analyse (kurz) + +### Datenbank + +- `training_unit_sections`: u. a. `training_unit_id`, `order_index`, `UNIQUE (training_unit_id, order_index)`. +- `training_unit_section_items`: Übung/Notiz, `planning_method_profile` (Kombi), `source_training_module_id`. + +### Backend (`training_planning.py`) + +- `_replace_unit_sections`: DELETE aller Sektionen der Einheit + INSERT (vollständiger Ersetzungsbaum). +- `_sections_clone_payload` + `_copy_blueprint_into_scheduled_unit`: tiefe Kopie für `from-framework-slot`. +- `_flatten_exercises_from_sections`: flaches `exercises` am Unit-Payload. +- `apply_training_module_to_training_unit`: Sektion per **`section_order_index`** global. + +### Rahmen (`training_framework_programs.py`) + +- Blueprint-`training_units` pro Slot; gleiche `_replace_unit_sections`-Semantik. + +### Frontend + +- Planung: `TrainingPlanningPageRoot.jsx`, `TrainingUnitSectionsEditor`, `buildSectionsPayload` / `normalizeUnitToForm`. +- Run: `TrainingUnitRunPage.jsx` — Fortschritt `sessionStorage` Key `sj_training_run_checked_${unitId}`. +- Coach: `TrainingCoachPage.jsx` — `flattenPlanTimeline` (linearer Ablauf). + +### Tests + +- Kaum Abdeckung für Plan-Inhalt; vorhanden u. a. `test_training_unit_assignments.py` (Merge Co-Trainer, ohne DB), `test_training_units_list_keyset.py` (Keyset-Validierung). + +--- + +## 3. Technische Optionen und Empfehlung + +| Option | Kurz | +|--------|------| +| A JSONB nur auf `training_units` | Niedriges DDL-Risiko, hohes Drift-/Wartungsrisiko — **nicht empfohlen** | +| B Normalisiert Phasen/Streams | **Empfohlen** — eine Wahrheit, saubere Kopie, Rahmen kompatibel | +| C Nur UI-Konvention ohne DB | Widerspricht Produkt — **abgelehnt** | + +--- + +## 4. Migrations- und Kompatibilitätsstrategie + +- Default **`whole_group`‑Phase** für alle bestehenden Einheiten; alle bisherigen Sektionen erhalten `phase_id`. +- Unique-Regel: **pro Phase** bzw. **pro Stream** `order_index` eindeutig (partielle Unique-Indizes). +- API optional: zusätzlich abgeleitetes flaches `sections` für Übergang — Entscheidung je nach Consumer (praktisch nur dieses Frontend). + +--- + +## 5. API- / Frontend-Hotspots + +- `GET`/`PUT` `/api/training-units/{id}`: verschachtelte `phases` / `streams` / `sections` / `items`. +- `POST .../apply-training-module`: Kontext **Phase/Stream + Sektionsindex im Träger**. +- Run/Coach: stream-spezifischer Fortschritt; `flattenPlanTimeline` phase-aware oder pro Stream. + +--- + +## 6. Implementierungspakete (Überblick) + +0. Spike DDL + Contract-Doku +1. **Erledigt (2026-05-14):** Migration **063** + `training_planning`: Phasen/Streams-Schema, Backfill whole_group, `GET` mit `phases`, Legacy-`sections`-PUT unverändert (eine whole_group-Phase). +2. PUT mit echten Parallelphasen / Streams, `apply-training-module` mit Stream-Kontext, `from-framework-slot`-Kopie +3. Planung UI +4. Run + Coach +5. Co-Trainer pro Stream +6. MVP+ (Duplizieren, Verschieben, „nur meine Spur“) + +--- + +## 7. Risiken + +- Migration Unique-Constraint / bestehende Daten. +- Regression Run/Coach / Dashboard-Joins (meist unkritisch, solange `training_unit_id` auf Sektionen bleibt). +- Rahmen-Blueprints: gleiche Struktur wie Kalender-Einheiten anstreben (oder bewusst zweite Phase nur Kalender). + +--- + +## 8. Offene Produkt-/Technikfragen + +- Rahmen-Blueprint parallel im MVP oder erst nach Kalender-Einheit? +- Semantik `exercises`-Flatlist bei Parallelität. +- Merge-Regel `assistant_trainer_profile_ids` Kopf vs. Stream-Zuweisungen. + +--- + +## 9. Verweise + +- Fachkonzept: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md` +- Technische Spec (Entwurf): `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md` +- Domänenüberblick: `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams) +- `./PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md` — **Prompt** für Folgesession (Performance/Wartung/Vorbereitung) + +--- + +## 10. Vorbereitende Arbeiten (Session 2026-05-13) + +Ohne produktives Parallel-Feature, nur Risikoabbau und Transparenz: + +- **`training_planning.py`:** Lesepfad `_fetch_sections` in SQL-Konstanten + `_fetch_section_items_for_section` / `_hydrate_section_item_combination_slots` strukturiert; `_replace_unit_sections` delegiert an `_insert_one_replacement_section`; `_hydrate_training_unit_payload` dokumentiert. +- **Tests:** `tests/test_training_planning_sections_pure.py` (flatten, ohne DB); `tests/test_training_planning_sections_integration.py` (Roundtrip replace↔fetch bei `TRAINING_PLANNING_INTEGRATION=1`). +- **Frontend:** Kurzkommentare an Planung (`TrainingPlanningPageRoot`, `buildSectionsPayload`), Run, Coach, `flattenPlanTimeline` — Anbindungspunkte für spätere Phase/Stream-Logik. +- **DOMAIN_MODEL:** UNIQUE-Hinweis und „keine Migration ohne Freigabe“. + +**Empfohlene nächste Schritte:** Pakete **0** (DDL/Contract festzurren) und **1** (Schema + Migration + hydrate/replace laut Plan Abschnitt 4–6) in einer dedizierten Feature-Session; danach Paket **2** (PUT/Module/Clone). + +--- diff --git a/.claude/docs/working/PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md b/.claude/docs/working/PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md new file mode 100644 index 0000000..4079d15 --- /dev/null +++ b/.claude/docs/working/PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md @@ -0,0 +1,41 @@ +# Prompt: Vorbereitungs- / Vorarbeit-Session (Performance & Wartung) für „Parallele Trainingsstreams“ + +**Kontext:** Du arbeitest in **Shinkan Jinkendo** (`c:\Dev\shinkan-jinkendo`). Das Feature **Parallele Trainingsstreams / Breakout** ist **inhaltlich** spezifiziert; eine **Ist-Analyse und ein risikoarmer Umsetzungsplan** liegen **persistiert** in: + +- `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md` +- Fachlich: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md` +- Technik-Entwurf: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md` + +**Deine Rolle:** Du hast bereits **Refaktorierungs- und Wartungsaufgaben** mit Fokus **Performance, Lesbarkeit und Testbarkeit** durchgeführt. In **dieser** Session geht es **nicht** darum, das komplette Parallel-Feature zu bauen, sondern um **Vorarbeiten („Prerequisites“)**, die die geplante Komplexitätsauflösung **sicherer und billiger** machen. + +## Ziele + +1. **Lesepfad Planung vereinheitlichen:** `backend/routers/training_planning.py` ist zentral für `_fetch_sections`, `_replace_unit_sections`, `_hydrate_training_unit_payload`, Klonen, Blueprint-Kopie, `apply-training-module`. Prüfe, ob klar abgegrenzte Hilfsfunktionen (ohne Verhaltensänderung) die **nächste** große Änderung erleichtern — **keine** Feature-Logik für Phasen/Streams hinzufügen, nur Struktur/Tests/Docs wenn nötig. + +2. **Test-Lücken schließen (minimal, hoher Nutzen):** Heute fehlen **DB/API-Tests** für kritische Pfade (`_replace_unit_sections` Roundtrip, `from-framework-slot` Struktur-Kopie, optional `apply-training-module`). Ergänze **kleine, deterministische** Tests (pytest mit DB, falls im Projekt üblich), ohne riesige Fixtures. + +3. **Frontend-Schneidstellen markieren:** kurze Kommentare oder ein **Working-Doc-Update**, wo `TrainingPlanningPageRoot`, `buildSectionsPayload`, `TrainingUnitRunPage`, `TrainingCoachPage` + `trainingPlanUtils.flattenPlanTimeline` später angebunden werden — **kein** großes UI-Rewrite. + +4. **Migrations-Sicherheit:** Dokumentiere in **einem Absatz** im `ANALYSIS`-Dokument oder hier, welche **Unique-Constraints** (`training_unit_sections`: `UNIQUE (training_unit_id, order_index)`) die Parallelität blockieren — **ohne** sie schon zu ändern, außer es ist Teil einer **explizit** freigegebenen ersten Migration. + +5. **Performance nur berührensensible Stellen:** Einzelabruf `GET /api/training-units/{id}` wird mit mehr JOINs kommen. Falls du **jetzt** N+1 oder redundante Arbeit in `_fetch_sections` siehst und das **risikoarm** verbesserbar ist, nur mit **Messpunkt/Messvorstellung** (kein unnötiger Micro-Optimismus). + +## Leitplanken + +- **Stabilität vor Geschwindigkeit:** Keine Änderung, die bestehende Einheiten, Rahmen-Blueprints oder Run-Modus bricht. +- **Keine pauschalen Refactors:** Nur Änderungen mit **klarem** Träger für das Parallel-Feature oder mit **Test-Regression-Schutz**. +- **Regeln:** `.claude/rules/ARCHITECTURE.md`, `CODING_RULES.md`, Zugriffsschicht wo relevant. + +## Erwartete Ausgabe + +1. Kurze **Liste erledigter Vorarbeiten** (Dateien, was warum). +2. **Empfohlene Reihenfolge** für die **nächste** Session, die Phasen/Streams **implementiert** (verweis auf `PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md` Pakete 0–2). +3. Falls nichts Sinnvolles ohne Feature-Branch riskiert werden kann: **explizit** „keine Code-Änderung“, nur Risiko-Notiz. + +## Optionaler Startbefehl + +``` +Lies zuerst: +.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md +dann backend/routers/training_planning.py (Abschnitte um _fetch_sections, _replace_unit_sections). +``` diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index dd1bd90..b340ef3 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -10,7 +10,7 @@ on: types: [completed] jobs: - # Wie Mitai-Jinkendo: pytest im laufenden backend-Container (Python aus Image, gleiche DB wie Deploy). + # Pytest im laufenden backend-Container; ACCESS_LAYER + TRAINING_PLANNING Integration gegen dieselbe PostgreSQL wie Deploy (Schema via Container-Start migriert). pytest-backend: if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest @@ -39,7 +39,7 @@ jobs: cd /app && ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py && python scripts/security_release_checks.py && - ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short + ACCESS_LAYER_INTEGRATION=1 TRAINING_PLANNING_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short " lint-backend: diff --git a/backend/migrations/063_training_unit_phases_parallel_streams.sql b/backend/migrations/063_training_unit_phases_parallel_streams.sql new file mode 100644 index 0000000..0006b6e --- /dev/null +++ b/backend/migrations/063_training_unit_phases_parallel_streams.sql @@ -0,0 +1,85 @@ +-- Migration 063: Phasen und parallele Streams pro Trainingseinheit (Grundlage Breakout). +-- Bestehende Sektionen werden einer Default-whole_group-Phase zugeordnet. +-- UNIQUE (training_unit_id, order_index) auf Sektionen entfällt zugunsten +-- eindeutiger order_index je Phase bzw. je parallel_stream. + +-- ── Phasen ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_unit_phases ( + id SERIAL PRIMARY KEY, + training_unit_id INT NOT NULL REFERENCES training_units(id) ON DELETE CASCADE, + order_index INT NOT NULL, + phase_kind VARCHAR(20) NOT NULL CHECK (phase_kind IN ('whole_group', 'parallel')), + title VARCHAR(200), + guidance_notes TEXT, + UNIQUE (training_unit_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_unit_phases_unit ON training_unit_phases(training_unit_id); + +-- ── Streams innerhalb einer Parallelphase ────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_unit_parallel_streams ( + id SERIAL PRIMARY KEY, + phase_id INT NOT NULL REFERENCES training_unit_phases(id) ON DELETE CASCADE, + order_index INT NOT NULL, + title VARCHAR(200), + notes TEXT, + assigned_trainer_profile_ids JSONB, + UNIQUE (phase_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_unit_parallel_streams_phase + ON training_unit_parallel_streams(phase_id); + +COMMENT ON COLUMN training_unit_parallel_streams.assigned_trainer_profile_ids IS + 'Optionale Co-Trainer-IDs (JSON-Array von Profil-IDs) für diese Teilstrecke; MVP+'; + +-- ── Sektionen: Zuordnung zu Phase (gemeinsam) oder Stream (parallel) ───── +ALTER TABLE training_unit_sections + ADD COLUMN IF NOT EXISTS phase_id INT REFERENCES training_unit_phases(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS parallel_stream_id INT REFERENCES training_unit_parallel_streams(id) ON DELETE CASCADE; + +-- Backfill: je Einheit mit Sektionen eine whole_group-Phase, alle Sektionen dorthin +INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title) +SELECT tu.id, 0, 'whole_group', NULL +FROM training_units tu +WHERE EXISTS (SELECT 1 FROM training_unit_sections s WHERE s.training_unit_id = tu.id) + AND NOT EXISTS ( + SELECT 1 FROM training_unit_phases p + WHERE p.training_unit_id = tu.id AND p.order_index = 0 AND p.phase_kind = 'whole_group' +); + +UPDATE training_unit_sections tus +SET phase_id = p.id +FROM training_unit_phases p +WHERE tus.phase_id IS NULL + AND p.training_unit_id = tus.training_unit_id + AND p.order_index = 0 + AND p.phase_kind = 'whole_group'; + +-- Alte globale Reihenfolge-Eindeutigkeit pro Einheit entfernen +ALTER TABLE training_unit_sections + DROP CONSTRAINT IF EXISTS training_unit_sections_training_unit_id_order_index_key; + +-- Genau eine Zielspalte gesetzt: gemeinsame Phase ODER paralleler Stream +ALTER TABLE training_unit_sections + DROP CONSTRAINT IF EXISTS training_unit_sections_phase_or_stream_chk; + +ALTER TABLE training_unit_sections + ADD CONSTRAINT training_unit_sections_phase_or_stream_chk CHECK ( + (phase_id IS NOT NULL AND parallel_stream_id IS NULL) + OR (phase_id IS NULL AND parallel_stream_id IS NOT NULL) + ); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_phase_order + ON training_unit_sections (phase_id, order_index) + WHERE phase_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_stream_order + ON training_unit_sections (parallel_stream_id, order_index) + WHERE parallel_stream_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_unit_sections_phase + ON training_unit_sections(phase_id) WHERE phase_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_unit_sections_parallel_stream + ON training_unit_sections(parallel_stream_id) WHERE parallel_stream_id IS NOT NULL; diff --git a/backend/pytest.ini b/backend/pytest.ini index 3525b2d..54c05d6 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -6,5 +6,5 @@ python_functions = test_* addopts = -q --tb=short markers = smoke: Schnelle Kern-Regression. - integration: PostgreSQL-Mandanten-Integration (ACCESS_LAYER_INTEGRATION=1). + integration: PostgreSQL-Integration (z. B. ACCESS_LAYER_INTEGRATION=1, TRAINING_PLANNING_INTEGRATION=1). slow: Lange/schwere Tests; in CI wie Mitai-Jinkendo ausgeschlossen (Auswahl: not slow). diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 461999e..acd9449 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -484,6 +484,94 @@ def _normalize_assistant_trainer_profile_ids( ) return uniq + +def _normalize_stream_assigned_trainer_profile_ids( + cur, + raw_val: Any, + *, + group_id: Optional[int], + profile_id: int, + role: str, + unit_created_by: Optional[int], + eff_lead_nid: Optional[int], +) -> Any: + """ + JSONB-Liste für training_unit_parallel_streams.assigned_trainer_profile_ids. + Ohne group_id (Rahmen-Blueprint): nur Profil-Existenz + keine Überschneidung mit Leitung. + Mit group_id: gleiche Vereins-/Zuweisungsregeln wie assistant_trainer_profile_ids. + """ + if raw_val is None: + return None + if not isinstance(raw_val, list): + raise HTTPException( + status_code=400, + detail="assigned_trainer_profile_ids (Stream) muss Liste oder null sein", + ) + ids_in: List[int] = [] + for x in raw_val: + try: + i = int(x) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail="assigned_trainer_profile_ids (Stream) ungültig", + ) + if i < 1: + raise HTTPException( + status_code=400, + detail="assigned_trainer_profile_ids (Stream) ungültig", + ) + ids_in.append(i) + uniq = sorted(set(ids_in)) + for nid in uniq: + cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) + if not cur.fetchone(): + raise HTTPException( + status_code=400, + detail="Profil für Stream-Co-Trainer nicht gefunden", + ) + if eff_lead_nid is not None and nid == eff_lead_nid: + raise HTTPException( + status_code=400, + detail="Leitung und Stream-Co-Trainer dürfen sich nicht überschneiden", + ) + if group_id is None: + return uniq + cur.execute( + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", + (group_id,), + ) + gr = cur.fetchone() + if not gr: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + grd = dict(gr) + cid = grd.get("club_id") + if cid is None: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + club_i = int(cid) + if not is_platform_admin(role) and not _caller_may_assign_session_trainers( + cur, grd, profile_id, role, unit_created_by + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer (Stream) zuzuweisen") + eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set() + for x in grd.get("co_trainer_ids") or []: + try: + eligible.add(int(x)) + except (TypeError, ValueError): + continue + for nid in uniq: + if is_platform_admin(role): + continue + if nid in eligible: + continue + if not _profile_active_in_club(cur, club_i, nid): + raise HTTPException( + status_code=400, + detail="Stream-Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe", + ) + return uniq + + def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]: """None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung.""" if raw is None: @@ -522,21 +610,52 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]: return i -def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: +# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ────────────────── +# Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich. + + +def _clear_unit_plan_content(cur, unit_id: int) -> None: + """Löscht alle Planungs-Phasen der Einheit (CASCADE: Streams, Sektionen, Items).""" + cur.execute("DELETE FROM training_unit_phases WHERE training_unit_id = %s", (unit_id,)) + + +def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0) -> int: + """Legt bei Bedarf eine whole_group-Phase an; gibt phase.id zurück.""" cur.execute( """ - SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id - FROM training_unit_sections - WHERE training_unit_id = %s - ORDER BY order_index + SELECT id FROM training_unit_phases + WHERE training_unit_id = %s AND phase_kind = 'whole_group' AND order_index = %s + LIMIT 1 """, - (unit_id,), + (unit_id, order_index), ) - secs = [] - for sec_row in cur.fetchall(): - sec = r2d(sec_row) - cur.execute( - """ + row = cur.fetchone() + if row: + return int(row["id"]) + cur.execute( + """ + INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes) + VALUES (%s, %s, 'whole_group', NULL, NULL) + RETURNING id + """, + (unit_id, order_index), + ) + return int(cur.fetchone()["id"]) + + +_SECTION_ROWS_SQL = """ + SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes, + tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id + FROM training_unit_sections tus + LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id + LEFT JOIN training_unit_parallel_streams ps ON ps.id = tus.parallel_stream_id + LEFT JOIN training_unit_phases ph_s ON ph_s.id = ps.phase_id + WHERE tus.training_unit_id = %s + ORDER BY COALESCE(ph.order_index, ph_s.order_index) ASC, + ps.order_index ASC NULLS FIRST, + tus.order_index ASC +""" +_SECTION_ITEMS_ROWS_SQL = """ SELECT tusi.*, e.title AS exercise_title, e.exercise_kind AS exercise_kind, @@ -558,75 +677,201 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id WHERE tusi.section_id = %s ORDER BY tusi.order_index - """, - (sec["id"],), - ) - sec["items"] = [r2d(r) for r in cur.fetchall()] - for it in sec["items"]: - if it.get("item_type") != "exercise": - continue - cmp_raw = it.get("catalog_method_profile") - if not isinstance(cmp_raw, dict): - it["catalog_method_profile"] = {} - else: - it["catalog_method_profile"] = dict(cmp_raw) - ek = str(it.get("exercise_kind") or "simple").strip().lower() - if ek == "combination" and it.get("exercise_id"): - try: - it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"])) - except (TypeError, ValueError): - it["combination_slots"] = [] - else: - it["combination_slots"] = [] +""" + + +def _hydrate_section_item_combination_slots(cur, it: Dict[str, Any]) -> None: + """Setzt `combination_slots` für Kombi‑Übungen; sonst leere Liste.""" + if it.get("item_type") != "exercise": + return + cmp_raw = it.get("catalog_method_profile") + if not isinstance(cmp_raw, dict): + it["catalog_method_profile"] = {} + else: + it["catalog_method_profile"] = dict(cmp_raw) + ek = str(it.get("exercise_kind") or "simple").strip().lower() + if ek == "combination" and it.get("exercise_id"): + try: + it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"])) + except (TypeError, ValueError): + it["combination_slots"] = [] + else: + it["combination_slots"] = [] + + +def _fetch_section_items_for_section(cur, section_id: int) -> List[Dict[str, Any]]: + cur.execute(_SECTION_ITEMS_ROWS_SQL, (section_id,)) + items = [r2d(r) for r in cur.fetchall()] + for it in items: + _hydrate_section_item_combination_slots(cur, it) + return items + + +def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: + """Lädt alle Sektionen inkl. Items und Katalog-Anreicherung für die Einheit.""" + cur.execute(_SECTION_ROWS_SQL, (unit_id,)) + secs = [] + for sec_row in cur.fetchall(): + sec = r2d(sec_row) + sec["items"] = _fetch_section_items_for_section(cur, sec["id"]) secs.append(sec) return secs -def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: - """Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder).""" - secs = _fetch_sections(cur, unit_id) +def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]: + """Verschachtelte Phasen/Streams/Sektionen für GET (UI kann parallele Sp später nutzen).""" + cur.execute( + """ + SELECT id, training_unit_id, order_index, phase_kind, title, guidance_notes + FROM training_unit_phases + WHERE training_unit_id = %s + ORDER BY order_index + """, + (unit_id,), + ) out: List[Dict[str, Any]] = [] - for sec in secs: - items_clean: List[Dict[str, Any]] = [] - for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): - itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") - oix = it.get("order_index") - if itype == "note": - note_item = { - "item_type": "note", - "order_index": oix, - "note_body": it.get("note_body") or "", - } - sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) - if sm is not None: - note_item["source_training_module_id"] = sm - items_clean.append(note_item) - continue - if itype != "exercise" or not it.get("exercise_id"): - continue - ex_item = { - "item_type": "exercise", + for prow in cur.fetchall(): + p = r2d(prow) + pk = str(p.get("phase_kind") or "").strip().lower() + if pk == "whole_group": + cur.execute( + """ + SELECT id, training_unit_id, order_index, title, guidance_notes, + source_template_section_id, phase_id, parallel_stream_id + FROM training_unit_sections + WHERE phase_id = %s + ORDER BY order_index + """, + (p["id"],), + ) + secs: List[Dict[str, Any]] = [] + for srow in cur.fetchall(): + sec = r2d(srow) + sec["items"] = _fetch_section_items_for_section(cur, sec["id"]) + secs.append(sec) + p["sections"] = secs + p["streams"] = [] + elif pk == "parallel": + p["sections"] = [] + cur.execute( + """ + SELECT id, phase_id, order_index, title, notes, assigned_trainer_profile_ids + FROM training_unit_parallel_streams + WHERE phase_id = %s + ORDER BY order_index + """, + (p["id"],), + ) + streams: List[Dict[str, Any]] = [] + for st_row in cur.fetchall(): + st = r2d(st_row) + cur.execute( + """ + SELECT id, training_unit_id, order_index, title, guidance_notes, + source_template_section_id, phase_id, parallel_stream_id + FROM training_unit_sections + WHERE parallel_stream_id = %s + ORDER BY order_index + """, + (st["id"],), + ) + secs = [] + for sec_row in cur.fetchall(): + sec = r2d(sec_row) + sec["items"] = _fetch_section_items_for_section(cur, sec["id"]) + secs.append(sec) + st["sections"] = secs + streams.append(st) + p["streams"] = streams + else: + p["sections"] = [] + p["streams"] = [] + out.append(p) + return out + + +def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]: + """Sektion inkl. Items ohne DB-IDs (für phases-Payload / Kopie).""" + items_clean: List[Dict[str, Any]] = [] + for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): + itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") + oix = it.get("order_index") + if itype == "note": + note_item = { + "item_type": "note", "order_index": oix, - "exercise_id": it["exercise_id"], - "exercise_variant_id": it.get("exercise_variant_id"), - "planned_duration_min": it.get("planned_duration_min"), - "actual_duration_min": it.get("actual_duration_min"), - "notes": it.get("notes"), - "modifications": it.get("modifications"), - "planning_method_profile": it.get("planning_method_profile"), + "note_body": it.get("note_body") or "", } sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) if sm is not None: - ex_item["source_training_module_id"] = sm - items_clean.append(ex_item) - out.append( - { - "title": sec.get("title"), - "order_index": sec.get("order_index"), - "guidance_notes": sec.get("guidance_notes"), - "items": items_clean, - } - ) + note_item["source_training_module_id"] = sm + items_clean.append(note_item) + continue + if itype != "exercise" or not it.get("exercise_id"): + continue + ex_item = { + "item_type": "exercise", + "order_index": oix, + "exercise_id": it["exercise_id"], + "exercise_variant_id": it.get("exercise_variant_id"), + "planned_duration_min": it.get("planned_duration_min"), + "actual_duration_min": it.get("actual_duration_min"), + "notes": it.get("notes"), + "modifications": it.get("modifications"), + "planning_method_profile": it.get("planning_method_profile"), + } + sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) + if sm is not None: + ex_item["source_training_module_id"] = sm + items_clean.append(ex_item) + row: Dict[str, Any] = { + "title": sec.get("title"), + "order_index": sec.get("order_index"), + "guidance_notes": sec.get("guidance_notes"), + "items": items_clean, + } + stid = sec.get("source_template_section_id") + if stid is not None and stid != "": + try: + stid_i = int(stid) + if stid_i >= 1: + row["source_template_section_id"] = stid_i + except (TypeError, ValueError): + pass + return row + + +def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: + """Vollständige Phasen/Streams/Sektionen für tiefe Kopie (ohne DB-IDs).""" + nested = _fetch_phases_nested(cur, unit_id) + out: List[Dict[str, Any]] = [] + for ph in nested: + kind = str(ph.get("phase_kind") or "").strip().lower() + if kind not in ("whole_group", "parallel"): + kind = "whole_group" + pd: Dict[str, Any] = { + "order_index": ph.get("order_index"), + "phase_kind": kind, + "title": ph.get("title"), + "guidance_notes": ph.get("guidance_notes"), + } + if kind == "whole_group": + pd["sections"] = [_clone_section_payload_dict(s) for s in ph.get("sections") or []] + pd["streams"] = [] + else: + pd["sections"] = [] + streams_clean: List[Dict[str, Any]] = [] + for st in ph.get("streams") or []: + sd: Dict[str, Any] = { + "order_index": st.get("order_index"), + "title": st.get("title"), + "notes": st.get("notes"), + "assigned_trainer_profile_ids": st.get("assigned_trainer_profile_ids"), + "sections": [_clone_section_payload_dict(s) for s in st.get("sections") or []], + } + streams_clean.append(sd) + pd["streams"] = streams_clean + out.append(pd) return out @@ -637,6 +882,7 @@ def _copy_blueprint_into_scheduled_unit( planned_date: str, profile_id: int, origin_framework_slot_id: Optional[int], + role: str, ) -> int: cur.execute( """ @@ -692,8 +938,8 @@ def _copy_blueprint_into_scheduled_unit( if not row: raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden") nu = row["id"] - cloned = _sections_clone_payload(cur, blueprint_unit_id) - _replace_unit_sections(cur, nu, cloned) + cloned = _phases_clone_payload(cur, blueprint_unit_id) + _replace_unit_phases(cur, nu, cloned, profile_id, role, profile_id) return nu @@ -707,17 +953,27 @@ def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None: def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]: + """GET-Payload: `phases` (verschachtelt), flache `sections` + abgeleitete `exercises` (Legacy).""" uid = unit["id"] + unit["phases"] = _fetch_phases_nested(cur, uid) unit["sections"] = _fetch_sections(cur, uid) _flatten_exercises_from_sections(unit) return unit def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int: + """Erste Sektion mit order_index in einer whole_group-Phase (Parallelstreams ausgeschlossen).""" cur.execute( """ - SELECT id FROM training_unit_sections - WHERE training_unit_id = %s AND order_index = %s + SELECT tus.id + FROM training_unit_sections tus + INNER JOIN training_unit_phases p ON p.id = tus.phase_id + WHERE tus.training_unit_id = %s + AND tus.order_index = %s + AND tus.parallel_stream_id IS NULL + AND LOWER(TRIM(p.phase_kind)) = 'whole_group' + ORDER BY p.order_index ASC, tus.id ASC + LIMIT 1 """, (unit_id, section_order_index), ) @@ -729,6 +985,52 @@ def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: in return int(r["id"]) +def _resolve_training_unit_section_id_for_apply( + cur, + unit_id: int, + section_order_index: int, + *, + phase_order_index: Optional[int], + parallel_stream_order_index: Optional[int], +) -> int: + """Ziel-Abschnitt: ganzes Gruppen physisch (nur section_order_index) oder innerhalb eines Parallelstreams.""" + if parallel_stream_order_index is None: + return _resolve_training_unit_section_id(cur, unit_id, section_order_index) + if phase_order_index is None: + raise HTTPException( + status_code=400, + detail="phase_order_index ist bei parallel_stream_order_index Pflicht", + ) + cur.execute( + """ + SELECT tus.id + FROM training_unit_sections tus + INNER JOIN training_unit_parallel_streams st ON st.id = tus.parallel_stream_id + INNER JOIN training_unit_phases p ON p.id = st.phase_id + WHERE tus.training_unit_id = %s + AND tus.order_index = %s + AND st.order_index = %s + AND p.order_index = %s + AND LOWER(TRIM(p.phase_kind)) = 'parallel' + ORDER BY tus.id ASC + LIMIT 1 + """, + ( + unit_id, + section_order_index, + parallel_stream_order_index, + phase_order_index, + ), + ) + r = cur.fetchone() + if not r: + raise HTTPException( + status_code=400, + detail="Abschnitt im Parallelstream für diese Indizes nicht gefunden", + ) + return int(r["id"]) + + def _append_copied_module_items_to_section( cur, section_id: int, @@ -874,31 +1176,188 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s ) +def _insert_one_replacement_section( + cur, + unit_id: int, + sec: Any, + enumeration_index: int, + *, + phase_id: Optional[int] = None, + parallel_stream_id: Optional[int] = None, +) -> None: + """Eine Sektion inkl. Items (genau eines von phase_id / parallel_stream_id gesetzt).""" + if (phase_id is None) == (parallel_stream_id is None): + raise HTTPException( + status_code=500, detail="Intern: Sektion braucht phase_id oder parallel_stream_id" + ) + title = (sec.get("title") or "").strip() or "Abschnitt" + order_ix = sec.get("order_index") + if order_ix is None: + order_ix = enumeration_index + src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id") + 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, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + unit_id, + phase_id, + parallel_stream_id, + order_ix, + title, + sec.get("guidance_notes"), + src_tsec, + ), + ) + sid = cur.fetchone()["id"] + _insert_section_items(cur, sid, sec.get("items")) + + def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]): - cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) + """Ersetzt den gesamten Plan (Legacy): eine whole_group-Phase + Sektionen.""" + _clear_unit_plan_content(cur, unit_id) + phase_id = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) for si, sec in enumerate(sections_in): - title = (sec.get("title") or "").strip() or "Abschnitt" - order_ix = sec.get("order_index") - if order_ix is None: - order_ix = si - src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id") + _insert_one_replacement_section( + cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None + ) + + +def _replace_unit_phases( + cur, + unit_id: int, + phases_in: List[Any], + profile_id: int, + role: str, + unit_created_by: Optional[int], +) -> None: + """Ersetzt Phasen inkl. paralleler Streams und Sektionen (voller Plan).""" + if not isinstance(phases_in, list): + raise HTTPException(status_code=400, detail="phases muss eine Liste sein") + + cur.execute( + """ + SELECT tu.group_id, + COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS eff_lead + FROM training_units tu + LEFT JOIN training_groups tg ON tg.id = tu.group_id + WHERE tu.id = %s + """, + (unit_id,), + ) + ur = cur.fetchone() + group_id_opt = int(ur["group_id"]) if ur and ur.get("group_id") is not None else None + eff_lead_raw = ur.get("eff_lead") if ur else None + eff_lead_nid = int(eff_lead_raw) if eff_lead_raw is not None else None + + _clear_unit_plan_content(cur, unit_id) + for pi, ph in enumerate(phases_in): + kind = str(ph.get("phase_kind") or "").strip().lower() + if kind not in ("whole_group", "parallel"): + raise HTTPException( + status_code=400, + detail="phase_kind muss whole_group oder parallel sein", + ) + # Reihenfolge strikt aus der Liste (pi): vermeidet UNIQUE(tu, order_index)-Kollisionen, + # wenn der Client dieselbe phase_order_index mehrfach trägt (z. B. nach Zuordnungswechseln). + p_oix = int(pi) cur.execute( """ - INSERT INTO training_unit_sections ( - training_unit_id, order_index, title, guidance_notes, source_template_section_id - ) VALUES (%s, %s, %s, %s, %s) + INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes) + VALUES (%s, %s, %s, %s, %s) RETURNING id """, ( unit_id, - order_ix, - title, - sec.get("guidance_notes"), - src_tsec, + p_oix, + kind, + ph.get("title"), + ph.get("guidance_notes"), ), ) - sid = cur.fetchone()["id"] - _insert_section_items(cur, sid, sec.get("items")) + phase_id = int(cur.fetchone()["id"]) + if kind == "whole_group": + secs = ph.get("sections") + if secs is None: + secs = [] + if not isinstance(secs, list): + raise HTTPException(status_code=400, detail="sections muss Liste sein") + for si, sec in enumerate(secs): + _insert_one_replacement_section( + cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None + ) + else: + streams = ph.get("streams") + if streams is None: + streams = [] + if not isinstance(streams, list): + raise HTTPException(status_code=400, detail="streams muss Liste sein") + for si, st in enumerate(streams): + raw_asst = st.get("assigned_trainer_profile_ids") + asst_norm = _normalize_stream_assigned_trainer_profile_ids( + cur, + raw_asst, + group_id=group_id_opt, + profile_id=profile_id, + role=role, + unit_created_by=unit_created_by, + eff_lead_nid=eff_lead_nid, + ) + asst_db = None if asst_norm is None else PsycopgJson(asst_norm) + st_oix = st.get("order_index") + if st_oix is None: + st_oix = si + cur.execute( + """ + INSERT INTO training_unit_parallel_streams ( + phase_id, order_index, title, notes, assigned_trainer_profile_ids + ) VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + phase_id, + int(st_oix), + st.get("title"), + st.get("notes"), + asst_db, + ), + ) + sid = int(cur.fetchone()["id"]) + secs = st.get("sections") + if secs is None: + secs = [] + if not isinstance(secs, list): + raise HTTPException( + status_code=400, + detail="sections (Stream) muss Liste sein", + ) + for ti, sec in enumerate(secs): + _insert_one_replacement_section( + cur, unit_id, sec, ti, phase_id=None, parallel_stream_id=sid + ) + + +def _assert_single_plan_content_key_create(data: dict) -> None: + """Höchstens ein Plan-Inhalt: phases | sections | exercises (Non-None).""" + n = sum(1 for k in ("phases", "sections", "exercises") if data.get(k) is not None) + if n > 1: + raise HTTPException( + status_code=400, + detail="Nur eines von phases, sections oder exercises angeben", + ) + + +def _assert_single_plan_content_key_update(data: dict) -> None: + """PUT: höchstens einer der Keys phases | sections | exercises.""" + keys = [k for k in ("phases", "sections", "exercises") if k in data] + if len(keys) > 1: + raise HTTPException( + status_code=400, + detail="Nur eines von phases, sections oder exercises im Body gleichzeitig", + ) def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]: @@ -1027,13 +1486,16 @@ def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int, def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]): if not exercises_in: return + pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) cur.execute( """ - INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) - VALUES (%s, 0, %s, NULL) + INSERT INTO training_unit_sections ( + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes + ) + VALUES (%s, %s, NULL, 0, %s, NULL) RETURNING id """, - (unit_id, "Übungen"), + (unit_id, pid, "Übungen"), ) sid = cur.fetchone()["id"] slot = 0 @@ -1062,6 +1524,8 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List 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) cur.execute( """ SELECT id, title, guidance_text @@ -1072,29 +1536,26 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int): (template_id,), ) rows = cur.fetchall() - for row in rows: + for gi, row in enumerate(rows): r = r2d(row) cur.execute( """ INSERT INTO training_unit_sections ( - training_unit_id, order_index, title, guidance_notes, source_template_section_id - ) VALUES (%s, ( - SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2 - WHERE u2.training_unit_id = %s - ), %s, %s, %s) + 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, unit_id, r["title"], r["guidance_text"], r["id"]), + (unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]), ) # Fallback: keine Sektionen in Vorlage → ein leerer Block if not rows: cur.execute( """ - INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) - SELECT %s, 0, %s, NULL - WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s) + INSERT INTO training_unit_sections ( + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes + ) VALUES (%s, %s, NULL, 0, 'Hauptteil', NULL) """, - (unit_id, "Hauptteil", unit_id), + (unit_id, pid), ) @@ -1758,7 +2219,11 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c def apply_training_module_to_training_unit( unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) ): - """Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar).""" + """Kopiert Modul-Positionen ans Ende eines Abschnitts. + + Ziel: `section_order_index` in einer whole_group-Phase (Standard) oder + zusätzlich `phase_order_index` + `parallel_stream_order_index` für einen Stream. + """ profile_id = tenant.profile_id role = tenant.global_role if not _has_planning_role(role): @@ -1780,12 +2245,44 @@ def apply_training_module_to_training_unit( if section_order_index < 0: raise HTTPException(status_code=400, detail="section_order_index ungültig") + ps_raw = data.get("parallel_stream_order_index") + parallel_stream_oi: Optional[int] = None + if ps_raw is not None and ps_raw != "": + try: + parallel_stream_oi = int(ps_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig") + if parallel_stream_oi < 0: + raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig") + + phase_oi: Optional[int] = None + ph_raw = data.get("phase_order_index") + if ph_raw is not None and ph_raw != "": + try: + phase_oi = int(ph_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="phase_order_index ungültig") + if phase_oi < 0: + raise HTTPException(status_code=400, detail="phase_order_index ungültig") + + if phase_oi is not None and parallel_stream_oi is None: + raise HTTPException( + status_code=400, + detail="phase_order_index nur zusammen mit parallel_stream_order_index", + ) + with get_db() as conn: cur = get_cursor(conn) unit_row = _training_unit_guard_row(cur, unit_id) _assert_training_unit_permission(cur, unit_row, profile_id, role) - section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index) + section_id = _resolve_training_unit_section_id_for_apply( + cur, + unit_id, + section_order_index, + phase_order_index=phase_oi, + parallel_stream_order_index=parallel_stream_oi, + ) mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role) _append_copied_module_items_to_section(cur, section_id, mod_items, src_mid) _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) @@ -1863,6 +2360,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ lead_ins, ) if assistant_set: + av_db = None if assistant_val is None else PsycopgJson(assistant_val) cur.execute( """ INSERT INTO training_units ( @@ -1874,7 +2372,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, - base_params + (assistant_val,), + base_params + (av_db,), ) else: cur.execute( @@ -1892,10 +2390,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ unit_id = cur.fetchone()["id"] + _assert_single_plan_content_key_create(data) + phases_in = data.get("phases") sections_in = data.get("sections") exercises_in = data.get("exercises") - if sections_in is not None: + if phases_in is not None: + _replace_unit_phases(cur, unit_id, phases_in, profile_id, role, profile_id) + 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) @@ -2013,7 +2515,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen eff_lead_for_co, ) assist_sql = ", assistant_trainer_profile_ids = %s" - assist_params.append(na) + assist_params.append(None if na is None else PsycopgJson(na)) debrief_frag = "" if "debrief_completed" in data and not is_blueprint: @@ -2071,20 +2573,29 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request", ) _template_access(cur, tid, profile_id, role) - cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) cur.execute( "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id) ) _instantiate_from_template(cur, unit_id, tid) content_handled = True - if not content_handled and "sections" in data: + _assert_single_plan_content_key_update(data) + if not content_handled and "phases" in data: + _replace_unit_phases( + cur, + unit_id, + data.get("phases") or [], + profile_id, + role, + unit_row.get("created_by"), + ) + elif not content_handled and "sections" in data: _replace_unit_sections(cur, unit_id, data["sections"] or []) elif not content_handled and "exercises" in data: - cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) + _clear_unit_plan_content(cur, unit_id) _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or []) - if content_handled or "sections" in data or "exercises" in data: + if content_handled or any(k in data for k in ("phases", "sections", "exercises")): _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() @@ -2196,6 +2707,7 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = str(planned_date), profile_id, slot_id, + role, ) _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role) diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py index c9ea3dc..313b45b 100644 --- a/backend/tests/test_dashboard_kpis.py +++ b/backend/tests/test_dashboard_kpis.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from unittest.mock import patch import pytest from fastapi import Query @@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1") from fastapi_param_unwrap import unwrap_query_default from main import app +from tenant_context import TenantContext, get_tenant_context @pytest.fixture @@ -27,3 +29,41 @@ def test_unwrap_query_default_for_direct_route_calls() -> None: def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None: r = client.get("/api/dashboard/kpis") assert r.status_code == 401 + + +def _fake_tenant_for_kpis() -> TenantContext: + return TenantContext( + profile_id=42, + global_role="trainer", + effective_club_id=7, + club_ids=frozenset({7}), + memberships=[], + ) + + +@patch("routers.dashboard.list_training_units") +@patch("routers.dashboard.list_exercises_like_get") +def test_dashboard_kpis_200_when_inner_lists_mocked( + mock_list_ex: object, + mock_list_tu: object, + client: TestClient, +) -> None: + mock_list_ex.return_value = [] + mock_list_tu.return_value = [] + app.dependency_overrides[get_tenant_context] = _fake_tenant_for_kpis + try: + r = client.get("/api/dashboard/kpis") + assert r.status_code == 200, r.text + data = r.json() + assert "year" in data + assert data["draft_count"] == 0 + assert data["mine_count"] == 0 + assert data["ytd_completed_count"] == 0 + th = data["training_home"] + assert th["upcoming"] == [] + assert th["planned_with_notes"] == [] + assert th["review_pending"] == [] + assert mock_list_ex.call_count == 2 + assert mock_list_tu.call_count == 3 + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/test_training_planning_sections_integration.py b/backend/tests/test_training_planning_sections_integration.py new file mode 100644 index 0000000..5f71a11 --- /dev/null +++ b/backend/tests/test_training_planning_sections_integration.py @@ -0,0 +1,290 @@ +""" +PostgreSQL-Integration: Roundtrip _replace_unit_sections / _replace_unit_phases ↔ Fetch-Helfer. + +Aktivierung: + - Lokal: TRAINING_PLANNING_INTEGRATION=1 + - CI: .gitea/workflows/test.yml setzt die Variable beim pytest-Lauf (deployter Backend-Container + PostgreSQL). + +Voraussetzung: migrierte DB, DB_* wie Docker-Compose. +""" +from __future__ import annotations + +import os +import uuid + +import pytest + +from db import get_db, get_cursor +from routers.training_planning import ( + _fetch_phases_nested, + _fetch_sections, + _replace_unit_phases, + _replace_unit_sections, +) + + +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_replace_sections_roundtrip(db_ready): + """INSERT-Hilfsdaten, replace, fetch — gleiche Semantik wie produktiver PUT-Pfad.""" + suffix = uuid.uuid4().hex[:12] + club_name = f"tpl_it_club_{suffix}" + email = f"tpl_it_{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, "T", "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"TP {suffix}", "trainer", club_id), + ) + profile_id = int(cur.fetchone()["id"]) + + cur.execute( + """ + INSERT INTO training_groups (club_id, name, trainer_id, status) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (club_id, f"Gruppe {suffix}", profile_id, "active"), + ) + group_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 {suffix}", "Ziel", "Ablauf", "private", "draft", profile_id), + ) + ex_id = int(cur.fetchone()["id"]) + + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, status, created_by + ) VALUES (%s, %s, %s, %s) + RETURNING id + """, + (group_id, "2026-06-01", "planned", profile_id), + ) + unit_id = int(cur.fetchone()["id"]) + + sections_in = [ + { + "title": "A1", + "order_index": 0, + "guidance_notes": "gn", + "items": [ + { + "item_type": "note", + "order_index": 0, + "note_body": "Hinweis", + }, + { + "item_type": "exercise", + "order_index": 1, + "exercise_id": ex_id, + "planned_duration_min": 5, + "notes": "n1", + }, + ], + }, + { + "title": "B2", + "order_index": 1, + "items": [], + }, + ] + _replace_unit_sections(cur, unit_id, sections_in) + loaded = _fetch_sections(cur, unit_id) + conn.commit() + + try: + assert len(loaded) == 2 + assert loaded[0]["title"] == "A1" + assert loaded[0]["guidance_notes"] == "gn" + assert loaded[0]["order_index"] == 0 + assert len(loaded[0]["items"]) == 2 + assert loaded[0]["items"][0]["item_type"] == "note" + assert loaded[0]["items"][0]["note_body"] == "Hinweis" + assert loaded[0]["items"][1]["item_type"] == "exercise" + assert int(loaded[0]["items"][1]["exercise_id"]) == ex_id + assert loaded[0]["items"][1]["planned_duration_min"] == 5 + assert loaded[1]["title"] == "B2" + assert loaded[1]["items"] == [] + finally: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) + cur.execute("DELETE FROM exercises WHERE id = %s", (ex_id,)) + cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,)) + cur.execute("DELETE FROM profiles WHERE id = %s", (profile_id,)) + cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,)) + conn.commit() + + +def test_replace_phases_roundtrip_parallel_stream(db_ready): + """Phasen inkl. parallel-Stream-Sektionen ersetzen und wieder laden.""" + suffix = uuid.uuid4().hex[:12] + club_name = f"ph_it_club_{suffix}" + email = f"ph_it_{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, "P", "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"PH {suffix}", "trainer", club_id), + ) + profile_id = int(cur.fetchone()["id"]) + + cur.execute( + """ + INSERT INTO training_groups (club_id, name, trainer_id, status) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (club_id, f"Gruppe PH {suffix}", profile_id, "active"), + ) + group_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 PH {suffix}", "Ziel", "Ablauf", "private", "draft", profile_id), + ) + ex_id = int(cur.fetchone()["id"]) + + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, status, created_by + ) VALUES (%s, %s, %s, %s) + RETURNING id + """, + (group_id, "2026-06-02", "planned", profile_id), + ) + unit_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, + }, + ], + }, + ], + }, + ], + }, + ] + _replace_unit_phases(cur, unit_id, phases_in, profile_id, "trainer", profile_id) + nested = _fetch_phases_nested(cur, unit_id) + flat_sec = _fetch_sections(cur, 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 len(streams[0]["sections"][0].get("items") or []) == 1 + assert int(streams[0]["sections"][0]["items"][0]["exercise_id"]) == ex_id + assert len(flat_sec) == 2 + finally: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) + cur.execute("DELETE FROM exercises WHERE id = %s", (ex_id,)) + cur.execute("DELETE FROM training_groups WHERE id = %s", (group_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/tests/test_training_planning_sections_pure.py b/backend/tests/test_training_planning_sections_pure.py new file mode 100644 index 0000000..fd71e59 --- /dev/null +++ b/backend/tests/test_training_planning_sections_pure.py @@ -0,0 +1,32 @@ +"""Unit-Tests ohne DB: abgeleitete Trainingseinheit-Payload-Helfer.""" +import pytest + +from routers.training_planning import _flatten_exercises_from_sections + + +def test_flatten_exercises_from_sections_order(): + unit = { + "sections": [ + { + "order_index": 1, + "items": [ + {"order_index": 1, "item_type": "exercise", "exercise_id": 10}, + {"order_index": 0, "item_type": "note"}, + {"order_index": 2, "item_type": "exercise", "exercise_id": 20}, + ], + }, + { + "order_index": 0, + "items": [{"order_index": 0, "item_type": "exercise", "exercise_id": 5}], + }, + ] + } + _flatten_exercises_from_sections(unit) + # Sektionen nach order_index; innerhalb nur exercise-Items nach order_index + assert [x["exercise_id"] for x in unit["exercises"]] == [5, 10, 20] + + +def test_flatten_exercises_from_sections_empty(): + unit = {"sections": []} + _flatten_exercises_from_sections(unit) + assert unit["exercises"] == [] diff --git a/backend/version.py b/backend/version.py index 7364ece..aaf4f4d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.123" +APP_VERSION = "0.8.140" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260514062" +DB_SCHEMA_VERSION = "20260515063" 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.9.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id) + "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 "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,122 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.140", + "date": "2026-05-14", + "changes": [ + "Frontend Trainingsplanung: Breakout-Panel (neue Ganzgruppen-/parallele Phase, Stream in letzter parallelen Phase); pro Abschnitt Zuordnung zu Phase/Stream oder klassischer Ein-Ganzgruppen-Ablauf.", + ], + }, + { + "version": "0.8.139", + "date": "2026-05-14", + "changes": [ + "Frontend Trainingsplanung: GET phases → Editor mit planLoc pro Abschnitt; Speichern sendet PUT phases bei Breakout-Einheiten (sonst weiter sections); Modul-Dialog zeigt Phase/Stream in der Abschnittsauswahl.", + ], + }, + { + "version": "0.8.138", + "date": "2026-05-14", + "changes": [ + "Planung Paket 2: POST/PUT training_units mit phases (voller Phasen-/Stream-Plan); höchstens eines von phases, sections, exercises pro Request; Rahmen-Blueprint→Termin kopiert verschachtelten Plan; apply-training-module optional phase_order_index + parallel_stream_order_index.", + "Fix: POST from-framework-slot übergibt role an _copy_blueprint_into_scheduled_unit (Stream-Trainer-Validierung).", + "Integrationstest test_replace_phases_roundtrip_parallel_stream.", + ], + }, + { + "version": "0.8.137", + "date": "2026-05-14", + "changes": [ + "DB 063: training_unit_phases, training_unit_parallel_streams; Sektionen mit phase_id oder parallel_stream_id; Default whole_group für Bestand.", + "Planung: GET training_unit liefert phases (verschachtelt) + sections (flach sortiert); Legacy-PUT sections weiterhin eine whole_group-Phase.", + ], + }, + { + "version": "0.8.136", + "date": "2026-05-13", + "changes": [ + "Fix: api/exercises.js — Mandanten-Header für Raw-fetch (PUT Übung, Medien-Upload, Bulk-Archiv) über lokale withActiveClubHeaders statt mergeActiveClubHeader-Import (ReferenceError beim Speichern).", + ], + }, + { + "version": "0.8.135", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 4 Welle 3: frontend/src/api/exercises.js (Übungen, Medien/Archiv, Progressionsgraphen, KI); client.js exportiert API_URL und mergeActiveClubHeader; utils/api.js re-exportiert Modul, api-Objekt spread exercises.", + ], + }, + { + "version": "0.8.134", + "date": "2026-05-14", + "changes": [ + "Frontend Phase 4 Welle 2: frontend/src/api/planning.js (Trainingsplanung); utils/api.js re-exportiert Modul, api-Objekt spread planning.", + ], + }, + { + "version": "0.8.133", + "date": "2026-05-14", + "changes": [ + "Frontend Phase 4 Welle 1: frontend/src/api/client.js (request, ACTIVE_CLUB_STORAGE_KEY); utils/api.js importiert Client, bleibt Facade. Roadmap Phase 4 gestartet.", + ], + }, + { + "version": "0.8.132", + "date": "2026-05-14", + "changes": [ + "Frontend Phase 3 abgeschlossen: TrainingPlanningPageRoot, ExerciseFormPageRoot, ExercisesListPageRoot unter components/; pages/ nur Re-Export (Soft-Limit). Roadmap UMSETZUNGSPLAN Phase 3 / M3 aktualisiert.", + ], + }, + { + "version": "0.8.131", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningUnitFormModal (Neu/Bearbeiten-Einheit); frameworkLineageText in trainingPlanningPageHelpers; BASELINE_SNAPSHOT §3.4 k6-Log-Mapping.", + ], + }, + { + "version": "0.8.130", + "date": "2026-05-13", + "changes": [ + "Fix: PUT/POST training_units — assistant_trainer_profile_ids als JSONB mit psycopg2.extras.Json schreiben (rohe Python-Liste → ProgrammingError/500 bei Co-Zuweisung).", + ], + }, + { + "version": "0.8.129", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningTrainerAssignModal (Trainer zuweisen) aus Trainingsplanungsseite; Handler per useCallback.", + ], + }, + { + "version": "0.8.128", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningModuleApplyModal (Trainingsmodul einfügen) aus Trainingsplanungsseite; gemeinsamer Callback onModuleApplySectionIndexChange.", + ], + }, + { + "version": "0.8.126", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningFrameworkImportModal aus Trainingsplanungsseite; Playwright-Test 13 (Rahmen-Dialog, skip ohne Gruppe).", + ], + }, + { + "version": "0.8.125", + "date": "2026-05-13", + "changes": [ + "Tests: Playwright 11 (Übungsliste Bulk-Toolbar), 12 (Trainingsplanung); Dashboard-Test 8 prüft HTTP 200 auf /api/dashboard/kpis; pytest test_dashboard_kpis_200_when_inner_lists_mocked.", + "Frontend Phase 3: trainingPlanningPageHelpers.js aus TrainingPlanningPage; ExerciseListBulkToolbar data-testid.", + ], + }, + { + "version": "0.8.124", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3 (Teil): ExerciseListBulkToolbar-Komponente; Übungsliste nur Verdrahtung.", + ], + }, { "version": "0.8.123", "date": "2026-05-13", diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md index 0018eca..0b77b98 100644 --- a/docs/architecture/BASELINE_SNAPSHOT.md +++ b/docs/architecture/BASELINE_SNAPSHOT.md @@ -90,6 +90,22 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production). |----------|-------------------|------------------| | 10 VUs, 30 s `/health` | *—* | *nach Messung* | +### 3.4 Aus dem Deployment-/CI-Log übernehmen (k6 `k6-health-baseline`) + +Das Skript `scripts/load/k6-health-baseline.js` nutzt **10 VUs**, **30 s**, Ziel **`GET {BASE_URL}/health`** (siehe Workflow-Env für `BASE_URL`). + +**In die Tabelle oben (Abschnitt 3.3) eintragen — aus der k6-Zusammenfassung am Ende des Jobs:** + +| Feld in BASELINE_SNAPSHOT | Wo im k6-Log (typisch) | +|---------------------------|-------------------------| +| **p95** (Latenz ms) | Zeile **`http_req_duration`** → Wert **`p(95)=…`** (ganze Zahl oder ms mit Einheit wie `12.34ms`) | +| **Fehlerquote** | Zeile **`http_req_failed`** → z. B. `0.00%` bzw. `✓ 0%` — oder kurz „0 %“ notieren | +| **Checks** (optional) | Zeile **`checks`** → Anteil **`✓`** (soll **100 %** sein, sonst Hinweis) | +| **Datum / BASE_URL** | Deploy-Datum + die **öffentliche** Basis-URL des Laufs (wie im Workflow gesetzt, z. B. `https://dev.shinkan.jinkendo.de`) | +| **App-Version** (optional) | dieselbe wie im Deploy (`backend/version.py` / Release), damit M2-Vergleich ressortfähig bleibt | + +**Zusätzlich (Abschnitt 2.2):** nur die Zeile **`/health` GET`** mit dem **gleichen** p95 befüllen, wenn ihr dort noch Platzhalter habt — echte API-Routen (`/api/...`) kommen weiter aus Monitoring/k6 mit Auth, nicht aus diesem Job. + --- ## 4. Nächster Schritt (Roadmap) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ac5fe6c..91bed80 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,6 +9,9 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP | [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md) | Zielarchitektur (Frontend, API, Daten), Qualitätsziele, Einbindung neuer Features | | [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md) | Erfasste Architekturschuld, Reihenfolge und Massnahmen zur Behebung | | [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte | +| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`, `API_URL`, `mergeActiveClubHeader`) | +| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen | +| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) | | [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) | | [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) | diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index ba2f011..558d42c 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -7,7 +7,8 @@ - **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert). - **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**. - **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget. -- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular). +- **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`). **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). @@ -82,7 +83,9 @@ | Virtualisierung für die längste produktive Liste | A1, S2 | | Schwere Imports auf `import()` umziehen (gezielt) | A4 | -**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular. +**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. + +**Abgeschlossen (2026-05-14):** Routen bleiben unter `frontend/src/pages/`; schwere Implementierung in **`components/planning/TrainingPlanningPageRoot.jsx`**, **`components/exercises/ExerciseFormPageRoot.jsx`**, **`components/exercises/ExercisesListPageRoot.jsx`** — **`pages/*` nur Re-Export** (Soft-Limit ~500 Zeilen laut `VERBINDLICHE_REGELN_SHINKAN.md`). **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar. @@ -90,13 +93,17 @@ ## Phase 4 – API-Client Modularisierung +**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; Welle 3: **`exercises.js`**; **`utils/api.js`** bleibt vollständige Facade. + **Fokus:** Wartbarkeit für viele neue Features. -| Task | Bezug | -|------|--------| -| `frontend/src/api/` anlegen, `request`/`client` zentral | A2 | -| Facade: bestehende Importe von `utils/api` nicht sofort alle brechen; Migration in Wellen | A2 | -| Neue Endpoints nur noch in Domänen-Dateien | S3 | +| Task | Bezug | Status | +|------|-------|--------| +| `frontend/src/api/client.js` — zentraler HTTP-Client | A2 | erledigt (Welle 1) | +| `frontend/src/api/planning.js` — Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, Dashboard-KPIs) | A2 | erledigt (Welle 2) | +| `frontend/src/api/exercises.js` — Übungen, Medien/Archiv, Varianten, Progressionsgraphen, KI | A2 | erledigt (Welle 3) | +| Weitere Domänen-Module unter `frontend/src/api/` + Entlastung von `utils/api.js` | A2 | offen | +| Neue Endpoints primär in Domänen-Dateien | S3 | offen | **Abnahme:** Anteil neuer Module > X% der neuen Zeilen (Team-Ziel); Monolith wächst nicht weiter. @@ -121,7 +128,7 @@ |-------------|--------| | **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert | | **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen | -| **M3** | Phase 3 Referenz-Page + Virtualisierung live | +| **M3** | Phase 3 abgeschlossen: Page-Dateien Soft-Limit (Re-Export); Virtualisierung Übungsliste | | **M4** | Phase 4 migrationsbereit für alle neuen Features | | **M5** | Phase 5 für Top-Listen abgeschlossen | diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..e4a0806 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,79 @@ +/** + * HTTP-Client: Token, Mandanten-Header, Fehler-Mapping. + * Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js). + */ + +export const API_URL = import.meta.env.VITE_API_URL || '' + +/** LocalStorage + Request-Header für Mandanten-Kontext */ +export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id' + +export function mergeActiveClubHeader(headers = {}) { + const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) + if (cid && /^\d+$/.test(String(cid).trim())) { + return { ...headers, 'X-Active-Club-Id': String(cid).trim() } + } + return { ...headers } +} + +/** + * Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id. + */ +export async function request(endpoint, options = {}) { + const token = localStorage.getItem('authToken') + const method = (options.method || 'GET').toUpperCase() + + const headers = mergeActiveClubHeader({ + ...options.headers, + }) + if (method !== 'GET' && method !== 'HEAD') { + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json' + } + } + + if (token) { + headers['X-Auth-Token'] = token + } + + const url = `${API_URL}${endpoint}` + + try { + const response = await fetch(url, { + ...options, + headers, + }) + + if (!response.ok) { + const text = await response.text() + let parsed = null + try { + parsed = JSON.parse(text) + } catch { + parsed = null + } + if (parsed?.detail != null) { + const d = parsed.detail + throw new Error(typeof d === 'string' ? d : JSON.stringify(d)) + } + if (response.status === 502) { + throw new Error( + 'HTTP 502 (Bad Gateway): Der Reverse-Proxy hat die API nicht korrekt erreicht. Ist `shinkan-api` aktiv (`docker compose ps`, `docker logs shinkan-api`)? Bei Host-Routing nur einen Weg verwenden — alles auf Port 3003 (Nginx nach `backend:8000`) oder sauber `/api` → Backend-Port.' + ) + } + const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) + throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`) + } + + return response.json() + } catch (e) { + if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) { + const hint = + API_URL && API_URL.length > 0 + ? `Verbindung zum API unter ${API_URL} fehlgeschlagen. Läuft das Backend (z. B. Port 8098) und ist CORS erlaubt?` + : 'Kein VITE_API_URL gesetzt: Anfragen gehen an die Frontend-URL und schlagen oft fehl. Setze in .env z. B. VITE_API_URL=http://localhost:8098 und starte Vite neu.' + throw new Error(`${hint} [Technisch: ${e.message}; URL war ${endpoint}]`) + } + throw e + } +} diff --git a/frontend/src/api/exercises.js b/frontend/src/api/exercises.js new file mode 100644 index 0000000..3c3f387 --- /dev/null +++ b/frontend/src/api/exercises.js @@ -0,0 +1,593 @@ +/** + * Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen. + */ + +import { stripHtmlToText } from '../utils/htmlUtils' +import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi' +import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js' + +/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */ +function withActiveClubHeaders(headers = {}) { + const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) + if (cid && /^\d+$/.test(String(cid).trim())) { + return { ...headers, 'X-Active-Club-Id': String(cid).trim() } + } + return { ...headers } +} + +// ============================================================================ +// Exercises +// ============================================================================ + +export async function listExercises(filters = {}) { + const q = new URLSearchParams() + Object.entries(filters).forEach(([k, v]) => { + if (v === undefined || v === null) return + if (typeof v === 'boolean') { + q.set(k, v ? 'true' : 'false') + return + } + if (Array.isArray(v)) { + if (v.length === 0) return + v.forEach((item) => { + if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') { + q.append(k, String(item)) + } + }) + return + } + if (String(v).trim() !== '') q.set(k, String(v)) + }) + const query = q.toString() + return request(`/api/exercises${query ? '?' + query : ''}`) +} + +/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */ +export function buildExerciseApiPayload(formData, extras = {}) { + const num = (v) => (v === '' || v == null ? null : Number(v)) + + const goalHtml = formData.goal || '' + const execHtml = formData.execution || '' + const goalText = stripHtmlToText(goalHtml) + const execText = stripHtmlToText(execHtml) + + if (!goalText && !execText) { + throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).') + } + + const mapFocus = (formData.focus_areas_multi || []) + .filter((x) => x && x.focus_area_id) + .map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary })) + const mapStyles = (formData.training_styles_multi || []) + .filter((x) => x && x.training_style_id) + .map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary })) + const mapTTypes = (formData.training_types_multi || []) + .filter((x) => x && x.training_type_id) + .map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary })) + const mapTg = (formData.target_groups_multi || []) + .filter((x) => x && x.target_group_id) + .map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary })) + + const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase() + + const payload = { + title: (formData.title || '').trim(), + summary: formData.summary || null, + goal: goalHtml.trim() ? goalHtml : null, + execution: execHtml.trim() ? execHtml : null, + preparation: formData.preparation || null, + trainer_notes: formData.trainer_notes || null, + duration_min: num(formData.duration_min), + duration_max: num(formData.duration_max), + group_size_min: num(formData.group_size_min), + group_size_max: num(formData.group_size_max), + equipment: Array.isArray(formData.equipment) ? formData.equipment : [], + focus_areas_multi: mapFocus, + training_styles_multi: mapStyles, + training_types_multi: mapTTypes, + target_groups_multi: mapTg, + age_groups: [], + skills: (formData.skills || []).map((s) => ({ + skill_id: s.skill_id, + is_primary: !!s.is_primary, + intensity: s.intensity || null, + required_level: s.required_level || null, + target_level: s.target_level || null, + })), + visibility: visibilityNorm, + status: formData.status || 'draft', + club_id: visibilityNorm === 'club' ? num(formData.club_id) : null, + exercise_kind: + String(formData.exercise_kind || 'simple').toLowerCase() === 'combination' + ? 'combination' + : 'simple', + ...extras, + } + + const isCombo = payload.exercise_kind === 'combination' + + if (isCombo) { + let mpObj = {} + const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : '' + if (mpRaw) { + try { + const parsed = JSON.parse(mpRaw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Ablaufprofil muss ein JSON-Objekt sein.') + } + mpObj = parsed + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error('Ablaufprofil (JSON): Syntax ungültig.') + } + throw e + } + } + + const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : [] + const combination_slots = [] + + function parseTimingField(raw) { + if (raw === '' || raw == null || raw === undefined) return undefined + const n = parseInt(String(raw), 10) + return Number.isFinite(n) ? n : undefined + } + + for (let i = 0; i < slotRows.length; i += 1) { + const row = slotRows[i] || {} + let ids = Array.isArray(row.candidate_exercise_ids) + ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n)) + : [] + + /** Legacy: noch idsText Unterstützung für Import von älteren FormStand */ + if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) { + ids = row.idsText + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n)) + } + + combination_slots.push({ + slot_index: i, + title: (typeof row.title === 'string' && row.title.trim()) || null, + candidate_exercise_ids: ids, + }) + } + + const slot_profiles_v1_next = [] + for (let i = 0; i < slotRows.length; i += 1) { + const row = slotRows[i] || {} + const o = { slot_index: i } + const advanceMode = normalizeAdvanceMode(row.advance_mode) + if (advanceMode !== 'timed') o.advance_mode = advanceMode + const load = parseTimingField(row.load_sec) + const crs = parseTimingField(row.consecutive_reps) + const rsc = parseTimingField(row.rep_series_count) + const intra = parseTimingField(row.intra_rep_rest_sec) + const tran = parseTimingField(row.transition_after_sec) + const serienUi = parseComboRepSeriesCountUi(row.rep_series_count) + const allowInterSeriesPause = + advanceMode === 'timed' || + ((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2) + + if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load) + if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs) + if ( + rsc !== undefined && + rsc >= 1 && + (advanceMode === 'rep' || advanceMode === 'manual') + ) { + o.rep_series_count = Math.round(rsc) + } + if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra) + if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran) + if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o) + } + + payload.method_archetype = (formData.method_archetype || '').trim() || null + if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next + else delete mpObj.slot_profiles_v1 + payload.method_profile = mpObj + payload.combination_slots = combination_slots + } else { + payload.method_archetype = null + payload.method_profile = {} + } + + return payload +} + +export async function uploadExerciseMedia(exerciseId, formData) { + const token = localStorage.getItem('authToken') + const headers = withActiveClubHeaders({}) + if (token) headers['X-Auth-Token'] = token + const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, { + method: 'POST', + headers, + body: formData, + }) + if (!response.ok) { + const text = await response.text() + let parsed = null + try { + parsed = text ? JSON.parse(text) : null + } catch { + parsed = null + } + const d = parsed?.detail + if ( + response.status === 409 && + d && + typeof d === 'object' && + !Array.isArray(d) && + typeof d.code === 'string' + ) { + const e = new Error( + typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden', + ) + e.code = d.code + e.status = 409 + e.payload = d + throw e + } + if (response.status === 413) { + const nginx = (text || '').toLowerCase().includes('nginx') + throw new Error( + nginx + ? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).' + : 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.', + ) + } + const msg = + typeof d === 'string' + ? d + : d != null && typeof d === 'object' && typeof d.message === 'string' + ? d.message + : d != null + ? JSON.stringify(d) + : text && text.length < 400 && !/^\s* o.key === k)) { + const pl = sec.planLoc + const label = + pl.phaseKind === 'parallel' + ? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}` + : `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}` + return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) => + a.key.localeCompare(b.key, undefined, { numeric: true }) + ) + } + return baseOpts +} + const DND_TU_ITEM = 'application/x-shinkan-training-unit-item' const DND_TU_SECTION = 'application/x-shinkan-training-section-v1' @@ -35,6 +82,31 @@ function dtHasType(e, mime) { return Array.from(t).includes(mime) } +/** Visuelle Zuordnung der Einfügezeile zu Split- vs. Ganzgruppen-Bereich (nur Darstellung). */ +function sectionDropBandRegionClass(sections, beforeIdx, enableParallel) { + if (!enableParallel) return '' + const n = sections?.length ?? 0 + const below = beforeIdx < n ? sections[beforeIdx] : null + const above = beforeIdx > 0 ? sections[beforeIdx - 1] : null + const po = (s) => s?.planLoc?.phaseOrderIndex ?? 0 + const aboveP = above?.planLoc?.phaseKind === 'parallel' + const belowP = below?.planLoc?.phaseKind === 'parallel' + const aboveW = above?.planLoc?.phaseKind === 'whole_group' + const belowW = below?.planLoc?.phaseKind === 'whole_group' + + if (aboveP && belowP && po(above) === po(below)) { + return ' tu-section-dropband--region-split' + } + if (aboveW && belowW) return ' tu-section-dropband--region-whole' + if (aboveP && belowW) return ' tu-section-dropband--region-split-to-whole' + if (aboveW && belowP) return ' tu-section-dropband--region-whole-to-split' + if (!above && belowP) return ' tu-section-dropband--region-split' + if (!below && aboveP) return ' tu-section-dropband--region-split-to-whole' + if (!above && belowW) return ' tu-section-dropband--region-whole' + if (!below && aboveW) return ' tu-section-dropband--region-whole' + return ' tu-section-dropband--region-neutral' +} + function truncatePreview(text, max = 160) { const t = (text || '').replace(/\s+/g, ' ').trim() if (t.length <= max) return t @@ -173,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 }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben + * @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number, toParallelStream?: { po: number, so: number } }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben */ export default function TrainingUnitSectionsEditor({ sections, @@ -192,6 +264,8 @@ export default function TrainingUnitSectionsEditor({ onMoveSectionsAcrossSlots = null, /** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */ betweenInsertMenus = true, + /** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */ + enableParallelPhaseControls = false, }) { const { user } = useAuth() const planningCompactLegend = isCompactTagLegendMode( @@ -211,6 +285,12 @@ export default function TrainingUnitSectionsEditor({ const sectionToSlot = slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1 + const list = ensure(sections) + const useStreamTagDropUx = + enableSectionDragReorder && + enableParallelPhaseControls && + list.some((s) => s?.planLoc?.phaseKind === 'parallel') + const updateSectionField = (sIdx, field, val) => { patch((prev) => prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s)) @@ -218,23 +298,221 @@ export default function TrainingUnitSectionsEditor({ } const addSection = () => { - patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)]) + patch((prev) => { + const base = defaultSection(`Abschnitt ${prev.length + 1}`) + const last = prev[prev.length - 1] + const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base + return [...prev, next] + }) + } + + const addParallelPhaseTwoStreams = () => { + patch((prev) => { + const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 + const pl0 = defaultPlanLocParallel(nextPo, 0) + const pl1 = defaultPlanLocParallel(nextPo, 1) + const base0 = defaultSection(`Abschnitt ${prev.length + 1}`) + const base1 = defaultSection(`Abschnitt ${prev.length + 2}`) + return [...prev, { ...base0, planLoc: pl0 }, { ...base1, planLoc: pl1 }] + }) + } + + const addStreamToParallelPhase = (phaseOrder) => { + patch((prev) => { + const po = Number(phaseOrder) || 0 + const par = (prev || []).filter( + (s) => s?.planLoc?.phaseKind === 'parallel' && (s.planLoc.phaseOrderIndex ?? 0) === po + ) + if (!par.length) return prev + const distinct = new Set(par.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) + if (distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev + const maxS = Math.max(...par.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) + const newSo = maxS + 1 + const tmpl = { + ...par[0].planLoc, + parallelStreamOrderIndex: newSo, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } + const base = defaultSection(`Abschnitt ${prev.length + 1}`) + const inPhaseIdx = indicesOfParallelPhase(prev, po) + const insertAfter = inPhaseIdx.length ? Math.max(...inPhaseIdx) : prev.length - 1 + return [ + ...prev.slice(0, insertAfter + 1), + { ...base, planLoc: tmpl }, + ...prev.slice(insertAfter + 1), + ] + }) + } + + const addWholeGroupSection = () => { + patch((prev) => { + const L = ensure(prev) + const wgs = L.filter((s) => s?.planLoc?.phaseKind === 'whole_group') + let pl + if (wgs.length) { + const maxPo = Math.max(...wgs.map((s) => s.planLoc.phaseOrderIndex ?? 0)) + const sample = wgs.find((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxPo) + pl = { ...sample.planLoc } + } else { + const nextPo = maxPhaseOrderIndexFromSections(L) + 1 + pl = defaultPlanLocWholeGroup(nextPo) + } + const base = defaultSection(`Abschnitt ${L.length + 1}`) + return [...L, { ...base, planLoc: pl }] + }) + } + + const addSectionToParallelStream = (phaseOrder, streamOrder) => { + patch((prev) => { + const L = ensure(prev) + const po = Number(phaseOrder) || 0 + const so = Number(streamOrder) || 0 + const idxs = sectionIndicesForParallelStream(L, po, so) + const tmpl = idxs.length ? L[idxs[0]].planLoc : defaultPlanLocParallel(po, so) + const pl = { + ...tmpl, + phaseKind: 'parallel', + phaseOrderIndex: po, + parallelStreamOrderIndex: so, + } + const base = defaultSection(`Abschnitt ${L.length + 1}`) + if (!idxs.length) { + return [...L, { ...base, planLoc: pl }] + } + const insertAfter = Math.max(...idxs) + return [...L.slice(0, insertAfter + 1), { ...base, planLoc: pl }, ...L.slice(insertAfter + 1)] + }) + } + + const updateParallelPhaseTitleAll = (phaseOrder, title) => { + const po = Number(phaseOrder) || 0 + const v = title.trim() ? title.trim() : null + patch((prev) => + prev.map((s) => { + const L = s?.planLoc + if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s + return { ...s, planLoc: { ...L, phaseTitle: v } } + }) + ) + } + + const updateParallelStreamTitleAll = (phaseOrder, streamOrder, title) => { + const po = Number(phaseOrder) || 0 + const so = Number(streamOrder) || 0 + const v = title.trim() ? title.trim() : null + patch((prev) => + prev.map((s) => { + const L = s?.planLoc + if ( + L?.phaseKind !== 'parallel' || + (L.phaseOrderIndex ?? 0) !== po || + (L.parallelStreamOrderIndex ?? 0) !== so + ) { + return s + } + return { ...s, planLoc: { ...L, streamTitle: v } } + }) + ) + } + + const removeParallelStream = (phaseOrder, streamOrder) => { + const po = Number(phaseOrder) || 0 + const so = Number(streamOrder) || 0 + const idxs = sectionIndicesForParallelStream(list, po, so) + if (!idxs.length) return + if ( + parallelStreamBucketHasContent(list, idxs, SECTION_INSERT_SEPARATOR_BODY) && + !window.confirm( + 'In diesem Stream sind Übungen oder Anmerkungen geplant. Stream wirklich löschen?' + ) + ) { + return + } + patch((prev) => { + const L = ensure(prev) + const beforeOrders = streamsForParallelPhaseOrders(L, po) + const rm = sectionIndicesForParallelStream(L, po, so) + if (!rm.length) return prev + let next = reorderWithoutIndices(L, rm) + const afterOrders = streamsForParallelPhaseOrders(next, po) + if (beforeOrders.length >= 2 && afterOrders.length <= 1) { + if ( + window.confirm( + 'Nur noch eine Gruppe in dieser Phase übrig. Parallelen Aufbau auflösen und alle Abschnitte als gemeinsame Ganzgruppen-Phase weiterführen?' + ) + ) { + next = dissolveParallelPhaseToWholeGroup(next, po) + } + } + return next + }) + } + + const applySectionPlanTarget = (sIdx, rawKey) => { + patch((prev) => { + if (!rawKey) { + return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s)) + } + const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev)) + const hit = opts.find((o) => o.key === rawKey) + if (!hit) return prev + const tpl = { ...hit.template } + return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s)) + }) } const removeSection = (sIdx) => { patch((prev) => { - const next = prev.filter((_, i) => i !== sIdx) - return next.length ? next : [defaultSection()] + let next = prev.filter((_, i) => i !== sIdx) + next = next.length ? next : [defaultSection()] + if (enableParallelPhaseControls) { + next = afterSectionReorderParallelGuard(prev, next) + } + return next }) } + /** Ganzgruppe: global tauschen; parallele Phase: innerhalb Stream oder ganze Parallel-Phase am Stück */ const moveSection = (sIdx, dir) => { patch((prev) => { - const p = [...prev] + const p = ensure(prev) + const sec = p[sIdx] + const L = sec?.planLoc + if (L?.phaseKind === 'parallel') { + const po = L.phaseOrderIndex ?? 0 + const so = L.parallelStreamOrderIndex ?? 0 + const bucket = sectionIndicesForParallelStream(p, po, so) + const pos = bucket.indexOf(sIdx) + if (pos < 0) return p + if (dir < 0 && pos > 0) { + const newPos = pos + dir + return reorderWithinBucketIndices(p, bucket, pos, newPos) + } + if (dir > 0 && pos < bucket.length - 1) { + const newPos = pos + dir + return reorderWithinBucketIndices(p, bucket, pos, newPos) + } + if (dir < 0 && pos === 0) { + const runs = phaseRunsFromSections(p) + const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po) + if (rIdx <= 0) return p + return swapAdjacentPhaseRuns(p, rIdx - 1) + } + if (dir > 0 && pos === bucket.length - 1) { + const runs = phaseRunsFromSections(p) + const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po) + if (rIdx < 0 || rIdx >= runs.length - 1) return p + return swapAdjacentPhaseRuns(p, rIdx) + } + return p + } + const arr = [...p] const ta = sIdx + dir - if (ta < 0 || ta >= p.length) return p - ;[p[sIdx], p[ta]] = [p[ta], p[sIdx]] - return p + if (ta < 0 || ta >= arr.length) return arr + ;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]] + return arr }) } @@ -314,6 +592,15 @@ export default function TrainingUnitSectionsEditor({ const [dropTargetPos, setDropTargetPos] = useState(null) const [dropSectionBand, setDropSectionBand] = useState(null) + /** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */ + const [parallelStreamTabByPhase, setParallelStreamTabByPhase] = useState({}) + /** `${phaseOrder}:${streamOrder}` während Stream-Name bearbeitet wird */ + const [streamNameEditKey, setStreamNameEditKey] = useState(null) + const [streamNameDraft, setStreamNameDraft] = useState('') + const [phaseTitleEditPo, setPhaseTitleEditPo] = useState(null) + const [phaseTitleDraft, setPhaseTitleDraft] = useState('') + const skipStreamNameBlurSave = useRef(false) + const skipPhaseTitleBlurSave = useRef(false) /** { slot: number, beforeIdx: number } */ useEffect(() => { @@ -394,6 +681,25 @@ export default function TrainingUnitSectionsEditor({ const clearSectionDnD = () => setDropSectionBand(null) + const onParallelPhaseDragStart = (e, phaseOrderIndex) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + e.stopPropagation() + try { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData( + DND_TU_SECTION, + JSON.stringify({ + fromSlot: sectionToSlot, + fromSectionIdx: null, + phaseRunMove: { phaseOrderIndex }, + }) + ) + } catch { + /* ignore */ + } + setDropSectionBand(null) + } + const onSectionDragStart = (e, sIdx) => { if (!enableSectionDragReorder) return e.stopPropagation() @@ -425,6 +731,149 @@ export default function TrainingUnitSectionsEditor({ setDropSectionBand({ slot: sectionToSlot, beforeIdx }) } + const onPhaseAboveSplitDragOver = (e, po) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + if (!dtHasType(e, DND_TU_SECTION)) return + e.preventDefault() + e.stopPropagation() + try { + e.dataTransfer.dropEffect = 'move' + } catch { + /* ignore */ + } + setDropSectionBand({ slot: sectionToSlot, phaseAboveSplitPo: Number(po) || 0 }) + } + + const onPhaseBelowSplitDragOver = (e, po, so) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + if (!dtHasType(e, DND_TU_SECTION)) return + e.preventDefault() + e.stopPropagation() + try { + e.dataTransfer.dropEffect = 'move' + } catch { + /* ignore */ + } + setDropSectionBand({ + slot: sectionToSlot, + phaseBelowSplit: { po: Number(po) || 0, so: Number(so) || 0 }, + }) + } + + const applyParsedSectionDrop = (data) => { + const phaseRunMove = data.phaseRunMove + const fromSi = data.fromSectionIdx + const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 + + if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) { + return { kind: 'phaseRun', phaseRunMove, fromSlot } + } + if (typeof fromSi !== 'number') return null + + if ( + typeof onMoveSectionsAcrossSlots === 'function' && + sectionToSlot >= 0 && + fromSlot >= 0 + ) { + return { kind: 'crossSlot', fromSi, fromSlot } + } + + return { kind: 'local', fromSi } + } + + const onPhaseAboveSplitDrop = (e, po) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + e.preventDefault() + e.stopPropagation() + clearSectionDnD() + let raw = '' + try { + raw = e.dataTransfer.getData(DND_TU_SECTION) + } catch { + return + } + if (!raw) return + let data + try { + data = JSON.parse(raw) + } catch { + return + } + const targetPo = Number(po) || 0 + const parsed = applyParsedSectionDrop(data) + if (!parsed) return + + if (parsed.kind === 'phaseRun') { + const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 + if (dragPo === targetPo) return + patch((prev) => { + const idxs = indicesOfParallelPhase(prev, targetPo) + const fg = idxs.length ? idxs[0] : -1 + if (fg < 0) return prev + let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + return + } + + if (parsed.kind === 'crossSlot') return + + const { fromSi } = parsed + patch((prev) => { + let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, fromSi, targetPo) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + + const onPhaseBelowSplitDrop = (e, po, so) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + e.preventDefault() + e.stopPropagation() + clearSectionDnD() + let raw = '' + try { + raw = e.dataTransfer.getData(DND_TU_SECTION) + } catch { + return + } + if (!raw) return + let data + try { + data = JSON.parse(raw) + } catch { + return + } + const targetPo = Number(po) || 0 + const targetSo = Number(so) || 0 + const parsed = applyParsedSectionDrop(data) + if (!parsed) return + + if (parsed.kind === 'phaseRun') { + const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 + if (dragPo === targetPo) return + patch((prev) => { + const idxs = indicesOfParallelPhase(prev, targetPo) + const fg = idxs.length ? idxs[0] : -1 + if (fg < 0) return prev + let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + return + } + + if (parsed.kind === 'crossSlot') return + + const { fromSi } = parsed + patch((prev) => { + let next = reorderSectionAsFirstInParallelStream(prev, fromSi, targetPo, targetSo) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + const onSectionBandDrop = (e, insertBeforeIdx) => { if (!enableSectionDragReorder) return e.preventDefault() @@ -444,9 +893,47 @@ export default function TrainingUnitSectionsEditor({ return } const fromSi = data.fromSectionIdx + const phaseRunMove = data.phaseRunMove const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 + + if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) { + patch((prev) => { + const po = Number(phaseRunMove.phaseOrderIndex) || 0 + let next = moveParallelPhaseRunToInsertBefore(prev, po, insertBeforeIdx) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + return + } + if (typeof fromSi !== 'number') return + if (enableParallelPhaseControls) { + const fromPl = list[fromSi]?.planLoc + if (fromPl?.phaseKind === 'parallel' && insertBeforeIdx >= 0 && insertBeforeIdx < list.length) { + const po = fromPl.phaseOrderIndex ?? 0 + const fromSo = fromPl.parallelStreamOrderIndex ?? 0 + const tabSo = + parallelStreamTabByPhase[po] ?? streamsForParallelPhaseOrders(list, po)[0] ?? 0 + const targetPl = list[insertBeforeIdx]?.planLoc + if ( + targetPl?.phaseKind === 'parallel' && + (targetPl.phaseOrderIndex ?? 0) === po && + (targetPl.parallelStreamOrderIndex ?? 0) !== fromSo && + tabSo === fromSo + ) { + return + } + } + } + + if ( + enableParallelPhaseControls && + (insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1) + ) { + return + } + if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && @@ -461,7 +948,86 @@ export default function TrainingUnitSectionsEditor({ return } - patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)) + patch((prev) => { + let next = enableParallelPhaseControls + ? reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx) + : reorderBlocksImmutable(prev, fromSi, insertBeforeIdx) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + + const onStreamDropTargetDragOver = (e, phaseOrder, streamOrder) => { + if (!enableSectionDragReorder || !useStreamTagDropUx) return + if (!dtHasType(e, DND_TU_SECTION)) return + e.preventDefault() + e.stopPropagation() + try { + e.dataTransfer.dropEffect = 'move' + } catch { + /* ignore */ + } + setDropSectionBand({ + slot: sectionToSlot, + streamDrop: { po: Number(phaseOrder) || 0, so: Number(streamOrder) || 0 }, + }) + } + + const onStreamDropTargetDragLeave = (e) => { + if (e.currentTarget.contains(e.relatedTarget)) return + clearSectionDnD() + } + + const onStreamDropTargetDrop = (e, phaseOrder, streamOrder) => { + if (!enableSectionDragReorder || !useStreamTagDropUx) return + e.preventDefault() + e.stopPropagation() + clearSectionDnD() + let raw = '' + try { + raw = e.dataTransfer.getData(DND_TU_SECTION) + } catch { + return + } + if (!raw) return + let data + try { + data = JSON.parse(raw) + } catch { + return + } + if (data.phaseRunMove != null && data.phaseRunMove.phaseOrderIndex != null) { + return + } + const fromSi = data.fromSectionIdx + + if (typeof fromSi !== 'number') return + + const po = Number(phaseOrder) || 0 + const so = Number(streamOrder) || 0 + const toIdx = globalInsertBeforeIndexForParallelStreamEnd(list, po, so) + + const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 + if ( + typeof onMoveSectionsAcrossSlots === 'function' && + sectionToSlot >= 0 && + fromSlot >= 0 + ) { + onMoveSectionsAcrossSlots({ + fromSlot, + fromSectionIdx: fromSi, + toSlot: sectionToSlot, + toSectionIdx: toIdx, + toParallelStream: { po, so }, + }) + return + } + + patch((prev) => { + let next = reorderBlockIntoParallelStreamEnd(prev, fromSi, po, so) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) } const onItemDragStart = (e, sIdx, iIdx) => { @@ -602,7 +1168,87 @@ export default function TrainingUnitSectionsEditor({ ) } - const list = ensure(sections) + const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list]) + + const firstSectionIndexByParallelPhase = useMemo(() => { + const m = new Map() + list.forEach((s, i) => { + const L = s?.planLoc + if (L?.phaseKind !== 'parallel') return + const po = L.phaseOrderIndex ?? 0 + if (!m.has(po)) m.set(po, i) + }) + return m + }, [list]) + + const parallelPhaseOrdersPresent = useMemo(() => { + const set = new Set() + for (const s of list) { + if (s?.planLoc?.phaseKind === 'parallel') set.add(s.planLoc.phaseOrderIndex ?? 0) + } + return [...set].sort((a, b) => a - b) + }, [list]) + + useEffect(() => { + if (!enableParallelPhaseControls || !parallelPhaseOrdersPresent.length) return + setParallelStreamTabByPhase((prev) => { + const next = { ...prev } + let changed = false + for (const po of parallelPhaseOrdersPresent) { + const orders = streamsForParallelPhaseOrders(list, po) + if (!orders.length) continue + if (next[po] === undefined) { + next[po] = orders[0] + changed = true + } else if (!orders.includes(next[po])) { + next[po] = orders[0] + changed = true + } + } + for (const k of Object.keys(next)) { + const poi = Number(k) + if (!Number.isFinite(poi) || !parallelPhaseOrdersPresent.includes(poi)) { + delete next[k] + changed = true + } + } + return changed ? next : prev + }) + }, [list, parallelPhaseOrdersPresent, enableParallelPhaseControls]) + + const sectionMoveDisabledUp = (sIdx) => { + const sec = list[sIdx] + const L = sec?.planLoc + if (L?.phaseKind === 'parallel') { + const po = L.phaseOrderIndex ?? 0 + const so = L.parallelStreamOrderIndex ?? 0 + const bucket = sectionIndicesForParallelStream(list, po, so) + const pos = bucket.indexOf(sIdx) + if (pos > 0) return false + const rIdx = planningPhaseRuns.findIndex( + (r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po + ) + return rIdx <= 0 + } + return sIdx === 0 + } + + const sectionMoveDisabledDown = (sIdx) => { + const sec = list[sIdx] + const L = sec?.planLoc + if (L?.phaseKind === 'parallel') { + const po = L.phaseOrderIndex ?? 0 + const so = L.parallelStreamOrderIndex ?? 0 + const bucket = sectionIndicesForParallelStream(list, po, so) + const pos = bucket.indexOf(sIdx) + if (pos >= 0 && pos < bucket.length - 1) return false + const rIdx = planningPhaseRuns.findIndex( + (r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po + ) + return rIdx < 0 || rIdx >= planningPhaseRuns.length - 1 + } + return sIdx === list.length - 1 + } const comboPlanningModalDerived = useMemo(() => { if (!comboPlanningModal) { @@ -690,17 +1336,90 @@ export default function TrainingUnitSectionsEditor({ const planMin = sectionPlannedMinutes(sec) const itemCount = sec.items?.length ?? 0 const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : [] + const pl = sec?.planLoc + const parallelPhaseOrder = + enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null + const streamOrdersForParallelPhase = + parallelPhaseOrder != null ? streamsForParallelPhaseOrders(list, parallelPhaseOrder) : [] + const activeParallelStream = + parallelPhaseOrder != null + ? parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0 + : null + const hideParallelSection = + enableParallelPhaseControls && + pl?.phaseKind === 'parallel' && + (pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream + const firstGlobalIdxThisPhase = + parallelPhaseOrder != null + ? firstSectionIndexByParallelPhase.get(parallelPhaseOrder) + : null + const firstVisibleIdxActiveStream = + parallelPhaseOrder != null && streamOrdersForParallelPhase.length + ? sectionIndicesForParallelStream( + list, + parallelPhaseOrder, + activeParallelStream + )[0] + : null + const hideDropBandBeforeOrphanFirstVisible = + parallelPhaseOrder != null && + firstVisibleIdxActiveStream != null && + sIdx === firstVisibleIdxActiveStream && + firstGlobalIdxThisPhase != null && + sIdx !== firstGlobalIdxThisPhase + + const isFirstSectionOfParallelPhase = + parallelPhaseOrder != null && + firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx + const hideDropBeforeFirstParallelBecauseDedicatedSlot = + enableParallelPhaseControls && + isFirstSectionOfParallelPhase && + pl?.phaseKind === 'parallel' + const showSectionDropBandBefore = + (pl?.phaseKind !== 'parallel' || !hideParallelSection) && + !hideDropBandBeforeOrphanFirstVisible && + !hideDropBeforeFirstParallelBecauseDedicatedSlot + const bandActiveBefore = (bx) => enableSectionDragReorder && dropSectionBand && dropSectionBand.slot === sectionToSlot && - dropSectionBand.beforeIdx === bx + dropSectionBand.beforeIdx === bx && + !dropSectionBand.streamDrop && + dropSectionBand.phaseAboveSplitPo == null && + !dropSectionBand.phaseBelowSplit + + const streamChipDropActive = (po, so) => + useStreamTagDropUx && + dropSectionBand?.slot === sectionToSlot && + dropSectionBand?.streamDrop?.po === po && + dropSectionBand?.streamDrop?.so === so + + const phaseAboveSplitDnd = + parallelPhaseOrder != null && + dropSectionBand?.slot === sectionToSlot && + dropSectionBand?.phaseAboveSplitPo === parallelPhaseOrder + const phaseBelowSplitDnd = + parallelPhaseOrder != null && + dropSectionBand?.slot === sectionToSlot && + dropSectionBand?.phaseBelowSplit?.po === parallelPhaseOrder && + dropSectionBand?.phaseBelowSplit?.so === activeParallelStream + + const streamVisual = + enableParallelPhaseControls && pl?.phaseKind === 'parallel' + ? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0) + : null + const allowSectionDragGrip = enableSectionDragReorder return ( - {enableSectionDragReorder ? ( + {enableSectionDragReorder && showSectionDropBandBefore ? (
{ if (!enableSectionDragReorder) return @@ -714,14 +1433,407 @@ export default function TrainingUnitSectionsEditor({ onDrop={(e) => onSectionBandDrop(e, sIdx)} /> ) : null} + {isFirstSectionOfParallelPhase && + enableParallelPhaseControls && + streamOrdersForParallelPhase.length ? ( + + {enableSectionDragReorder ? ( +
onPhaseAboveSplitDragOver(e, parallelPhaseOrder)} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget)) return + clearSectionDnD() + }} + onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)} + /> + ) : null} +
+
+ {enableSectionDragReorder ? ( + onParallelPhaseDragStart(e, parallelPhaseOrder)} + role="button" + tabIndex={0} + aria-label="Parallele Phase ziehen" + title="Gesamte parallele Phase an neue Planposition ziehen" + > + + + ) : null} + + {(() => { + const hi = firstSectionIndexByParallelPhase.get(parallelPhaseOrder) + const phaseTitleStr = + hi != null && list[hi]?.planLoc?.phaseTitle != null + ? String(list[hi].planLoc.phaseTitle) + : '' + const editingPhase = phaseTitleEditPo === parallelPhaseOrder + return editingPhase ? ( + setPhaseTitleDraft(e.target.value)} + onBlur={() => { + if (!skipPhaseTitleBlurSave.current) { + updateParallelPhaseTitleAll(parallelPhaseOrder, phaseTitleDraft) + } + skipPhaseTitleBlurSave.current = false + setPhaseTitleEditPo(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Escape') { + skipPhaseTitleBlurSave.current = true + setPhaseTitleEditPo(null) + e.currentTarget.blur() + } + }} + placeholder="Bezeichnung der Phase (z. B. Drill-Runde)" + /> + ) : ( + <> + + {(phaseTitleStr || '').trim() || + `Phase ${parallelPhaseOrder} · Namen per Stift bearbeiten`} + + + + ) + })()} + {(() => { + const prRunIdx = planningPhaseRuns.findIndex( + (r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === parallelPhaseOrder + ) + const chipPhaseUpDis = prRunIdx <= 0 + const chipPhaseDownDis = + prRunIdx < 0 || prRunIdx >= planningPhaseRuns.length - 1 + return ( +
+ + +
+ ) + })()} + + +
+
+ {streamOrdersForParallelPhase.map((so) => { + const sel = + (parallelStreamTabByPhase[parallelPhaseOrder] ?? + streamOrdersForParallelPhase[0] ?? + 0) === so + const pv = parallelStreamVisual(so) + const si = sectionIndicesForParallelStream(list, parallelPhaseOrder, so) + const titleSource = si.length ? list[si[0]]?.planLoc?.streamTitle : null + const streamName = titleSource != null ? String(titleSource) : '' + const editKey = `${parallelPhaseOrder}:${so}` + const editingStream = streamNameEditKey === editKey + return ( +
onStreamDropTargetDragOver(e, parallelPhaseOrder, so) + : undefined + } + onDragLeave={ + useStreamTagDropUx ? onStreamDropTargetDragLeave : undefined + } + onDrop={ + useStreamTagDropUx + ? (e) => onStreamDropTargetDrop(e, parallelPhaseOrder, so) + : undefined + } + > + {editingStream ? ( + setStreamNameDraft(e.target.value)} + onBlur={() => { + if (!skipStreamNameBlurSave.current) { + updateParallelStreamTitleAll(parallelPhaseOrder, so, streamNameDraft) + } + skipStreamNameBlurSave.current = false + setStreamNameEditKey(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Escape') { + skipStreamNameBlurSave.current = true + setStreamNameEditKey(null) + e.currentTarget.blur() + } + }} + onClick={(e) => e.stopPropagation()} + placeholder={`Gruppe ${so + 1}`} + aria-label={`Name Gruppe ${so + 1}`} + /> + ) : ( + + )} + + +
+ ) + })} +
+
+ {enableSectionDragReorder ? ( +
+ onPhaseBelowSplitDragOver( + e, + parallelPhaseOrder, + activeParallelStream ?? 0 + ) + } + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget)) return + clearSectionDnD() + }} + onDrop={(e) => + onPhaseBelowSplitDrop( + e, + parallelPhaseOrder, + activeParallelStream ?? 0 + ) + } + /> + ) : null} + + ) : null} + {!hideParallelSection ? ( + <>
- {enableSectionDragReorder ? ( + {allowSectionDragGrip ? ( moveSection(sIdx, -1)} - disabled={sIdx === 0} - style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }} + disabled={sectionMoveDisabledUp(sIdx)} + style={{ + padding: '4px 10px', + opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1, + }} > ▲ @@ -767,10 +1882,10 @@ export default function TrainingUnitSectionsEditor({ type="button" aria-label="Abschnitt runter" onClick={() => moveSection(sIdx, 1)} - disabled={sIdx === list.length - 1} + disabled={sectionMoveDisabledDown(sIdx)} style={{ padding: '4px 10px', - opacity: sIdx === list.length - 1 ? 0.35 : 1, + opacity: sectionMoveDisabledDown(sIdx) ? 0.35 : 1, }} > ▼ @@ -784,6 +1899,36 @@ export default function TrainingUnitSectionsEditor({ Abschnitt entfernen
+ {enableParallelPhaseControls && sec.planLoc ? ( +

+ {sec.planLoc.phaseKind === 'whole_group' + ? (() => { + const pt = sec.planLoc.phaseTitle + const po = sec.planLoc.phaseOrderIndex ?? 0 + return pt != null && String(pt).trim() + ? `Ganzgruppe: ${String(pt).trim()} (Phase ${po})` + : `Ganzgruppen-Phase ${po}` + })() + : (() => { + const pt = sec.planLoc.phaseTitle + const st = sec.planLoc.streamTitle + const po = sec.planLoc.phaseOrderIndex ?? 0 + const so = sec.planLoc.parallelStreamOrderIndex ?? 0 + const phaseLbl = + pt != null && String(pt).trim() ? String(pt).trim() : `Phase ${po}` + const streamLbl = + st != null && String(st).trim() ? String(st).trim() : `Gruppe ${so + 1}` + return `Parallel · ${phaseLbl} · ${streamLbl}` + })()} +

+ ) : null}