Enhance training unit sections handling and documentation for parallel training streams
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m9s

- Updated the backend to improve the fetching and insertion of training unit sections, including a new function for handling section items.
- Added documentation notes regarding the unique constraint on `training_unit_sections` and the implications for parallel training streams.
- Updated frontend components and utility functions to reflect changes in the training planning API and to prepare for future enhancements related to parallel streams.
This commit is contained in:
Lars 2026-05-14 22:24:55 +02:00
parent e759076a6c
commit 220a16429c
11 changed files with 437 additions and 54 deletions

View File

@ -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)

View File

@ -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 46) in einer dedizierten Feature-Session; danach Paket **2** (PUT/Module/Clone).
---

View File

@ -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 02).
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).
```

View File

@ -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]:

View File

@ -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()

View File

@ -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"] == []

View File

@ -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 12;
Payload-Aufbau buildSectionsPayload (trainingUnitSectionsForm); Backend _replace_unit_sections. */
import {
defaultSection,
normalizeUnitToForm,

View File

@ -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'

View File

@ -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'

View File

@ -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/streamaware erweitern. */
export function flattenPlanTimeline(unit) {
const list = []
sortedSections(unit).forEach((sec, si) => {

View File

@ -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,