diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md new file mode 100644 index 0000000..5ac7708 --- /dev/null +++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md @@ -0,0 +1,684 @@ +# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3 + +**Status:** fachlicher Spezifikationsentwurf +**Stand:** 2026-05-12 +**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan. + +**Wichtige Leitlinie dieser Version:** +Diese Spezifikation beschreibt bewusst **keine verbindlichen Tabellen, API-Pfade, Spaltennamen oder konkrete Implementierungsdetails**. Die technische Umsetzung soll durch den Coding Agent auf Basis der bestehenden Codebasis geplant werden. Ziel dieses Dokuments ist es, die fachliche Zielarchitektur, Nutzerlogik, Datenbedeutung und Produktentscheidungen so klar zu beschreiben, dass spätere große Refactorings vermieden werden, ohne die bestehende Anwendung durch zu frühe technische Festlegungen zu destabilisieren. + +--- + +## 1. Ausgangslage + +Shinkan ist eine trainerzentrierte App für Übungsverwaltung, Trainingsplanung, Rahmenprogramme und Durchführung. Die bestehende Planung arbeitet fachlich mit Trainingseinheiten, Trainingsabschnitten und Einträgen wie Übungen oder Notizen. + +Für die nächste Ausbaustufe werden zwei zusätzliche fachliche Bausteine benötigt: + +1. **Kombinationsübungen** + Strukturierte Übungsformen, bei denen mehrere Einzelübungen, Stationen, Rollen oder Schritte methodisch zusammenwirken. + +2. **Trainingsmodule** + Wiederverwendbare Planungsbausteine, also gespeicherte Übungsfolgen oder Trainingsblöcke, die in konkrete Trainings oder Rahmenprogramme übernommen werden können. + +Zusätzlich muss geklärt werden, wie **Trainingsmethoden**, **Methoden-Archetypen** und **konkrete Ablaufprofile** fachlich voneinander getrennt werden. + +--- + +## 2. Fachliche Grundentscheidungen + +### 2.1 Trainingsabschnitte bleiben Makrostruktur + +Trainingsabschnitte beschreiben die grobe Struktur einer Trainingseinheit, z. B.: + +* Aufwärmen, +* Hauptteil, +* Kumite, +* Kata, +* Selbstschutz, +* Abschluss. + +Ein Abschnitt ist damit ein Gliederungselement der gesamten Trainingseinheit. + +### 2.2 Kombinationsübungen sind nicht an genau einen Abschnitt gebunden + +Eine Kombinationsübung darf nicht fachlich oder technisch auf genau einen Trainingsabschnitt reduziert werden. + +Sie kann: + +* innerhalb eines Abschnitts verwendet werden, +* einen Abschnitt faktisch ausfüllen, +* zwischen zwei Abschnitten stehen, +* als zentraler Block der Einheit auf Trainingsebene liegen, +* Bestandteil eines Trainingsmoduls sein, +* Bestandteil eines Rahmenprogramms oder Rahmen-Slots sein. + +Der Abschnitt kann ein sinnvoller Anzeige- oder Planungskontext sein, ist aber nicht die fachliche Heimat der Kombinationsübung. + +### 2.3 Kombinationsübungen gehören fachlich zum Übungsbereich + +Eine Kombinationsübung ist eine Sonderform einer Übung. Sie besitzt daher die typischen Eigenschaften einer Übung: + +* Titel, +* Ziel, +* Durchführung, +* Trainerhinweise, +* Vorbereitung, +* Hilfsmittel, +* Dauer, +* Zielgruppe, +* Fähigkeiten, +* Methodenbezug, +* Medien, +* Sichtbarkeit, +* Freigabestatus. + +Zusätzlich besitzt sie eine interne Struktur: + +* Slots, +* Stationen, +* Rollen, +* Schritte, +* Übungspools, +* Methoden-Archetyp, +* Ablaufprofil für Planung und Coaching. + +### 2.4 Trainingsmodule gehören fachlich zur Planung + +Trainingsmodule sind keine Übungen, sondern wiederverwendbare Planungsbausteine. + +Ein Trainingsmodul kann enthalten: + +* einzelne Übungen, +* Kombinationsübungen, +* Notizen, +* methodische Hinweise, +* kurze wiederverwendbare Übungsfolgen, +* größere Blöcke innerhalb einer Einheit. + +Trainingsmodule sollten deshalb fachlich unter **Planung / Bibliothek / Module** verortet werden. + +### 2.5 Einfügen bedeutet Kopie mit Herkunft, nicht Live-Verknüpfung + +Wenn ein Trainingsmodul oder eine Kombinationsübung in eine konkrete Trainingseinheit übernommen wird, entsteht eine bearbeitbare Planungsinstanz. + +Grundsatz: + +> Bibliothek = Vorlage. +> Planung = lokal bearbeitbare Übernahme. +> Durchführung = tatsächliche Nutzung im Training. + +Spätere Änderungen an der Vorlage dürfen bereits geplante oder historische Einheiten nicht ungefragt verändern. + +--- + +## 3. Zentrale Begriffe + +| Begriff | Fachliche Bedeutung | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Trainingseinheit** | Konkretes geplantes oder durchgeführtes Training. | +| **Trainingsabschnitt** | Makrostruktur der Einheit, z. B. Aufwärmen oder Hauptteil. | +| **Planungsblock** | Zusammenhängender Inhalt innerhalb einer Einheit, z. B. Modul, Kombinationsübung oder manuell gruppierter Block. | +| **Kombinationsübung** | Sonderform einer Übung mit interner Struktur aus Slots, Stationen, Rollen oder Schritten. | +| **Trainingsmodul** | Wiederverwendbarer Planungsbaustein aus Übungen, Kombinationsübungen und Notizen. | +| **Trainingsmethode** | Fachlicher Katalogeintrag, der beschreibt, wie eine Trainingsform didaktisch oder sportmethodisch einzuordnen ist. | +| **Methoden-Archetyp** | Ablaufmuster für Planung und Coaching, z. B. rotierender Zirkel oder lineare Sequenz. | +| **Ablaufprofil** | Konkrete Ausprägung eines Archetyps, z. B. Arbeitszeit, Wechselzeit, Runden oder Erklärphase. | +| **Slot** | Platzhalter innerhalb einer Kombinationsübung, z. B. Station 1, Rolle A oder Schritt 2. | +| **Slot-Pool** | Menge möglicher Übungen für einen Slot, aus denen bei der Planung eine konkrete Auswahl getroffen werden kann. | + +--- + +## 4. Trainingsmethoden: Ablage und Beschreibung + +### 4.1 Rolle des Methodenkatalogs + +Trainingsmethoden sollen als eigenständige fachliche Katalogobjekte geführt werden. + +Sie beschreiben nicht eine konkrete Übung, sondern die methodische Qualität einer Trainingsform. + +Beispiele: + +* Zirkeltraining, +* Rollenspiel, +* strukturierte Übung, +* Koordinationstraining, +* plyometrisches Training, +* Dauermethode, +* extensive Intervallmethode, +* Partnerübung, +* freie Anwendung, +* Reflexionsformat. + +Der Methodenkatalog dient: + +* der Übungsbeschreibung, +* der Suche und Filterung, +* der Trainingsplanung, +* der fachlichen Standardisierung im Verein, +* der späteren KI- oder Assistenzunterstützung, +* der Qualitätssicherung bei offiziellen Inhalten. + +### 4.2 Abgrenzung: Methode, Archetyp, Ablaufprofil + +Die drei Begriffe müssen getrennt bleiben. + +| Ebene | Frage | Beispiel | +| --------------------- | ---------------------------------------------- | --------------------------------------------------------- | +| **Trainingsmethode** | Welche fachliche Methode wird verwendet? | Zirkeltraining, Rollenspiel, Intervalltraining. | +| **Methoden-Archetyp** | Nach welchem Ablaufmuster wird gesteuert? | rotierender Zirkel, parallele Stationen, lineare Sequenz. | +| **Ablaufprofil** | Wie ist die konkrete Durchführung eingestellt? | 45 Sekunden Arbeit, 15 Sekunden Wechsel, 3 Runden. | + +Wichtig: + +> Der Methoden-Archetyp ersetzt nicht die Trainingsmethode. Er ergänzt sie nur dort, wo der Ablauf für Planung oder Coaching maschinenlesbar interpretiert werden muss. + +### 4.3 Fachliche Beschreibung einer Trainingsmethode + +Eine Trainingsmethode sollte aus Trainersicht so beschrieben werden, dass sie zuverlässig angewendet, gesucht und von anderen Methoden unterschieden werden kann. + +Empfohlene fachliche Beschreibungsfelder: + +| Feld | Zweck | +| ---------------------------------- | -------------------------------------------------------------------------- | +| **Name** | Eindeutige Bezeichnung der Methode. | +| **Kurzbeschreibung** | Schnelle Orientierung in Listen und Auswahlfeldern. | +| **Langbeschreibung** | Fachliche Erklärung der Methode. | +| **Ziel / Nutzen** | Wofür diese Methode besonders geeignet ist. | +| **Typische Einsatzsituationen** | Wann die Methode sinnvoll eingesetzt wird. | +| **Geeignete Zielgruppen** | Altersgruppen, Leistungsgruppen oder Trainingskontexte. | +| **Organisationsform** | Einzelarbeit, Partnerarbeit, Gruppe, Stationen, Kreis, freie Fläche usw. | +| **Belastungscharakter** | locker, technisch, koordinativ, intensiv, intervallartig, spielerisch usw. | +| **Typische Dauer** | Orientierung für Planung und Zeitmanagement. | +| **Benötigte Rahmenbedingungen** | Platz, Material, Gruppengröße, Sicherheitsabstände. | +| **Trainerhinweise** | Wichtige Hinweise für Anleitung und Steuerung. | +| **Risiken / typische Fehler** | Was bei falscher Anwendung problematisch sein kann. | +| **Geeignete Fähigkeiten** | Fähigkeiten, die mit der Methode häufig adressiert werden. | +| **Verwandte Methoden** | Ähnliche oder kombinierbare Methoden. | +| **Abgrenzung zu anderen Methoden** | Wann eine andere Methode passender wäre. | +| **Optionale Standard-Archetypen** | Falls die Methode häufig mit bestimmten Ablaufmustern genutzt wird. | +| **Status und Sichtbarkeit** | Entwurf, freigegeben, offiziell, vereinsintern usw. | + +Diese Felder sind fachliche Anforderungen. Die konkrete technische Ablage soll der Coding Agent anhand der bestehenden Methodendomäne planen. + +### 4.4 Haupt- und Nebenmethoden + +Eine Übung sollte fachlich mindestens eine Hauptmethode haben können. + +Zusätzlich können Nebenmethoden sinnvoll sein, weil eine Übung aus mehreren methodischen Perspektiven beschrieben werden kann. + +Beispiel: + +* Hauptmethode: Zirkeltraining +* Nebenmethode: plyometrisches Training +* weitere Nebenmethode: Koordinationstraining + +Produktentscheidung: + +> Übungen und Kombinationsübungen sollen eine Hauptmethode und optional weitere Nebenmethoden unterstützen. + +Perspektivisch kann zusätzlich unterschieden werden zwischen: + +* sportmethodischer Methode, +* didaktischer Vermittlungsmethode, +* organisatorischer Durchführungsform. + +Diese Unterscheidung sollte aber im MVP nicht übermodelliert werden. + +### 4.5 Trainingsmethoden in Kombinationsübungen + +Bei Kombinationsübungen ist der Methodenbezug besonders wichtig. + +Eine Kombinationsübung sollte daher fachlich drei Dinge besitzen: + +1. **Methode** + Beispiel: Zirkeltraining. + +2. **Archetyp** + Beispiel: rotierender Zeit-Zirkel. + +3. **Ablaufprofil** + Beispiel: 6 Stationen, 45 Sekunden Arbeit, 15 Sekunden Wechsel, 3 Runden. + +So kann dieselbe Methode unterschiedlich angewendet werden: + +| Methode | Archetyp | Beispiel | +| ------------------- | ------------------- | --------------------------------------------------- | +| Zirkeltraining | rotierender Zirkel | Alle Gruppen wechseln gemeinsam weiter. | +| Zirkeltraining | parallele Stationen | Stationen laufen parallel, kein gemeinsamer Umlauf. | +| Intervalltraining | Intervallblock | Gemeinsame Zeitdomäne ohne Stationen. | +| Strukturierte Übung | lineare Sequenz | Schritt 1, Schritt 2, Schritt 3. | + +### 4.6 Methoden in Trainingsmodulen + +Trainingsmodule können ebenfalls einen Methodenbezug besitzen, aber anders als Übungen. + +Ein Modul kann: + +* eine dominante Methode haben, +* mehrere Methoden enthalten, +* methodisch neutral sein, +* nur aus einzelnen Übungen bestehen, die selbst Methoden besitzen. + +Empfehlung: + +> Ein Trainingsmodul darf optional eine primäre methodische Ausrichtung besitzen, sollte aber nicht zwingend eine Methode erzwingen. + +Beispiel: + +* Modul: „Aktivierung und Reaktion“ +* Primäre methodische Ausrichtung: Koordinationstraining +* Enthaltene Übungen: Reaktionsspiel, Sprintsignal, Partneraufgabe + +### 4.7 Methoden in der Suche und Planung + +Der Methodenkatalog soll in der Nutzung sichtbar werden. + +Benötigte Such- und Planungsfunktionen: + +* Übungen nach Methode filtern, +* Kombinationsübungen nach Methode und Archetyp filtern, +* Trainingsmodule nach methodischer Ausrichtung filtern, +* in der Planung passende Methoden für ein Trainingsziel finden, +* Methoden als Qualitätsmerkmal offizieller Vereinsinhalte nutzen, +* bei der Auswahl einer Kombinationsübung passende Ablaufmuster vorschlagen. + +Beispiel aus Trainersicht: + +> „Ich suche eine Übung für Kumite, Jugendliche, Schwerpunkt Beinarbeit, Methode Zirkeltraining oder Koordinationstraining, Dauer maximal 15 Minuten.“ + +### 4.8 Governance des Methodenkatalogs + +Trainingsmethoden sind fachliche Standardobjekte. Daher sollten sie stärker kontrolliert werden als private Trainingsnotizen. + +Empfehlung: + +* offizielle Methoden werden durch Administratoren oder Inhaltsverantwortliche gepflegt, +* Vereine können eigene Ergänzungen oder Spezialisierungen anlegen, +* Trainer können Vorschläge oder private methodische Hinweise erfassen, +* Änderungen an offiziellen Methoden sollten nicht ungeprüft globale Inhalte verändern. + +--- + +## 5. Methoden-Archetypen für Kombinationsübungen + +### 5.1 Zweck + +Archetypen beschreiben wiederkehrende Ablaufmuster, die für Planung und Coaching relevant sind. + +Sie beantworten nicht die Frage „Welche Methode ist das?“, sondern: + +> Wie soll dieser Block im Training durchlaufen oder angezeigt werden? + +### 5.2 Empfohlene Start-Archetypen + +| Archetyp | Fachliche Bedeutung | Coaching-Idee | +| ------------------------ | -------------------------------------------------------------------- | -------------------------------------------------- | +| **Lineare Sequenz** | Übungen bauen nacheinander aufeinander auf. | Schrittfolge mit optionalem Timer. | +| **Rotierender Zirkel** | Mehrere Stationen, Gruppen wechseln nach Zeit weiter. | Gemeinsamer Timer, Wechselhinweis, Rundenzähler. | +| **Parallele Stationen** | Mehrere Stationen laufen gleichzeitig, aber ohne zwingende Rotation. | Vorher erklären, dann paralleler Betrieb. | +| **Parcours** | Stationen oder Aufgaben entlang eines Wegs oder Ablaufs. | Navigation, Abhaken, flexible Reihenfolge möglich. | +| **Partner-/Paarwechsel** | Rollen oder Aufgaben wechseln gekoppelt. | A/B-Logik, Rollenhinweise, Wechselimpulse. | +| **Intervallblock** | Gemeinsame Zeitdomäne mit wiederholten Belastungsphasen. | Globale Uhr, Intervallanzeige. | +| **Freier Methodenblock** | Methodischer Zusammenhang ohne harte Steuerungslogik. | Kompakte Anzeige, manuelles Abhaken. | + +### 5.3 Mindestanforderung an Archetypen + +Für jeden Archetyp muss fachlich beschrieben sein: + +* wann er verwendet wird, +* welche Informationen der Trainer bei der Planung benötigt, +* welche Informationen im Coaching-Modus angezeigt werden, +* welche Angaben verpflichtend sind, +* welche Angaben optional sind, +* wann ein anderer Archetyp besser geeignet wäre. + +Die technische Validierung und konkrete Ablage dieser Angaben soll der Coding Agent planen. + +--- + +## 6. Kombinationsübungen + +### 6.1 Fachliche Beschreibung + +Eine Kombinationsübung ist eine wiederverwendbare Übungsform mit interner Struktur. + +Beispiele: + +* Kumite-Zirkel mit fünf Stationen, +* Koordinationsparcours, +* Selbstschutz-Parcours, +* Partnerwechselübung, +* methodische Sequenz zur Distanzkontrolle, +* Reaktions- und Explosivitätsblock, +* Aufwärmparcours für Kinder. + +### 6.2 Bestandteile + +Eine Kombinationsübung sollte fachlich enthalten: + +* allgemeine Übungsbeschreibung, +* Ziel, +* Durchführung, +* Trainerhinweise, +* Vorbereitung, +* Hilfsmittel, +* Zielgruppe, +* Fähigkeiten, +* Hauptmethode, +* optionale Nebenmethoden, +* Archetyp, +* Slots / Stationen / Rollen / Schritte, +* mögliche Übungen je Slot, +* optionale Standardwerte für Dauer, Runden oder Wechsel, +* Hinweise für den Coaching-Modus. + +### 6.3 Slot- und Pool-Logik + +Slots können fest oder variabel sein. + +Beispiel fest: + +* Station 1 = Seilspringen +* Station 2 = Liegestütz +* Station 3 = Beinarbeit + +Beispiel variabel: + +* Station 1 = eine Übung aus Pool „Beinarbeit“ +* Station 2 = eine Übung aus Pool „Reaktion“ +* Station 3 = eine Übung aus Pool „Konter“ + +Die konkrete Auswahl kann bei der Planung angepasst werden, ohne die Bibliotheksvorlage zu ändern. + +--- + +## 7. Trainingsmodule + +### 7.1 Fachliche Beschreibung + +Ein Trainingsmodul ist ein wiederverwendbarer Planungsbaustein. + +Beispiele: + +* Standard-Aufwärmen für Kinder, +* Mobilisation und Aktivierung, +* Kumite-Beinarbeit 20 Minuten, +* SV-Einstieg Wahrnehmung und Distanz, +* Abschlussritual mit Reflexion, +* prüfungsnaher Kihon-Block. + +### 7.2 Bestandteile + +Ein Trainingsmodul sollte fachlich enthalten: + +* Titel, +* Kurzbeschreibung, +* Ziel, +* empfohlene Dauer, +* empfohlene Zielgruppe, +* optional empfohlener Einsatzbereich, +* optionale methodische Ausrichtung, +* enthaltene Übungen, +* enthaltene Kombinationsübungen, +* Notizen oder Trainerhinweise, +* Sichtbarkeit, +* Freigabestatus. + +### 7.3 Keine harte Abschnittsbindung + +Ein Modul kann für einen Abschnitt empfohlen sein, z. B. „Aufwärmen“, darf aber nicht technisch darauf beschränkt werden. + +Ein Modul kann: + +* in einen Abschnitt eingefügt werden, +* als eigener Block auf Einheitsebene eingefügt werden, +* zwischen Abschnitten eingefügt werden, +* in ein Rahmenprogramm übernommen werden. + +--- + +## 8. Planungslogik + +### 8.1 Planungsblöcke + +Für die Produktlogik braucht Shinkan den Begriff des Planungsblocks. + +Ein Planungsblock ist ein zusammengehöriger Inhalt in einer Trainingseinheit. + +Planungsblöcke können sein: + +* eingefügtes Trainingsmodul, +* eingefügte Kombinationsübung, +* manuell gruppierter Block, +* später ggf. weitere Blocktypen. + +### 8.2 Verhältnis zu Abschnitten + +Ein Planungsblock kann einem Abschnitt zugeordnet sein, muss aber nicht vollständig in einem Abschnitt aufgehen. + +Produktregel: + +> Abschnitte gliedern die Einheit. Planungsblöcke gliedern den konkreten Trainingsinhalt. + +### 8.3 Lokale Anpassbarkeit + +Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können: + +* Dauer ändern, +* Übung austauschen, +* Station ergänzen, +* Hinweise anpassen, +* Reihenfolge ändern, +* Block auflösen, +* Block duplizieren, +* Block als neues Modul speichern. + +Diese Änderungen betreffen nur die konkrete Einheit oder den konkreten Rahmen-Slot, nicht automatisch das Bibliotheksexemplar. + +--- + +## 9. UX-Anforderungen + +### 9.1 Inhalt hinzufügen + +Im Planungseditor sollte der Trainer fachlich klar wählen können: + +* Übung hinzufügen, +* Kombinationsübung hinzufügen, +* Trainingsmodul hinzufügen, +* Notiz hinzufügen, +* manuellen Block erstellen. + +### 9.2 Modul erstellen + +Ein Modul sollte auf mehreren Wegen entstehen können: + +* leer anlegen, +* aus bestehendem Abschnitt speichern, +* aus markierten Übungen speichern, +* aus einem Teil eines alten Trainings speichern. + +### 9.3 Kombinationsübung erstellen + +Eine Kombinationsübung sollte geführt angelegt werden: + +1. Grunddaten erfassen, +2. Methode wählen, +3. Archetyp wählen, +4. Slots / Stationen / Rollen definieren, +5. Übungen oder Pools zuordnen, +6. Ablaufprofil festlegen, +7. Coaching-Vorschau prüfen, +8. speichern. + +### 9.4 Methoden auswählen + +Die Methodenauswahl sollte Trainer unterstützen, nicht belasten. + +Empfohlene UX: + +* Hauptmethode prominent, +* Nebenmethoden optional, +* passende Methoden vorschlagen, +* Methoden kurz erklären, +* bei Kombinationsübungen passende Archetypen vorschlagen, +* keine Pflicht zur Überklassifizierung bei einfachen Übungen. + +--- + +## 10. Coaching- und Assistenzmodus + +### 10.1 Ziel + +Der Coaching-Modus soll die Durchführung unterstützen, ohne den Trainer zu zwingen, exakt dem Plan zu folgen. + +Grundsatz: + +> Der Coaching-Modus gibt Orientierung, Zeitstruktur und Ablaufhilfe, bleibt aber in der Praxis flexibel. + +### 10.2 Unterschiedliche Anzeige je Archetyp + +| Archetyp | Coaching-Anzeige | +| -------------------- | -------------------------------------------- | +| Lineare Sequenz | Schrittfolge mit Weiter/Zurück. | +| Rotierender Zirkel | Stationen, Arbeitszeit, Wechselzeit, Runden. | +| Parallele Stationen | Erst Erklärübersicht, dann Parallelbetrieb. | +| Parcours | Stationen oder Wegpunkte zum Abhaken. | +| Partner-/Paarwechsel | Rollen, Aufgaben und Wechselhinweise. | +| Intervallblock | Globale Zeit, Intervallzähler, Aufgaben. | +| Freier Methodenblock | Kompakte Übersicht und manuelle Steuerung. | + +### 10.3 Durchführungsdokumentation + +Perspektivisch sollte dokumentierbar sein: + +* was durchgeführt wurde, +* was übersprungen wurde, +* was verändert wurde, +* tatsächliche Dauer, +* Trainerhinweise, +* Reflexion, +* Vorschläge zur Verbesserung einer Übung oder eines Moduls. + +Die konkrete technische Umsetzung wird nicht in dieser Spezifikation festgelegt. + +--- + +## 11. Rahmenprogramm-Integration + +Trainingsmodule und Kombinationsübungen müssen auch in Rahmenprogrammen nutzbar sein. + +Regel: + +> Was in einer konkreten Trainingseinheit geplant werden kann, sollte grundsätzlich auch in einem Rahmenprogramm oder Rahmen-Slot vorbereitet werden können, sofern es keine echte Durchführung voraussetzt. + +Das betrifft insbesondere: + +* Modul einfügen, +* Kombinationsübung einfügen, +* methodische Ausrichtung übernehmen, +* Slot-Pools vorbelegen, +* Dauer anpassen, +* später konkrete Einheit daraus ableiten. + +Nicht in den Rahmen gehört: + +* echte Durchführung, +* tatsächliche Dauer, +* spontane Trainingsnotizen, +* Nachbereitungsreflexion. + +--- + +## 12. Governance + +Für Methoden, Übungen, Kombinationsübungen und Module gelten abgestufte Sichtbarkeiten und Verantwortlichkeiten. + +Empfohlene fachliche Ebenen: + +* privat, +* Verein, +* offiziell, +* archiviert, +* Entwurf, +* freigegeben. + +Normale Trainer sollen Inhalte nutzen und lokal anpassen können. Offizielle oder vereinsweite Vorlagen sollen nicht ungeprüft überschrieben werden. + +Für Methoden ist eine besondere Qualitätskontrolle sinnvoll, weil sie als fachlicher Katalog für viele Übungen und Planungen wirken. + +--- + +## 13. MVP-Empfehlung + +### 13.1 Muss enthalten sein + +* Trainingsmodule anlegen und wiederverwenden, +* Kombinationsübungen als fachliche Sonderform von Übungen, +* Methodenbezug mit Hauptmethode und optionalen Nebenmethoden, +* klare Trennung zwischen Methode, Archetyp und Ablaufprofil, +* mindestens folgende Archetypen: + + * lineare Sequenz, + * rotierender Zirkel, + * freier Methodenblock, +* Planungsblöcke als fachliches Konzept, +* lokale Anpassbarkeit nach Einfügen, +* einfache Coaching-Ansicht. + +### 13.2 Sollte vorbereitet werden + +* parallele Stationen, +* Parcours, +* Partner-/Paarwechsel, +* Intervallblock, +* Durchführungsdokumentation, +* Rückfluss von Erfahrungswissen, +* Offline-/PWA-Nutzung, +* stärkere Suche nach Methoden und Archetypen. + +### 13.3 Nicht im MVP + +* vollständige technische Event-Historie jeder Planänderung, +* automatische Synchronisation alter Einheiten bei Vorlagenänderung, +* komplexe Verschachtelung von Modulen in Modulen, +* individuelles Athleten-Tracking, +* KI-generierte Trainingsplanung, +* verbindliche technische Tabellen- oder API-Architektur. + +--- + +## 14. Arbeitsauftrag an den Coding Agent — fachliche Leitplanken + +Der Coding Agent soll die bestehende Codebasis prüfen und auf dieser Grundlage eine technische Umsetzungsplanung erstellen. + +Dabei soll er ausdrücklich: + +1. bestehende Strukturen wiederverwenden, soweit sinnvoll, +2. keine unnötigen Refactorings auslösen, +3. bestehende Trainingsplanung nicht destabilisieren, +4. Migrationen schrittweise und rückwärtskompatibel planen, +5. vorhandene Methodendomäne berücksichtigen, +6. die Trennung zwischen Trainingsmethode, Archetyp und Ablaufprofil fachlich erhalten, +7. technische Alternativen mit Vor- und Nachteilen darstellen, +8. erst danach konkrete Tabellen, APIs und UI-Komponenten vorschlagen. + +Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlicher Rahmen. + +--- + +## 15. Zusammenfassung der verbindlichen Produktlogik + +1. Trainingsabschnitte sind die Makrostruktur der Einheit. +2. Kombinationsübungen sind keine Abschnitte. +3. Kombinationsübungen sind Sonderformen von Übungen. +4. Trainingsmodule sind Planungsbausteine. +5. Trainingsmethoden sind eigenständige fachliche Katalogobjekte. +6. Eine Übung hat eine Hauptmethode und optional Nebenmethoden. +7. Methoden-Archetypen beschreiben Ablaufmuster, nicht die Methode selbst. +8. Ablaufprofile konkretisieren den Archetyp für Planung und Coaching. +9. Einfügen aus Bibliotheken erzeugt lokal bearbeitbare Planungsinhalte. +10. Vorlagenänderungen verändern historische oder konkrete Planungen nicht automatisch. +11. Rahmenprogramme sollen dieselbe Planungslogik nutzen wie konkrete Einheiten. +12. Der Coding Agent entscheidet die technische Umsetzung anhand der bestehenden Codebasis. diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index b0a613c..984c0eb 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | +| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` | | training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | | admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` | | platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users | @@ -36,12 +37,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-07 — Upload-Dedupe Papierkorb 409 + `reactivate`; DELETE …/media nur Verknüpfung. +Letzte Änderung: 2026-05-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek. --- ### Changelog (Fortführung) +- **2026-05-12:** `training_modules` Router dokumentiert. - **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`. - **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`. diff --git a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..2e8dfc2 --- /dev/null +++ b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md @@ -0,0 +1,30 @@ +# Umsetzungsplan: Trainingsmodule & Kombinationsübungen + +**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, Stand 2026-05-12) +**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` +**Stand dieses Dokuments:** 2026-05-12 + +## Ziele + +Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung zu destabilisieren: schrittweise Migrationen, bestehende Sektions-/Item-Struktur (`training_unit_sections`, `training_unit_section_items`) beibehalten, Kopiersemantik bei Übernahmen. + +## Phasenüberblick + +| Phase | Inhalt | Status | +|-------|--------|--------| +| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** | +| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, Haupt-/Nebenmethoden-M:N, Archetyp + Ablaufprofil | geplant | +| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant | +| **4** | Coaching-Ansicht: Archetyp-spezifische Darstellung für MVP-Archetypen | geplant | +| **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant | + +## Phase 1 (technische Notizen) + +- **Governance:** `visibility`/`club_id`/`created_by` analog `training_plan_templates`; Listenfilter `library_content_visibility_sql`. +- **Übernahme:** Keine Live-Verknüpfung; Items werden kopiert; `source_training_module_id` dokumentiert Herkunft. +- **Schnittstelle Übernahme:** `section_order_index` entspricht der Reihenfolge der Abschnitte in der gespeicherten Einheit (0-basiert), konsistent zur Planungs-API. + +## Pflege nach Merge + +- `DATABASE_SCHEMA.md` bei größeren Schema-Erweiterungen ergänzen. +- `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei neuen mandantenbezogenen Endpunkten fortpflegen. diff --git a/backend/main.py b/backend/main.py index 10547ec..108cfe0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports app.include_router(auth.router) app.include_router(profiles.router) @@ -209,6 +209,7 @@ app.include_router(media_assets.admin_rights_router) app.include_router(media_assets.admin_legal_hold_router) app.include_router(skills.router) app.include_router(training_planning.router) +app.include_router(training_modules.router) app.include_router(training_framework_programs.router) app.include_router(catalogs.router) app.include_router(maturity_models.router) diff --git a/backend/migrations/054_training_modules.sql b/backend/migrations/054_training_modules.sql new file mode 100644 index 0000000..dc9046c --- /dev/null +++ b/backend/migrations/054_training_modules.sql @@ -0,0 +1,60 @@ +-- Migration 054: Trainingsmodule (Bibliothek / Planung) — Phase 1 MVP +-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md + +CREATE TABLE IF NOT EXISTS training_modules ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id) ON DELETE SET NULL, + created_by INT REFERENCES profiles(id) ON DELETE SET NULL, + title VARCHAR(200) NOT NULL, + summary TEXT, + goal TEXT, + recommended_duration_min INT, + target_group_notes TEXT, + deployment_context_notes TEXT, + primary_method_id INT REFERENCES training_methods(id) ON DELETE SET NULL, + visibility VARCHAR(50) NOT NULL DEFAULT 'club' + CHECK (visibility IN ('private', 'club', 'official')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_training_modules_club ON training_modules(club_id); +CREATE INDEX IF NOT EXISTS idx_training_modules_creator ON training_modules(created_by); +CREATE INDEX IF NOT EXISTS idx_training_modules_visibility ON training_modules(visibility); +CREATE INDEX IF NOT EXISTS idx_training_modules_method ON training_modules(primary_method_id) + WHERE primary_method_id IS NOT NULL; + +DROP TRIGGER IF EXISTS training_modules_update ON training_modules; +CREATE TRIGGER training_modules_update + BEFORE UPDATE ON training_modules + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); + +CREATE TABLE IF NOT EXISTS training_module_items ( + id SERIAL PRIMARY KEY, + module_id INT NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE, + order_index INT NOT NULL, + item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('exercise', 'note')), + exercise_id INT REFERENCES exercises(id) ON DELETE SET NULL, + exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL, + planned_duration_min INT, + notes TEXT, + note_body TEXT, + UNIQUE (module_id, order_index), + CHECK ( + (item_type = 'exercise' AND exercise_id IS NOT NULL AND note_body IS NULL) + OR + (item_type = 'note' AND exercise_id IS NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_training_module_items_module ON training_module_items(module_id); +CREATE INDEX IF NOT EXISTS idx_training_module_items_exercise ON training_module_items(exercise_id) + WHERE exercise_id IS NOT NULL; + +-- Herkunft bei Übernahme aus Modul-Bibliothek (Kopie, keine Live-Verknüpfung) +ALTER TABLE training_unit_section_items + ADD COLUMN IF NOT EXISTS source_training_module_id INT REFERENCES training_modules(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_training_unit_section_items_source_module + ON training_unit_section_items(source_training_module_id) + WHERE source_training_module_id IS NOT NULL; diff --git a/backend/routers/training_modules.py b/backend/routers/training_modules.py new file mode 100644 index 0000000..1fb40b3 --- /dev/null +++ b/backend/routers/training_modules.py @@ -0,0 +1,381 @@ +""" +Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek). + +Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`): +Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder Plattform‑Admin. + +Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`. +""" +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import APIRouter, Depends, HTTPException + +from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql +from club_tenancy import ( + assert_valid_governance_visibility, + is_platform_admin, + library_content_visible_to_profile, +) + +router = APIRouter(prefix="/api", tags=["training_modules"]) + + +def _has_planning_role(role: Optional[str]) -> bool: + return role in ("admin", "superadmin", "trainer", "user") + + +def _fetch_training_module_row(cur, mid: int) -> Dict[str, Any]: + cur.execute("SELECT * FROM training_modules WHERE id = %s", (mid,)) + r = cur.fetchone() + if not r: + raise HTTPException(status_code=404, detail="Trainingsmodul nicht gefunden") + return r2d(r) + + +def _module_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return + if not library_content_visible_to_profile( + cur, + profile_id, + row.get("visibility") or "club", + row.get("club_id"), + row.get("created_by"), + role, + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul") + + +def _module_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return + if row.get("created_by") != profile_id: + raise HTTPException(status_code=403, detail="Nur der Ersteller darf dieses Modul ändern") + + +def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]: + row = _fetch_training_module_row(cur, mid) + _module_assert_readable(cur, row, profile_id, role) + return row + + +def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): + if not variant_id: + return + if not exercise_id: + raise HTTPException( + status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt" + ) + cur.execute( + "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", + (variant_id, exercise_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung") + + +def _optional_positive_int(val, field_name: str) -> Optional[int]: + if val is None or val == "": + return None + try: + i = int(val) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"Ungültige {field_name}") + if i < 1: + raise HTTPException(status_code=400, detail=f"Ungültige {field_name}") + return i + + +def _replace_module_items(cur, module_id: int, items_in: Optional[List[Any]]) -> None: + cur.execute("DELETE FROM training_module_items WHERE module_id = %s", (module_id,)) + items_in = items_in or [] + for i, raw in enumerate(items_in): + itype = raw.get("item_type") + if not itype: + itype = "exercise" if raw.get("exercise_id") else "note" + order_ix = raw.get("order_index") + if order_ix is None: + order_ix = i + order_ix = int(order_ix) + + if itype == "note": + body = raw.get("note_body") + if body is None: + body = "" + cur.execute( + """ + INSERT INTO training_module_items ( + module_id, order_index, item_type, + exercise_id, exercise_variant_id, + planned_duration_min, notes, note_body + ) VALUES (%s, %s, 'note', + NULL, NULL, NULL, NULL, %s) + """, + (module_id, order_ix, body), + ) + continue + + eid = raw.get("exercise_id") + if not eid: + continue + eid = int(eid) + vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") + _validate_variant_for_exercise(cur, eid, vid) + cur.execute( + """ + INSERT INTO training_module_items ( + module_id, order_index, item_type, + exercise_id, exercise_variant_id, + planned_duration_min, notes, note_body + ) VALUES (%s, %s, 'exercise', + %s, %s, %s, %s, NULL) + """, + ( + module_id, + order_ix, + eid, + vid, + raw.get("planned_duration_min"), + raw.get("notes"), + ), + ) + + +def load_training_module_for_apply( + cur, module_id: int, profile_id: int, role: Optional[str] +) -> Tuple[List[Dict[str, Any]], int]: + """ + Liest Modul inkl. Items für Übernahme in eine Trainingseinheit. + Returns (items_ordered, module_id). + Raises HTTPException bei 403/404. + """ + row = _fetch_training_module_row(cur, module_id) + _module_assert_readable(cur, row, profile_id, role or "") + cur.execute( + """ + SELECT item_type, exercise_id, exercise_variant_id, + planned_duration_min, notes, note_body + FROM training_module_items + WHERE module_id = %s + ORDER BY order_index ASC + """, + (module_id,), + ) + raw_items = [r2d(x) for x in cur.fetchall()] + items: List[Dict[str, Any]] = [] + for r in raw_items: + items.append(dict(r)) + return items, int(module_id) + + +@router.get("/training-modules") +def list_training_modules(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + vis_clause, vis_params = library_content_visibility_sql( + alias="m", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT m.*, + (SELECT COUNT(*) FROM training_module_items i WHERE i.module_id = m.id) + AS items_count + FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST, m.title + """, + vis_params, + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.get("/training-modules/{module_id}") +def get_training_module(module_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _module_access(cur, module_id, profile_id, role) + cur.execute( + """ + SELECT * + FROM training_module_items + WHERE module_id = %s + ORDER BY order_index ASC + """, + (module_id,), + ) + row["items"] = [r2d(r) for r in cur.fetchall()] + return row + + +@router.post("/training-modules") +def create_training_module(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingsmodule anlegen") + + title = (data.get("title") or "").strip() + if not title: + raise HTTPException(status_code=400, detail="title ist Pflicht") + + vis_raw = data.get("visibility") + visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club" + club_id = data.get("club_id") + if club_id in ("", []): + club_id = None + if visibility == "club" and club_id is None: + club_id = tenant.effective_club_id + + primary_method_id = data.get("primary_method_id") + if primary_method_id in ("", []): + primary_method_id = None + if primary_method_id is not None: + primary_method_id = int(primary_method_id) + + items_in = data.get("items") or [] + + with get_db() as conn: + cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id) + if primary_method_id is not None: + cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (primary_method_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden") + + cur.execute( + """ + INSERT INTO training_modules ( + club_id, created_by, title, summary, goal, + recommended_duration_min, target_group_notes, deployment_context_notes, + primary_method_id, visibility + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + club_id, + profile_id, + title, + (data.get("summary") or "").strip() or None, + data.get("goal"), + data.get("recommended_duration_min"), + data.get("target_group_notes"), + data.get("deployment_context_notes"), + primary_method_id, + visibility, + ), + ) + mid = cur.fetchone()["id"] + _replace_module_items(cur, mid, items_in if isinstance(items_in, list) else []) + conn.commit() + + return get_training_module(mid, tenant) + + +@router.put("/training-modules/{module_id}") +def update_training_module( + module_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row_prev = _fetch_training_module_row(cur, module_id) + _module_assert_writable(cur, row_prev, profile_id, role) + + merged_vis = row_prev.get("visibility") or "club" + merged_club = row_prev.get("club_id") + + if "visibility" in data: + v_in = data.get("visibility") + if not isinstance(v_in, str) or v_in not in ("private", "club", "official"): + raise HTTPException(status_code=400, detail="visibility ungültig") + merged_vis = v_in + + if "club_id" in data: + merged_club = data.get("club_id") + if merged_club in ("", []): + merged_club = None + + if "visibility" in data or "club_id" in data: + assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) + + fields: List[str] = [] + params: List[Any] = [] + + if "title" in data: + t = data.get("title") + t = t.strip() if isinstance(t, str) else "" + if not t: + raise HTTPException(status_code=400, detail="title ist Pflicht") + fields.append("title = %s") + params.append(t) + + for col in ("summary", "goal", "target_group_notes", "deployment_context_notes"): + if col in data: + fields.append(f"{col} = %s") + v = data.get(col) + if col == "summary" and isinstance(v, str): + v = v.strip() or None + params.append(v) + + if "recommended_duration_min" in data: + fields.append("recommended_duration_min = %s") + params.append(data.get("recommended_duration_min")) + + if "primary_method_id" in data: + pm = data.get("primary_method_id") + if pm in ("", [], None): + fields.append("primary_method_id = %s") + params.append(None) + else: + pm = int(pm) + cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (pm,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden") + fields.append("primary_method_id = %s") + params.append(pm) + + if "club_id" in data: + fields.append("club_id = %s") + params.append(merged_club) + + if "visibility" in data: + fields.append("visibility = %s") + params.append(merged_vis) + + if fields: + fields.append("updated_at = NOW()") + params.append(module_id) + cur.execute( + f"UPDATE training_modules SET {', '.join(fields)} WHERE id = %s", + tuple(params), + ) + + if "items" in data: + items_in = data["items"] + _replace_module_items(cur, module_id, items_in if isinstance(items_in, list) else []) + + conn.commit() + + return get_training_module(module_id, tenant) + + +@router.delete("/training-modules/{module_id}") +def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row_del = _fetch_training_module_row(cur, module_id) + _module_assert_writable(cur, row_del, profile_id, role) + cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,)) + conn.commit() + return {"ok": True} diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 78bc771..d621e88 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -17,6 +17,7 @@ from club_tenancy import ( is_platform_admin, library_content_visible_to_profile, ) +from routers.training_modules import load_training_module_for_apply router = APIRouter(prefix="/api", tags=["training_planning"]) @@ -568,6 +569,93 @@ def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]: return unit +def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int: + cur.execute( + """ + SELECT id FROM training_unit_sections + WHERE training_unit_id = %s AND order_index = %s + """, + (unit_id, section_order_index), + ) + r = cur.fetchone() + if not r: + raise HTTPException( + status_code=400, detail="Abschnitt für diese Reihenfolge nicht gefunden" + ) + return int(r["id"]) + + +def _append_copied_module_items_to_section( + cur, + section_id: int, + module_items: List[Dict[str, Any]], + source_training_module_id: int, +) -> None: + """Hängt kopierte Modul‑Items ans Ende eines Abschnitts (section_order_index in API).""" + cur.execute( + """ + SELECT COALESCE(MAX(order_index), -1) AS mo + FROM training_unit_section_items + WHERE section_id = %s + """, + (section_id,), + ) + row = cur.fetchone() + start = int(row["mo"]) + 1 if row and row["mo"] is not None else 0 + + for i, mi in enumerate(module_items): + oi = start + i + itype = mi.get("item_type") + if itype == "note": + body = mi.get("note_body") + if body is None: + body = "" + cur.execute( + """ + INSERT INTO training_unit_section_items ( + section_id, order_index, item_type, + exercise_id, exercise_variant_id, + planned_duration_min, actual_duration_min, + notes, modifications, note_body, source_training_module_id + ) VALUES (%s, %s, 'note', + NULL, NULL, NULL, NULL, NULL, NULL, %s, %s) + """, + (section_id, oi, body, source_training_module_id), + ) + continue + + eid = mi.get("exercise_id") + if not eid: + continue + eid = int(eid) + vid = mi.get("exercise_variant_id") + if vid is not None: + vid = int(vid) + else: + vid = None + _validate_variant_for_exercise(cur, eid, vid) + cur.execute( + """ + INSERT INTO training_unit_section_items ( + section_id, order_index, item_type, + exercise_id, exercise_variant_id, + planned_duration_min, actual_duration_min, + notes, modifications, note_body, source_training_module_id + ) VALUES (%s, %s, 'exercise', + %s, %s, %s, NULL, %s, NULL, NULL, %s) + """, + ( + section_id, + oi, + eid, + vid, + mi.get("planned_duration_min"), + mi.get("notes"), + source_training_module_id, + ), + ) + + def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0): if items_in is None: items_in = [] @@ -1443,6 +1531,46 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c return unit +@router.post("/training-units/{unit_id}/apply-training-module") +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).""" + profile_id = tenant.profile_id + role = tenant.global_role + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Trainer dürfen Module übernehmen") + + module_id_raw = data.get("module_id") + if module_id_raw is None or module_id_raw == "": + raise HTTPException(status_code=400, detail="module_id ist Pflicht") + try: + module_id = int(module_id_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="module_id ungültig") + + soy = data.get("section_order_index") + try: + section_order_index = int(soy) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="section_order_index ist Pflicht (Ganzzahl)") + if section_order_index < 0: + raise HTTPException(status_code=400, detail="section_order_index ungültig") + + 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) + 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) + conn.commit() + + return get_training_unit(unit_id, tenant) + + @router.post("/training-units") def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id diff --git a/backend/version.py b/backend/version.py index 5a32e9d..e23c351 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.96" +APP_VERSION = "0.8.97" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260511053" +DB_SCHEMA_VERSION = "20260512054" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -24,7 +24,8 @@ MODULE_VERSIONS = { "exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451) "training_units": "0.2.0", "training_programs": "0.1.0", - "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile + "planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1) + "training_modules": "1.0.0", "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -34,6 +35,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.97", + "date": "2026-05-12", + "changes": [ + "Trainingsmodule (Phase 1): Bibliothek `training_modules` + `training_module_items` (Migration 054); REST `/api/training-modules`; Übernahme in Einheiten per `POST /api/training-units/{id}/apply-training-module`; Herkunft `source_training_module_id` auf kopierten Sektions-Items; UI unter /planning/training-modules und Übernahme-Dialog in der Trainingsplanung.", + "Umsetzungsplan: `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.", + ], + }, { "version": "0.8.96", "date": "2026-05-12", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ad2cc96..e462127 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -26,6 +26,8 @@ import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage' +import TrainingModulesListPage from './pages/TrainingModulesListPage' +import TrainingModuleEditPage from './pages/TrainingModuleEditPage' import TrainingUnitRunPage from './pages/TrainingUnitRunPage' import TrainingCoachPage from './pages/TrainingCoachPage' import AdminCatalogsPage from './pages/AdminCatalogsPage' @@ -199,6 +201,9 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx new file mode 100644 index 0000000..08e88db --- /dev/null +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -0,0 +1,443 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import api from '../utils/api' +import ExercisePickerModal from '../components/ExercisePickerModal' +import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' + +function nextLocalKey() { + return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function swapItems(arr, i, j) { + if (i === j || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return [...arr] + const n = [...arr] + ;[n[i], n[j]] = [n[j], n[i]] + return n +} + +export default function TrainingModuleEditPage() { + const { id: routeId } = useParams() + const navigate = useNavigate() + const isNew = !routeId || routeId === 'new' + const moduleId = !isNew ? parseInt(routeId, 10) : NaN + + const [loading, setLoading] = useState(!isNew) + const [saving, setSaving] = useState(false) + const [methods, setMethods] = useState([]) + const [pickerOpen, setPickerOpen] = useState(false) + const [error, setError] = useState('') + + const [title, setTitle] = useState('') + const [summary, setSummary] = useState('') + const [goal, setGoal] = useState('') + const [recommendedDurationMin, setRecommendedDurationMin] = useState('') + const [targetGroupNotes, setTargetGroupNotes] = useState('') + const [deploymentContextNotes, setDeploymentContextNotes] = useState('') + const [visibility, setVisibility] = useState('club') + const [clubIdField, setClubIdField] = useState('') + const [primaryMethodId, setPrimaryMethodId] = useState('') + const [items, setItems] = useState([]) + + const itemsPayload = items.map((it, i) => { + if (it.item_type === 'note') { + return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' } + } + const vid = + it.exercise_variant_id !== '' && it.exercise_variant_id != null + ? parseInt(it.exercise_variant_id, 10) + : null + return { + item_type: 'exercise', + order_index: i, + exercise_id: parseInt(it.exercise_id, 10), + exercise_variant_id: Number.isFinite(vid) ? vid : null, + planned_duration_min: + it.planned_duration_min !== '' && it.planned_duration_min != null + ? parseInt(String(it.planned_duration_min), 10) + : null, + notes: it.notes?.trim() ? it.notes.trim() : null, + } + }) + + const loadCatalogs = useCallback(async () => { + try { + const m = await api.listMethods({}) + setMethods(Array.isArray(m) ? m : []) + } catch { + setMethods([]) + } + }, []) + + useEffect(() => { + loadCatalogs() + }, [loadCatalogs]) + + useEffect(() => { + if (isNew || !Number.isFinite(moduleId)) { + setLoading(false) + return + } + let cancelled = false + async function load() { + setLoading(true) + setError('') + try { + const m = await api.getTrainingModule(moduleId) + if (cancelled) return + setTitle((m.title || '').trim()) + setSummary((m.summary || '').trim()) + setGoal(m.goal || '') + setRecommendedDurationMin( + m.recommended_duration_min != null && m.recommended_duration_min !== '' + ? String(m.recommended_duration_min) + : '' + ) + setTargetGroupNotes(m.target_group_notes || '') + setDeploymentContextNotes(m.deployment_context_notes || '') + setVisibility((m.visibility || 'club').trim()) + setClubIdField(m.club_id != null ? String(m.club_id) : '') + setPrimaryMethodId(m.primary_method_id != null ? String(m.primary_method_id) : '') + const nextItems = [] + for (const row of Array.isArray(m.items) ? m.items : []) { + if (row.item_type === 'note') { + nextItems.push({ localKey: nextLocalKey(), item_type: 'note', note_body: row.note_body || '' }) + continue + } + const ex = await hydrateExercisePlanningRow({ + id: row.exercise_id, + title: '', + variants: [], + }) + if (ex) { + ex.localKey = nextLocalKey() + if (row.exercise_variant_id) ex.exercise_variant_id = String(row.exercise_variant_id) + ex.planned_duration_min = + row.planned_duration_min != null && row.planned_duration_min !== '' + ? String(row.planned_duration_min) + : '' + ex.notes = row.notes || '' + nextItems.push(ex) + } + } + setItems(nextItems) + } catch (e) { + if (!cancelled) setError(e.message || 'Laden fehlgeschlagen') + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { + cancelled = true + } + }, [isNew, moduleId]) + + const buildBody = () => { + const cid = + visibility === 'club' && clubIdField !== '' ? parseInt(clubIdField, 10) : null + const pm = + primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null + return { + title: title.trim(), + summary: summary.trim() || null, + goal: goal.trim() || null, + recommended_duration_min: + recommendedDurationMin !== '' ? parseInt(recommendedDurationMin, 10) : null, + target_group_notes: targetGroupNotes.trim() || null, + deployment_context_notes: deploymentContextNotes.trim() || null, + visibility, + club_id: + cid != null && Number.isFinite(cid) && cid >= 1 + ? cid + : visibility === 'club' + ? undefined + : null, + primary_method_id: + pm != null && Number.isFinite(pm) && pm >= 1 ? pm : null, + items: itemsPayload.filter((row) => + row.item_type === 'note' ? true : Number.isFinite(row.exercise_id) && row.exercise_id >= 1 + ), + } + } + + const handleSave = async (e) => { + e.preventDefault() + if (!title.trim()) { + alert('Titel ist Pflicht.') + return + } + setSaving(true) + setError('') + try { + const body = buildBody() + if (isNew) { + const created = await api.createTrainingModule(body) + navigate(`/planning/training-modules/${created.id}`, { replace: true }) + } else { + await api.updateTrainingModule(moduleId, body) + alert('Trainingsmodul gespeichert.') + } + } catch (err) { + setError(err.message || 'Speichern fehlgeschlagen') + } finally { + setSaving(false) + } + } + + const pickExercise = async (ex) => { + if (!ex?.id) return + const row = await hydrateExercisePlanningRow(ex) + if (row) row.localKey = nextLocalKey() + if (row) setItems((prev) => [...prev, row]) + setPickerOpen(false) + } + + return ( +
+

+ + ← Zurück zur Modul‑Bibliothek + +

+

{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}

+

+ Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie). +

+ + {error ?

{error}

: null} + {loading ? ( +

Laden …

+ ) : ( +
+
+ + setTitle(e.target.value)} /> +
+
+ +