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..15fb078 --- /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. Schema + Migration + hydrate/replace +2. PUT + Module + Clone (`from-framework-slot`) +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/backend/routers/training_planning.py b/backend/routers/training_planning.py index dfa8fa8..99794ae 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -522,21 +522,15 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]: return i -def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: - cur.execute( - """ +# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ────────────────── +# Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich. +_SECTION_ROWS_SQL = """ 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 - """, - (unit_id,), - ) - secs = [] - for sec_row in cur.fetchall(): - sec = r2d(sec_row) - cur.execute( - """ +""" +_SECTION_ITEMS_ROWS_SQL = """ SELECT tusi.*, e.title AS exercise_title, e.exercise_kind AS exercise_kind, @@ -558,26 +552,43 @@ 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 @@ -707,6 +718,7 @@ 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: verschachtelte `sections` + abgeleitete flache `exercises` (Legacy-Kompatibilität).""" uid = unit["id"] unit["sections"] = _fetch_sections(cur, uid) _flatten_exercises_from_sections(unit) @@ -874,31 +886,37 @@ 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) -> None: + """Eine Sektion inkl. Items einfügen (Ersetzungsbaum; keine Löschlogik).""" + 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, order_index, title, guidance_notes, source_template_section_id + ) VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + unit_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]): + """Ersetzt den gesamten Sektionsbaum der Einheit (DELETE aller Sektionen + Neuaufbau).""" cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) 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") - 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) - RETURNING id - """, - ( - unit_id, - order_ix, - title, - sec.get("guidance_notes"), - src_tsec, - ), - ) - sid = cur.fetchone()["id"] - _insert_section_items(cur, sid, sec.get("items")) + _insert_one_replacement_section(cur, unit_id, sec, si) def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]: 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..e3d9fe7 --- /dev/null +++ b/backend/tests/test_training_planning_sections_integration.py @@ -0,0 +1,159 @@ +""" +PostgreSQL-Integration: Roundtrip _replace_unit_sections ↔ _fetch_sections. + +Aktivierung (lokal, analog zu test_access_layer_integration): + set TRAINING_PLANNING_INTEGRATION=1 + pytest tests/test_training_planning_sections_integration.py -v -m integration + +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_sections, _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() 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/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 3a49e7c..9f85400 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -11,6 +11,8 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal' import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal' import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal' +/* Parallele Trainingsstreams (Breakout): Planungs-API bleibt flache sections/items bis Schema-Paket 1–2; + Payload-Aufbau → buildSectionsPayload (trainingUnitSectionsForm); Backend _replace_unit_sections. */ import { defaultSection, normalizeUnitToForm, diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx index a81bef5..bd22b50 100644 --- a/frontend/src/pages/TrainingCoachPage.jsx +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -1,5 +1,6 @@ /** * Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung. + * Parallele Streams: Schritte aus flattenPlanTimeline (linear); Stream-/Phasenwahl später einzubinden. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' diff --git a/frontend/src/pages/TrainingUnitRunPage.jsx b/frontend/src/pages/TrainingUnitRunPage.jsx index 5a7f4e1..7d4b9b6 100644 --- a/frontend/src/pages/TrainingUnitRunPage.jsx +++ b/frontend/src/pages/TrainingUnitRunPage.jsx @@ -1,5 +1,6 @@ /** * Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert). + * Parallele Streams: rendering nutzt sortedSections/sortedItems (trainingPlanUtils); Fortschritt pro unitId — später ggf. pro Stream. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js index db4139d..3a240ce 100644 --- a/frontend/src/utils/trainingPlanUtils.js +++ b/frontend/src/utils/trainingPlanUtils.js @@ -20,7 +20,8 @@ export function itemStableKey(it, secOrder, ix) { return `${secOrder}-${it?.item_type || 'row'}-${ix}` } -/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */ +/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. + * Parallele Streams: aktuell strikt linear (sortedSections × sortedItems); für Breakout phase/stream‑aware erweitern. */ export function flattenPlanTimeline(unit) { const list = [] sortedSections(unit).forEach((sec, si) => { diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index ecfacaa..e6105d6 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -398,6 +398,7 @@ export function parseMin(v) { return Number.isFinite(n) ? n : null } +/** PUT /api/training-units/:id `sections` — flache order_index pro Einheit; Parallelität bricht am DB-UNIQUE (training_unit_id, order_index) bis Migration. */ export function buildSectionsPayload(sections) { return sections.map((sec, si) => ({ order_index: si,