From 81b9e8f60108ecaf6add3dcf0a88beda56f94ec6 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 10:52:06 +0200
Subject: [PATCH 01/27] chore: bump version to 0.8.95 and update legal
documents features
- Updated app version to 0.8.95 with a new build date of 2026-05-12.
- Enhanced legal documents functionality to support section numbering and Markdown formatting in the output.
- Updated dependencies in package.json to include 'marked' and 'react-markdown'.
- Added new CSS styles for legal document presentation.
- Refactored PDF generation logic to incorporate new metadata and improved document structure.
Co-Authored-By: Claude Sonnet 4.6
---
backend/version.py | 13 +-
frontend/package.json | 5 +-
frontend/src/app.css | 77 ++++
frontend/src/components/LegalDocumentBody.jsx | 25 ++
.../src/pages/AdminLegalDocumentsPage.jsx | 98 +----
frontend/src/pages/LegalPage.jsx | 87 +---
frontend/src/utils/legalPdfExport.js | 391 ++++++++++++++++++
7 files changed, 533 insertions(+), 163 deletions(-)
create mode 100644 frontend/src/components/LegalDocumentBody.jsx
create mode 100644 frontend/src/utils/legalPdfExport.js
diff --git a/backend/version.py b/backend/version.py
index bee357d..576abb2 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,11 +1,11 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.94"
-BUILD_DATE = "2026-05-11"
+APP_VERSION = "0.8.95"
+BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = {
- "legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
+ "legal_documents": "1.3.0", # P-01: Ausgabe §-Nummerierung pro Abschnitt; Markdown im Fließtext + PDF; gem. legalPdfExport
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
@@ -34,6 +34,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.95",
+ "date": "2026-05-12",
+ "changes": [
+ "P-01 Rechtstexte: Abschnitte in der Ausgabe mit fortlaufender §1, §2, … (nur Darstellung/PDF, nicht in der DB); Fließtext mit Markdown (react-markdown) inkl. PDF-Rendering (fett/kursiv, Listen, Links, Codeblöcke).",
+ ],
+ },
{
"version": "0.8.94",
"date": "2026-05-11",
diff --git a/frontend/package.json b/frontend/package.json
index eb10a77..9ad335a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,9 +10,12 @@
"dependencies": {
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
+ "marked": "^18.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.22.0"
+ "react-markdown": "^10.1.0",
+ "react-router-dom": "^6.22.0",
+ "remark-breaks": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 4d1c5f8..1b9c9f1 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -291,6 +291,83 @@ ul > li.card + li.card,
margin-top: 2px;
}
+/* Rechtstexte (P-01): Markdown im Fließtext */
+.legal-doc-body {
+ font-size: 0.95rem;
+ line-height: 1.55;
+ color: var(--text1);
+}
+.legal-doc-body--muted {
+ color: var(--text3);
+ font-style: italic;
+}
+.legal-doc-body p {
+ margin: 0 0 0.65em;
+}
+.legal-doc-body p:last-child {
+ margin-bottom: 0;
+}
+.legal-doc-body ul,
+.legal-doc-body ol {
+ margin: 0.4em 0 0.65em 1.25rem;
+ padding: 0;
+}
+.legal-doc-body li {
+ margin: 0.2em 0;
+}
+.legal-doc-body strong {
+ font-weight: 700;
+}
+.legal-doc-body em {
+ font-style: italic;
+}
+.legal-doc-body code {
+ font-family: ui-monospace, monospace;
+ font-size: 0.88em;
+ background: var(--surface2);
+ padding: 0.1em 0.35em;
+ border-radius: 4px;
+}
+.legal-doc-body pre {
+ background: var(--surface2);
+ padding: 10px 12px;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin: 0.65em 0;
+ font-size: 0.85em;
+}
+.legal-doc-body pre code {
+ background: none;
+ padding: 0;
+}
+.legal-doc-body blockquote {
+ margin: 0.5em 0;
+ padding-left: 12px;
+ border-left: 3px solid var(--border2);
+ color: var(--text2);
+}
+.legal-doc-body a {
+ color: var(--accent);
+}
+.legal-doc-body h1,
+.legal-doc-body h2,
+.legal-doc-body h3,
+.legal-doc-body h4 {
+ font-size: 1em;
+ font-weight: 700;
+ margin: 0.75em 0 0.35em;
+}
+.legal-doc-body h1:first-child,
+.legal-doc-body h2:first-child,
+.legal-doc-body h3:first-child {
+ margin-top: 0;
+}
+.legal-doc-body hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 0.85em 0;
+}
+
.form-input {
width: 100%;
min-width: 0;
diff --git a/frontend/src/components/LegalDocumentBody.jsx b/frontend/src/components/LegalDocumentBody.jsx
new file mode 100644
index 0000000..8838d3d
--- /dev/null
+++ b/frontend/src/components/LegalDocumentBody.jsx
@@ -0,0 +1,25 @@
+import ReactMarkdown from 'react-markdown'
+import remarkBreaks from 'remark-breaks'
+
+/**
+ * Rechtstext-Absatz aus Markdown (ohne Roht-HTML; Links mit target=_blank).
+ */
+export default function LegalDocumentBody({ content, muted }) {
+ if (content == null || content === '') return null
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/AdminLegalDocumentsPage.jsx b/frontend/src/pages/AdminLegalDocumentsPage.jsx
index cb770e4..af6c928 100644
--- a/frontend/src/pages/AdminLegalDocumentsPage.jsx
+++ b/frontend/src/pages/AdminLegalDocumentsPage.jsx
@@ -1,90 +1,9 @@
import { useState, useEffect, useCallback } from 'react'
-import { jsPDF } from 'jspdf'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
import api from '../utils/api'
+import { generateLegalPdf } from '../utils/legalPdfExport'
-// ─── PDF generation ──────────────────────────────────────────────────────────
-
-function generateLegalPdf(doc) {
- const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
- const marginL = 22
- const marginR = 22
- const marginTop = 28
- const pageW = 210
- const contentW = pageW - marginL - marginR
- const bottomLimit = 277 // A4 297mm - 20mm bottom margin
- let y = marginTop
-
- const checkBreak = (need) => {
- if (y + need > bottomLimit) {
- pdf.addPage()
- y = marginTop
- }
- }
-
- // Title
- pdf.setFont('helvetica', 'bold')
- pdf.setFontSize(20)
- pdf.text(doc.title, marginL, y)
- y += 10
-
- // Meta line
- const STATUS_DE = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
- const dateStr = doc.published_at
- ? new Date(doc.published_at).toLocaleDateString('de-DE')
- : new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
- const metaLine = `Version ${doc.version} | ${STATUS_DE[doc.status] || doc.status} ${dateStr}`
-
- pdf.setFont('helvetica', 'normal')
- pdf.setFontSize(10)
- pdf.setTextColor(90, 90, 90)
- pdf.text(metaLine, marginL, y)
- y += 3
- pdf.setDrawColor(0, 0, 0)
- pdf.setLineWidth(0.4)
- pdf.line(marginL, y, pageW - marginR, y)
- y += 8
- pdf.setTextColor(0, 0, 0)
-
- // Sections
- for (const section of (doc.content_sections || [])) {
- checkBreak(14)
- pdf.setFont('helvetica', 'bold')
- pdf.setFontSize(11)
- pdf.text(section.heading || '', marginL, y)
- y += 6
-
- if (section.content) {
- pdf.setFont('helvetica', 'normal')
- pdf.setFontSize(10)
- const lines = pdf.splitTextToSize(section.content, contentW)
- for (const line of lines) {
- checkBreak(5)
- pdf.text(line, marginL, y)
- y += 5
- }
- }
- y += 5
- }
-
- // Footer on every page
- const total = pdf.getNumberOfPages()
- for (let i = 1; i <= total; i++) {
- pdf.setPage(i)
- pdf.setFont('helvetica', 'normal')
- pdf.setFontSize(8)
- pdf.setTextColor(150, 150, 150)
- const fy = 289
- pdf.text(
- `Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
- marginL, fy
- )
- pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
- pdf.setTextColor(0, 0, 0)
- }
-
- pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
-}
+const PDF_STATUS_META = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
// ─── Sub-components ──────────────────────────────────────────────────────────
@@ -191,7 +110,12 @@ function SectionEditor({ sections, onChange }) {
/>
-
Inhalt
+
+ Inhalt
+
+ Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://…), Zeilenumbruch mit Leerzeile.
+
+
-
-
- Inhalt
-
- Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://…), Zeilenumbruch mit Leerzeile.
+
+
+
+ Inhalt
+
+ Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://…), Zeilenumbruch mit Leerzeile.
+
+
+
+
@@ -135,7 +154,7 @@ function SectionEditor({ sections, onChange }) {
)
}
-function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit }) {
+function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit, onRenderedPreview }) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
@@ -183,6 +202,10 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
)}
+ onRenderedPreview(doc)} title="Gerenderte Vorschau">
+
+
onCopy(doc)} title="Als neuen Entwurf kopieren">
@@ -200,7 +223,7 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
)
}
-function EditForm({ docType, editDoc, onSaved, onCancel }) {
+function EditForm({ docType, editDoc, onSaved, onCancel, onShowRenderedPreview }) {
const [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
const [sections, setSections] = useState([])
const [changeNote, setChangeNote] = useState('')
@@ -266,10 +289,23 @@ function EditForm({ docType, editDoc, onSaved, onCancel }) {
onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung" />
-
+
{saving ? 'Speichern…' : 'Entwurf speichern'}
+
+ onShowRenderedPreview?.({
+ title,
+ sections,
+ metaLine: 'Aktueller Editorstand (nicht automatisch gespeichert)',
+ })}
+ style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}
+ >
+ Vollständige Vorschau
+
Abbrechen
@@ -329,6 +365,13 @@ export default function AdminLegalDocumentsPage() {
const [editDoc, setEditDoc] = useState(null)
const [auditDocId, setAuditDocId] = useState(null)
const [confirmPublish, setConfirmPublish] = useState(null)
+ const [legalPreview, setLegalPreview] = useState({
+ open: false,
+ loading: false,
+ title: '',
+ sections: [],
+ metaLine: '',
+ })
const activeDocType = DOC_TYPES.find(d => d.key === activeType)
@@ -376,6 +419,42 @@ export default function AdminLegalDocumentsPage() {
catch (e) { alert('Fehler: ' + e.message) }
}
+ const closeLegalPreview = () =>
+ setLegalPreview({ open: false, loading: false, title: '', sections: [], metaLine: '' })
+
+ const openLegalPreviewFromEditor = (payload) => {
+ setLegalPreview({
+ open: true,
+ loading: false,
+ title: payload.title,
+ sections: payload.sections,
+ metaLine: payload.metaLine || '',
+ })
+ }
+
+ const openLegalPreviewFromList = async (doc) => {
+ setLegalPreview({
+ open: true,
+ loading: true,
+ title: '',
+ sections: [],
+ metaLine: 'Laden…',
+ })
+ try {
+ const full = await api.getLegalDocument(doc.id)
+ setLegalPreview({
+ open: true,
+ loading: false,
+ title: full.title,
+ sections: full.content_sections || [],
+ metaLine: `Version ${full.version} · ${STATUS_LABELS[full.status]?.label || full.status}`,
+ })
+ } catch (e) {
+ closeLegalPreview()
+ alert('Fehler: ' + e.message)
+ }
+ }
+
const handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id)
const dateStr = full.published_at
@@ -473,6 +552,7 @@ export default function AdminLegalDocumentsPage() {
onCopy={handleCopy}
onDownload={handleDownload}
onViewAudit={handleViewAudit}
+ onRenderedPreview={openLegalPreviewFromList}
/>
))
)}
@@ -483,9 +563,19 @@ export default function AdminLegalDocumentsPage() {
editDoc={editDoc}
onSaved={handleSaved}
onCancel={() => { setShowForm(false); setEditDoc(null) }}
+ onShowRenderedPreview={openLegalPreviewFromEditor}
/>
)}
+
+
{auditDocId && (
setAuditDocId(null)} />
)}
--
2.43.0
From 59d53d615430b88613f7da209356262485be9e90 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 21:20:36 +0200
Subject: [PATCH 03/27] docs: update project status and add user overview
documentation
- Updated project status to version 0.8.96 as of 2026-05-12, reflecting recent enhancements and features.
- Added a new section for the user overview in `docs/FACHLICHE_NUTZERFUNKTIONEN.md`, providing a compact perspective for design and product teams.
- Revised references in various documents to include the new user overview and updated project status.
- Enhanced the requirements documentation to link to the user overview for better clarity.
Co-Authored-By: Claude Sonnet 4.6
---
.claude/docs/PROJECT_STATUS.md | 29 +--
.claude/docs/functional/DOMAIN_MODEL.md | 4 +-
.../docs/functional/SHINKAN_REQUIREMENTS.md | 7 +-
.../library/FEATURES_DELIVERED_2026-Q2.md | 5 +-
..._MODULES_AND_COMBINATION_EXERCISES_SPEC.md | 197 ++++++++++++++++++
CLAUDE.md | 3 +-
docs/FACHLICHE_NUTZERFUNKTIONEN.md | 128 ++++++++++++
docs/HANDOVER.md | 30 ++-
8 files changed, 366 insertions(+), 37 deletions(-)
create mode 100644 .claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
create mode 100644 docs/FACHLICHE_NUTZERFUNKTIONEN.md
diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md
index bf53926..493ad03 100644
--- a/.claude/docs/PROJECT_STATUS.md
+++ b/.claude/docs/PROJECT_STATUS.md
@@ -1,21 +1,25 @@
# Shinkan Jinkendo - Projekt-Status
-**Stand:** 2026-05-08
-**Version (Code):** 0.8.64 (`backend/version.py`, APP_VERSION)
-**DB-Schema-Version:** `20260508049` (`backend/version.py`, DB_SCHEMA_VERSION)
+**Stand:** 2026-05-12
+**Version (Code):** 0.8.96 (`backend/version.py`, APP_VERSION)
+**DB-Schema-Version:** `20260511053` (`backend/version.py`, DB_SCHEMA_VERSION)
**Branch:** develop
---
## Executive Summary
-**Aktueller Meilenstein (Medien):** Das **Medien-Archiv** (`media_assets` + `exercise_media.media_asset_id`) ist **produktiv nutzbar**: zentrale Bibliothek **`/media`** (Kacheln/Liste, Filter inkl. Lifecycle, Suche/Tags, Copyright, Bulk-Lifecycle und Bulk-PATCH), **Verknüpfung aus dem Archiv** in der Übungsbearbeitung (`POST …/media/from-asset`), **deduplizierter Speicher** unter **`library/…`** (Vereinsordner aus Name, Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeitsänderung), **Papierkorb & Lifecycle** (Reaktivierung, Soft-Trash, Superadmin-Purge), plus **Inline-Medien im Rich-Text** (Modal-Picker, Größenwahl, Drag&Drop mit Auto-Scroll, Vorschau-/Rückweg-UX). **Governance:** Sichtbarkeit **`official`** nur noch **Superadmin** (Übungen und Medien); Plattform-Admin wie Trainer für Vereins-/Private-Inhalte. **Vereinsübungen** mit Datei-Assets: **Copyright-Pflicht** (API/UI). **Aktiver Verein:** Dropdown, Profilfeld `active_club_id`, Header `X-Active-Club-Id` und `effective_club_id` sind nach **0.8.59** synchronisiert (inkl. Plattform-Admin ohne Header beim ersten Request).
+**Aktueller Meilenstein (Medien):** Das **Medien-Archiv** (`media_assets` + `exercise_media.media_asset_id`) ist **produktiv nutzbar**: zentrale Bibliothek **`/media`** (Kacheln/Liste, Filter inkl. Lifecycle, Suche/Tags, Copyright, Bulk-Lifecycle und Bulk-PATCH), **Verknüpfung aus dem Archiv** in der Übungsbearbeitung (`POST …/media/from-asset`), **deduplizierter Speicher** unter **`library/…`**, **Papierkorb & Lifecycle**, plus **Inline-Medien im Rich-Text** (Modal-Picker, Größenwahl, Drag-and-Drop mit Auto-Scroll). **Governance:** Sichtbarkeit **`official`** nur **Superadmin** (Übungen und Medien). **Vereinsübungen** mit Datei-Assets: **Copyright-Pflicht** (API/UI). **Aktiver Verein:** Dropdown, Profilfeld `active_club_id`, Header `X-Active-Club-Id` und `effective_club_id` sind nach **0.8.59** synchronisiert.
+
+**Melde- und Transparenzpfad (P-13, seit 0.8.87 ff.):** **Inhaltsmeldungen** mit Workflow im Posteingang, Club-Admin-Beteiligung für Vereinsmedien, Legal-Hold-Anbindung, Badges in der Medienbibliothek; Folgepakete P-14–P-16 bewusst offen (siehe `docs/HANDOVER.md`).
+
+**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
-**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) §12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **§11 Inline-Medien**, umgesetzt)
+**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
-**Nächste Schritte — Medien & Archiv** (Stand 2026-05-08, für **neue Session**):
+**Nächste Schritte — Medien & Archiv** (Stand 2026-05-12, für **neue Session**):
1. ~~**Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2)~~ — umgesetzt (0.8.47).
2. ~~**Eigenständige Medienmanager-Seite**~~ — **Basis umgesetzt** (`/media`); Ausbau nach Bedarf: Quotas, feinere Bulk-Workflows, Sichtbarkeits-PATCH in der UI vereinheitlichen.
@@ -23,7 +27,7 @@
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext (Spec §11)** — **Basis umgesetzt (0.8.60–0.8.64)**: Platzhalter-Syntax, zentraler Renderer, Modal-Picker, Drag&Drop + Auto-Scroll; offen: weitere UX-Politur und ggf. strategischer Umbau auf reine Asset-Referenz (separat zu entscheiden).
-**Inline:** verbindliche Leitplanken **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; Umsetzung aktiv im Produktpfad (RTE + Anzeige).
+**Inline:** verbindliche Leitplanken **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** Abschnitt 11; Umsetzung aktiv im Produktpfad (RTE + Anzeige).
---
@@ -150,17 +154,18 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
| Dokument | Pfad | Stand | Status |
|----------|------|-------|--------|
-| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-08 | ✅ Aktualisiert (§12 Medien inkl. Inline 0.8.60–0.8.64) |
+| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-12 | neu, Ist-Überblick |
+| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-12 | Verweis Version siehe `version.py` |
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
-| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-04-27 | ✅ Neu |
+| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-05-12 | Verweis Nutzerüberblick |
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040–046 Medien (Kurz) |
-| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-07 | ✅ Abschnitt Medien-Archiv |
+| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-12 | Version 0.4.5, Verweis Nutzerüberblick |
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-05-08 | ✅ Medien/Inline-Workflow ergänzt |
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-05-07 | ✅ Verweis Archiv/Inline |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-08 | ✅ Ist-Changelog + §11 Inline erweitert |
-| Projektstatus | `PROJECT_STATUS.md` | 2026-05-08 | ✅ auf 0.8.64 aktualisiert |
+| Projektstatus | `PROJECT_STATUS.md` | 2026-05-12 | auf 0.8.96 + P-13/P-01 + Nutzerüberblick |
---
@@ -171,4 +176,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
---
-**Letzte Aktualisierung:** 2026-05-08 (Inline-/Medien-Workflow 0.8.60–0.8.64 konsolidiert)
+**Letzte Aktualisierung:** 2026-05-12 (Version 0.8.96, Executive Summary P-13/P-01, `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md
index 0c19ec9..8de4ca0 100644
--- a/.claude/docs/functional/DOMAIN_MODEL.md
+++ b/.claude/docs/functional/DOMAIN_MODEL.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo - Fachliches Domänenmodell
-**Version:** 0.4.4
-**Stand:** 2026-05-08 (Medien-Archiv **`media_assets`** / Bibliothek **`/media`** im Ist; **Inline-Medien** im Fließtext umgesetzt — `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11)
+**Version:** 0.4.5
+**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
---
diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
index 01d05b0..d36098b 100644
--- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
+++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
@@ -4,11 +4,12 @@ Ausführliche fachliche Inhalte:
| Dokument | Inhalt |
|----------|--------|
+| [**Fachliche Nutzerfunktionen (Ist, Überblick)**](../../../docs/FACHLICHE_NUTZERFUNKTIONEN.md) | Kompakte **Nutzer-/Rollen-Perspektive** zur Übergabe an Design & Product (ohne Implementierungsdetail) |
| [shinkan_anforderungsdokument_entwurf.md](./shinkan_anforderungsdokument_entwurf.md) | Gesamtentwurf Anforderungen |
-| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (§11.2), **Medien-Archiv** (Abschnitt 2026-05) |
-| [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) | Medien-Archiv, Lifecycle, **geplante Inline-Medien §11** |
+| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2), **Medien-Archiv** (Abschnitt 2026-05) |
+| [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) | Medien-Archiv, Lifecycle, **Inline-Medien** (Spec Abschnitt 11, umgesetzt) |
| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan |
-**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) §12, Repo-Root **`docs/HANDOVER.md`**.
+**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
`CLAUDE.md` (Repo-Root) verweist hierher als Einstieg.
diff --git a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md
index 118bbff..e19ad1b 100644
--- a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md
+++ b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md
@@ -1,7 +1,7 @@
# Gelieferte Features & technische Basis (Q2 2026)
-**Stand:** 2026-05-08
-**Referenz:** `backend/version.py` — **APP_VERSION 0.8.64**, **DB_SCHEMA_VERSION** siehe dort
+**Stand:** 2026-05-12
+**Referenz:** `backend/version.py` — aktuelle **APP_VERSION** / **DB_SCHEMA_VERSION** (Stand Code u. a. **0.8.96**)
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. **Medien-Archiv & Bibliothek:** Abschnitt **12** unten + **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
@@ -170,4 +170,5 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
+| Fachlicher Nutzerüberblick | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` (Repo-Root) |
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |
diff --git a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
new file mode 100644
index 0000000..6035e09
--- /dev/null
+++ b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
@@ -0,0 +1,197 @@
+# Trainingsmodule und Kombinationsübungen — Spezifikation (Entwurf)
+
+**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12
+**Zweck:** Rahmen für Umsetzung, Integration in Planung/Rahmenprogramm und Durchführung im assistierten Training (Coaching-Modus). Dieses Dokument ist **nicht** implementierungsbindend, bis die markierten **offenen Entscheidungen** geschlossen und der Status angehoben wurde.
+
+**Verwandte Dokumente:**
+
+| Dokument | Bezug |
+|----------|--------|
+| `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) |
+| `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items |
+| `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) |
+| `EXERCISES_*` (Katalog) | Einzelübungen, Varianten |
+| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Sichtbarkeit, Mandant, Rollen bei neuen Bibliotheks-Entitäten |
+
+---
+
+## 1. Zielbild und Abgrenzung
+
+### 1.1 Problem
+
+Die Trainingsplanung unterstützt Einheiten mit Sektionen und einzelnen Übungen (inkl. Notizen) sowie Rahmenprogramme mit Blueprint-Einheiten pro Slot. Es fehlen:
+
+- **Wiederverwendbare Übungsfolgen** („Trainingsmodule“), die sich wie Bausteine in eine Einheit einfügen lassen (ganze Sektion oder Block innerhalb einer Sektion), inkl. kopierbasierter Integration analog zum Rahmen.
+- **Strukturierte Kombinationsformen** (z. B. Zirkel mit Stationstausch, Parcour), bei denen **mehrere Einzelübungen** Slots oder Rollen einnehmen und die **Trainingsmethode** den Ablauf (Rotation, parallele Stationen, Zeitmodell) bestimmt.
+- Ein durchgängiges Konzept für den **Coaching- bzw. Assistenzmodus**, in dem derselbe Plan je nach Archetyp **unterschiedlich gesteuert** wird (Beispiel Zirkel: Erklärphase vs. parallele Nutzung aller Stationen).
+
+### 1.2 Nicht-Ziele (für erste Ausbaustufe)
+
+- **Individuelles Athleten-Tracking** oder Leistungsmessung pro Person (außerhalb Shinkan-MVP, siehe Produktabgrenzung).
+- Automatische **Synchronisation** zwischen Bibliotheksexemplar und bereits geplanten historischen Einheiten (bewusst: **Kopie** statt Live-Spiegel, konsistent mit Rahmen-Konzept).
+
+### 1.3 Zwei Bausteine (fachliche Trennung)
+
+| Baustein | Kurzname | Einordnung | Kurzbeschreibung |
+|----------|-----------|------------|------------------|
+| **Typ 1** | **Kombinationsübung** | Übungskatalog (Sonderform einer **Übung**) | Eine logische Übung mit **1–n Slots**; Slots können einzelne Übungen oder **Pools** auswählbarer Übungen tragen; **Methodenprofil / Archetyp** steuert später den Durchlauf. |
+| **Typ 2** | **Trainingsmodul** | Planung / Bibliothek | Gespeicherte, wiederverwendbare **Sequenz** von Elementen (Einzelübungen, optional Kombinationsübungen, Notizen); Einbindung per **Kopie** in konkrete `training_units` oder in Rahmen-Slot-Blueprints. |
+
+**Abgrenzung Rahmenprogramm:** Das Rahmenprogramm strukturiert **mehrere Einheiten** (Slots) auf Programm-Ebene. Ein Trainingsmodul strukturiert typischerweise **Inhalt einer Einheit** oder eines Teils davon, nicht den Wochen-/Periodenrahmen.
+
+---
+
+## 2. Begriffe
+
+| Begriff | Definition |
+|---------|------------|
+| **Bibliotheksexemplar** | Gespeicherte Vorlage (Modul oder Kombinationsübung-Definition) mit Governance (z. B. global, Verein, privat). |
+| **Instanz in der Planung** | In `training_unit_section_items` (und ggf. ergänzende Tabellen) materialisierter Ablauf für einen **konkreten Termin** bzw. eine **geplante Einheit**. |
+| **Slot (Typ 1)** | Position innerhalb einer Kombinationsübung; kann genau eine gewählte Übung oder einen **Pool** (mehrere Kandidaten) referenzieren. |
+| **Methodenprofil / Archetyp** | Maschinenlesbare Semantik **wie** trainiert wird (Zeit, Rotation, Parallelität), ergänzend zum bestehenden Katalog `training_methods` (Beschreibung **was** für eine Didaktik/Kondition gilt). |
+| **Coaching-Modus** | UI- und Zustandslogik zur Durchführung einer geplanten Einheit (Timer, Phasen, Stationen). |
+
+---
+
+## 3. Trainingsmethoden und Archetypen (Typ 1)
+
+### 3.1 Bestehende Basis
+
+Der Katalog `training_methods` (Migration 003) enthält u. a. **Zirkeltraining** (`category` u. a. `zirkel`, `kondition`). Er beschreibt die Methode **inhaltlich**, nicht aber Parameter wie Wechselintervalle oder parallele vs. rotierende Nutzung.
+
+### 3.2 Erweiterung: Archetyp
+
+Jede Kombinationsübung (und optional der Methodendatensatz als Default) erhält ein Feld **`method_archetype`** (Enum/Wertliste). Der Archetyp legt fest, welche **Parameter** am Methodenprofil relevant sind und wie der **Coaching-Modus** den Ablauf interpretiert.
+
+**Vorschlagsliste (erweiterbar, zu verbindlich machen):**
+
+| Archetyp-ID (Vorschlag) | Beschreibung Planungslogik | Coaching (Intent) |
+|-------------------------|----------------------------|---------------------|
+| `circuit_rotate_time` | n Stationen; Wechsel nach Ablaufzeit, optional globale Rundenanzahl | Rotierender oder gemeinsamer Takt; Umlauf zur nächsten Station |
+| `circuit_all_parallel` | n Stationen; **kein** Umlauf als fachlicher Kern, alle Stationen gleichzeitig aktiv | Erklärphase (alle Inhalte vorher), dann **parallel** alle Stationen |
+| `sequence_linear` | feste Reihenfolge; Aufbau, keine Kreisrotation | Schrittliste / Timer optional pro Schritt |
+| `station_parcour` | Stationsbezogener Pfad, Reihenfolge kann variieren | Navigation / Abhaken eher als ein globaler Umlauf-Takt |
+| `pair_superset` | zwei (oder wenige) Blöcke im Wechsel | Partnerlogik, gekoppelte Timer |
+| `time_domain_interval` | AMRAP/EMOM-ähnliche Zeitdomäne | Globale Uhr, Runden-/Intervallzähler |
+
+### 3.3 Parameter des Methodenprofils
+
+Zu präzisieren (JSON-Dokument vs. normalisierte Spalten):
+
+- Zeit: `work_seconds`, `rest_seconds`, `transition_seconds`, `rounds`
+- Organisation: `station_count`, `rotation_direction`, Flags `explain_all_before_start`, `stations_operate_simultaneously`
+- ggf. `intensity_profile` (skalar oder Enum), nur wenn für MVP nötig
+
+**Offen:** Welche Parameter sind **Pflicht pro Archetyp** (Validierung).
+
+---
+
+## 4. Datenmodell (Zielarchitektur, Entwurf)
+
+### 4.1 Typ 2 — Trainingsmodule
+
+**Entwurfstabellen (Namen können bei Implementierung angeglichen werden):**
+
+- `training_modules` — Kopf: Titel, Beschreibung, Metadaten, `visibility`, `club_id`, `created_by`, Timestamps
+- optional `training_module_sections` — falls ein Modul mehrere semantische Blöcke abbilden soll
+- `training_module_items` — Reihenfolge, Verweis auf:
+ - Einzelübung (`exercise_id`, `exercise_variant_id`)
+ - Kombinationsübung (`combination_exercise_id` / `exercise_id` mit `kind=combination`)
+ - Freitext-Notiz (analog `note` bei Einheiten)
+
+Semantik: **Bibliotheksbaum**, keine Bindung an Kalender oder Gruppe.
+
+### 4.2 Typ 1 — Kombinationsübungen
+
+**Option A (Embedding in `exercises`):** Spalte `exercise_kind` = `simple` | `combination` und Kindtabellen für Slots/Pools.
+
+**Option B (Separate Kopf-Tabelle):** 1:1-Beziehung zwischen `exercises` und `combination_exercises`.
+
+**Slot-Pools:** mindestens M:N **Pool-Kandidat** pro Slot; die **konkret geplante Auswahl** gehört zur **Instanz** (geplante Einheit), nicht zwingend zum Bibliotheksexemplar.
+
+### 4.3 Integration in geplante Einheiten
+
+Heute: `training_unit_section_items` mit `item_type` in (`exercise`, `note`).
+
+**Erweiterungsoptionen (Entscheidung offen):**
+
+1. **Expansion beim Einfügen:** Modul wird in Items „aufgeklappt“; optional `source_module_id` an Items für Herkunft (Lineage-Light).
+2. **Block-Item:** neuer `item_type` `module_reference` oder `combination` mit ID und eingebetteter Bearbeitungssemantik (komplexer, aber „Modul als Einheit“ editierbar).
+
+Empfehlung zur Abstimmung: MVP oft mit **Expansion** + optionaler Markierung; später Block-Knoten.
+
+**Rahmenprogramm:** Blueprint-`training_units` pro Slot nutzen dieselbe Sektions-/Item-Struktur — Module müssen **dort** ebenfalls einfügbar sein, wenn Rahmen und konkrete Planung konsistent bleiben sollen.
+
+---
+
+## 5. API (Skizze)
+
+Verbindliche Pfade und Payloads folgen nach Freigabe dieses Dokuments.
+
+| Richtung | Beispielpfad / Funktion | Zweck |
+|----------|-------------------------|--------|
+| CRUD | `GET/POST/PUT/DELETE …/training-modules` | Bibliothek Trainingsmodule |
+| Anwendung | `POST …/training-units/{id}/apply-module` | Modulinhalt in Sektion kopieren (tiefe Kopie) |
+| Übungen | Erweiterung `GET/POST/PUT …/exercises` oder Unterressource `…/exercises/{id}/combination` | Kombinationsübung inkl. Slots |
+| optional | `POST …/training-units/from-module` | Neue Einheit aus Modul (falls produktrelevant) |
+
+**AuthZ:** analog andere Bibliotheks- und Planungsobjekte; Abgleich mit `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` und Endpoint-Audit.
+
+---
+
+## 6. Frontend
+
+- **Bibliothek:** Verwaltung Trainingsmodule (Liste, Editor, Sortierung, Vorschau).
+- **Übungsbereich:** Editor für Kombinationsübungen (Slots, Pools, Methodenprofil/Archetyp).
+- **Planungs-UI:** Aktion „Modul einfügen“, Ziel-Sektion und Position; Hinweis **Kopie** und Editierbarkeit pro Termin.
+
+---
+
+## 7. Coaching- / Assistenzmodus (Durchlauf)
+
+### 7.1 Phasenmodell (konzeptionell)
+
+- **Briefing / Erklärung:** insbesondere für `circuit_all_parallel` und Varianten mit `explain_all_before_start`
+- **Arbeitsphase(n):** timer- und stationsgetrieben
+- **Übergänge:** Pausen, Wechsel, Rundenzähler
+
+### 7.2 Persistenz während Durchführung
+
+**Offen:** Ob ein **`training_session_run`** (Snapshot der aufgelösten Einheit zum Startzeitpunkt) für Nachvollziehbarkeit und Offline-Fähigkeit nötig ist.
+
+### 7.3 Ausbaustufen
+
+1. Read-only **Durchführungsansicht** (Archetyp + Zeiten, keine komplexe State Machine)
+2. **Aktiver Modus** mit State Machine und Archetyp-spezifischer UI
+3. Optional: Offline/PWA-Verhalten
+
+---
+
+## 8. Umsetzungsphasen (Vorschlag)
+
+| Phase | Inhalt |
+|-------|--------|
+| **A** | Dieses Dokument verbindlich machen; Archetypen und Parameter final; Governance-Regeln |
+| **B** | Typ 2: `training_modules` + API + „Modul in Einheit einfügen“ (Expansion) |
+| **C** | Typ 1: Kombinationsübung im Katalog + Slots/Pools + Methodenprofil |
+| **D** | Einbindung in Rahmen-Slot-Blueprints (Editor-Flow) |
+| **E** | Coaching-Modus gemäß Archetyp |
+
+---
+
+## 9. Offene Entscheidungen (Checkliste)
+
+- [ ] Modul-Einfügung: nur **Expansion** vs. **Block-Knoten** vs. beides
+- [ ] Normalisierung vs. JSON für **Methodenprofil-Parameter**
+- [ ] Globale vs. vereinsbezogene vs. private **Trainingsmodule** (Governance-Matrix)
+- [ ] Pflichtbinding: muss jede Kombinationsübung einen **Default-Archetyp** aus `training_methods` erben dürfen?
+- [ ] Coaching: Mindestumfang MVP (nur Ansicht vs. interaktive Timer)
+- [ ] Verweise in `DOMAIN_MODEL.md` und `DATABASE_SCHEMA.md` nach Implementierung pflegen
+
+---
+
+## 10. Changelog
+
+| Datum | Änderung |
+|-------|----------|
+| 2026-05-12 | Erstversion (Entwurf) angelegt |
diff --git a/CLAUDE.md b/CLAUDE.md
index 6e926ba..f48905a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,6 +13,7 @@
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
+> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
## Projekt-Übersicht
@@ -83,7 +84,7 @@ frontend/src/
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
-Kurz (Stand 2026-05-08): App **0.8.64**, DB‑Schema‑Version siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, **Inline-Medien im Rich-Text** (Modal-Picker, Größenwahl, Drag&Drop + Auto-Scroll), Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (§11 umgesetzt).
+Kurz (Stand 2026-05-12): App **0.8.96**, DB‑Schema‑Version siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, **Inline-Medien im Rich-Text**, **Inhaltsmeldungen (P-13)** im Posteingang, Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, Nutzerüberblick **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (Abschnitt 11 umgesetzt).
### Log (Auszug)
diff --git a/docs/FACHLICHE_NUTZERFUNKTIONEN.md b/docs/FACHLICHE_NUTZERFUNKTIONEN.md
new file mode 100644
index 0000000..4f9ddc3
--- /dev/null
+++ b/docs/FACHLICHE_NUTZERFUNKTIONEN.md
@@ -0,0 +1,128 @@
+# Shinkan Jinkendo – Fachliche Nutzerfunktionen (Ist-Stand)
+
+**Zweck:** Überblick über die **wesentlichen, produktiv nutzbaren Funktionen** aus Nutzer- und Fachperspektive – zur Weitergabe an Design, Product Discovery oder externe Fachplanung.
+
+**Technischer Detailstand:** App-Version und Schema siehe `backend/version.py` (Stand Code: **0.8.96**, **DB_SCHEMA_VERSION** siehe dort).
+
+**Vertiefung:** Domänenmodell `.claude/docs/functional/DOMAIN_MODEL.md`, Lieferdetal `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md`, Projektstatus `.claude/docs/PROJECT_STATUS.md`, Entwickler-Handover `docs/HANDOVER.md`.
+
+---
+
+## 1. Produktauftrag und Zielgruppe
+
+**Shinkan Jinkendo** ist eine **Web-Applikation für Trainer, Vereinsadmins und Inhaltsverantwortliche** in der Kampfsport- und Trainingsplanung: zentrale Übungs- und Methodenverwaltung, strukturierte **Trainingsplanung für Gruppen**, wiederverwendbare **Rahmenprogramme**, sowie **Governance** von Inhalten (Sichtbarkeit, Vereinszuordnung, Plattform-Inhalte).
+
+**Explizit keine persönliche Sportler-App:** Es geht nicht um individuelles Leistungstracking von Endnutzern oder um ein Athleten-Tagebuch; der Fokus liegt auf **vereinlicher/trainersicher Organisation von Wissen und Ablaufplänen**.
+
+---
+
+## 2. Rollen (vereinfachte Nutzerbilder)
+
+Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter Nutzer, aktiver Verein, Plattform- vs. Vereins-Admin).
+
+| Rollenprofil (fachlich) | Typische Aufgaben in der App |
+|-------------------------|------------------------------|
+| **Trainer / Redakteur** | Übungen anlegen und pflegen, medienreich beschreiben, filtern/suchen; Trainingseinheiten für Gruppen planen; Rahmenprogramme nutzen oder mitgestalten (je nach Berechtigung); Medienbibliothek nutzen. |
+| **Vereinsadmin** | Vereinsdaten, Mitgliedschaften, ggf. vereinsgebundene Inhalte und Medien; kann je nach Implementierung **Inhaltsmeldungen zu Vereinsmedien** bearbeiten und **Legal Hold** für Vereinsmedien auslösen. |
+| **Plattform-Admin** | Globale Kataloge, Hierarchien, Importe, Nutzerverwaltung (soweit freigeschaltet); **Posteingang** inkl. organisationsbezogener Meldungen; Reifegradmodelle / Matrix-Stack. |
+| **Superadmin** | Stärkste technische Rolle: u. a. **offizielle Plattform-Inhalte** (`official`), tiefe Medien-Lifecycle-Operationen, ausgewählte Hochrisiko-Aktionen (z. B. bestimmte Legal-Hold-Fälle). |
+
+**Aktiver Verein:** Nutzer mit Vereinsbezug arbeiten oft im Kontext eines **gewählten aktiven Vereins** (Profil, API-Header); das beeinflusst Sichtbarkeit von Inhalten und Mandantenlogik.
+
+---
+
+## 3. Hauptnavigation (Nutzerpfade)
+
+Über die **Hauptnavigation** (mobil und Desktop) sind u. a. erreichbar:
+
+- **Übersicht** – Einstieg / Dashboard.
+- **Posteingang** – für berechtigte Nutzer: **Änderungs- und Organisationsanfragen** sowie **Inhaltsmeldungen** (Workflow, Status, Archiv).
+- **Übungen** – Katalogarbeit, Suche, Filter, Detail, Bearbeitung; **Progressionsgraphen** zwischen Übungen; **Fähigkeiten** (Skills) als verknüpfte Dimension.
+- **Planung** – Kalender-/Listenlogik für **Trainingseinheiten** (Sektionen, Übungen, optional **Übungsvarianten**); **Trainingsrahmen (Bibliothek)** mit Zielen und Slots; **Durchführungsansicht** und **Coaching-Modus** pro Einheit (je Route).
+- **Medien** – zentrale **Medienbibliothek** (Filter, Suche, Tags, Lifecycle, Copyright-Hinweise; rollenabhängige Bearbeitung).
+- **Vereine** – Organisation: Vereine, Struktur, Gruppen (soweit für den Nutzer freigeschaltet).
+- **Einstellungen** – Profil, Systeminfos, ggf. Rechtstexte; **Trainer-Kontexte** separat (Route `trainer-contexts`).
+- **Admin** (nur Admin-Rolle) – Plattformbereich: Nutzer, Hierarchie/Kataloge, Reifegradmodelle, MediaWiki-Import, **Rechtstexte/P-01** u. a.
+
+Öffentlich bzw. ohne volle App: **Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie**; Login/Registrierung/Verifizierung.
+
+---
+
+## 4. Funktionsblöcke im Detail (fachlich)
+
+### 4.1 Übungen (Kernobjekt)
+
+- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Sichtbarkeit.
+- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen**; Suche und Filter über diese Dimensionen.
+- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**.
+- **Progressionsgraph:** gerichtete Beziehungen **von Übung zu Übung** (und Variantenbezug), Pflege in der Übungswelt; unterstützt didaktische „weiter“-Ketten.
+- **Medien an der Übung:** Upload, Einbettung, Verknüpfung aus dem **Archiv**; Darstellung in Detail- und Bearbeitungsansicht.
+- **Rich-Text-Felder** (Ablauf, Ziele, Hinweise): **Inline-Verweise auf verknüpfte Medien** über eine einheitliche Platzhalter-/Renderlogik (konsistent mit Archiv-Governance).
+- **Exercise Blocks** („Bausteine“) und gespeicherte Suchpräferenzen, wo implementiert.
+
+### 4.2 Fähigkeiten, Methoden, Kataloge
+
+- **Globaler Fähigkeitskatalog** mit hierarchischer Struktur (Kategorien, Stufen); Zuordnung zu Übungen.
+- **Trainingsmethoden-Katalog** (bestehende Domäne).
+- **Admin/Katalog-Pflege** für Fokusbereiche, Stile, Zielgruppen und Zusammenhänge (Plattform-Admin-Bereich).
+
+### 4.3 Reifegradmodelle (Fähigkeitsmatrix)
+
+- **Matrixbasierte Modelle** mit Stufen und Zelltexten; **kontextsensitive Auflösung** (Fokus, optional Stilrichtung, Trainingsart) über Bindings.
+- **Export/Import** einzelner Modelle und **Komplett-Stack** (Admin-Werkzeuge) für Übertrag zwischen Umgebungen oder Backup.
+
+### 4.4 Trainingsplanung
+
+- **Trainingseinheiten** als planbare Objekte mit **Sektionen** und **Einträgen** (Übungen, ggf. mit **Variante** und Metadaten wie Dauer).
+- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden.
+- **Trainingsrahmenprogramm (Bibliothek):** übergeordnete Programme mit **Zielen** und **Slots**; Slot-Inhalt technisch als **Blueprint-Trainingsunit** abgebildet.
+- **Materialisierung:** aus einem Rahmen-Slot kann eine **konkrete Kalender-Einheit** für eine Gruppe erzeugt werden (API vorhanden; UI-Anbindung kann erweitert werden).
+- **Durchführung:** Ansicht zum Abarbeiten einer Einheit; **Coaching-Modus** als separater Erlebnispfad.
+
+### 4.5 Medienbibliothek und Archiv
+
+- **Zentrale Medienverwaltung:** Suche, Filter (u. a. Lifecycle, Medientyp, Verein für Admins), Tags, Copyright-Felder.
+- **Lifecycle:** aktive Nutzung, Papierkorb-Stufen, Wiederherstellung; endgültiges Entfernen stark eingeschränkt (Superadmin-Kontext).
+- **Governance:** Sichtbarkeit (z. B. privat, vereinsbezogen, **official**); **official** ist fachlich „Plattform offiziell“ und an **Superadmin** gebunden.
+- **Rechtliche Sofortmaßnahme:** **Legal Hold** kann Medien vor automatisiertem Lifecycle schützen (Fälle aus Meldungen oder Admin-Prozessen).
+
+### 4.6 Organisation und Mitgliedschaft
+
+- **Vereine (Clubs)** mit Struktur (Sparten/Divisions, Trainingsgruppen) je nach Ausprägung.
+- **Beitritts- und Mitgliedschaftslogik** (Requests, Rollen) für mandantenfähige Zusammenarbeit.
+
+### 4.7 Governance von Übungsinhalten
+
+- **Änderungsanfragen** (Content Change Requests) für vorgeschlagene Änderungen an Inhalten – Einreichung und Bearbeitung über Posteingang/Admin-Prozesse (Detailtiefe siehe Fachdoku).
+- **Sichtbarkeits- und Statusmodelle** für Übungen (Entwurf, veröffentlicht, archiviert – konkrete Werte siehe Datenmodell).
+
+### 4.8 Inhaltsmeldungen (P-13, vertrauens- und compliance-orientiert)
+
+- **Melden** von problematischen Inhalten (auch aus Medien- und Übungskontexten; **official**-Medien teils ohne Login meldbar).
+- **Posteingang für Admins:** Eingang neuer Meldungen, **Statusworkflow** (z. B. eingereicht, in Prüfung, erledigt/abgelehnt), Notizen, **Wiedereröffnen**; getrennte Darstellung abgeschlossener Fälle (Archiv).
+- **Priorisierung** bei sensiblen Kategorien (Minderjährige, illegaler Inhalt, Jugendschutz – fachlich automatisch höher gewichtet).
+- Anbindung an **Legal Hold** und Audit-Spuren im Medien-Journal wo vorgesehen.
+
+### 4.9 Import und Plattform-Werkzeuge
+
+- **MediaWiki-basierter Import** von Übungsinhalten mit Tracking und Duplikat-Referenzen (Admin).
+- **Plattformnutzerverwaltung** und **Rechtstexte** mit editorseitiger und listenbasierter Vorschau (Markdown, strukturierte Ausgabe inkl. PDF-Darstellung – siehe technische Versionsnotizen).
+
+---
+
+## 5. Bekannte Lücken und Planungshinweise (kurz)
+
+Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen** für Product/Design:
+
+- Kalender-UX: **„Aus Rahmen übernehmen“** flächendeckend und ggf. bulkfähig anbinden.
+- **Policies** für geteilte Rahmen (Wer darf Bibliotheks-Rahmen sehen/kopieren?).
+- **Skill-Kategorie-Admin-UI**, **Dark Mode/Responsive/PWA-Ausbau**, **KI-Suche** über Volltext hinaus – je nach Backlog.
+- **Moderations-Fläche**, Uploader-Benachrichtigung bei Sperre, **Beschwerdeverfahren** – laut Handover bewusst noch nicht umgesetzt (Nachfolge von P-13).
+
+---
+
+## 6. Änderungshistorie dieser Zusammenfassung
+
+| Datum | Änderung |
+|-------|----------|
+| 2026-05-12 | Erstfassung für Übergabe an fachliches Design; Abgleich mit Code-Navigation, `version.py`, `HANDOVER.md`, `FEATURES_DELIVERED`, `DOMAIN_MODEL`. |
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 1acde31..d6b1303 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
-**Stand:** 2026-05-11
-**App-Version / DB-Schema:** App **0.8.94**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
+**Stand:** 2026-05-12
+**App-Version / DB-Schema:** App **0.8.96**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@@ -32,6 +32,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
+| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
---
@@ -105,21 +106,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
-## 5. Geplant: Inline-Medienverlinkung (nicht umgesetzt)
+## 5. Inline-Medien im Fließtext (Spec Abschnitt 11 — umgesetzt)
-**Ziel:** Mediendarstellung **innerhalb** von Fließtext-Feldern (Ablauf, Ziele, Trainerhinweise), konsistent mit derselben **`exercise_media`‑** bzw. Asset-Governance wie die Medienliste.
+**Ist:** Platzhalter-Syntax, zentraler Render-Pfad, Modal-Picker, Größenwahl, Drag-and-Drop in den Übungstextfeldern — siehe **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (Abschnitt 11), **`FEATURES_DELIVERED_2026-Q2.md`** Abschnitt 12.3 und **`PROJECT_STATUS.md`**.
-**Norm:** **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** — u. a.:
-
-- Verweis auf **`exercise_media.id`** (oder kanonisch übersetzte Markup-Syntax), **keine** zweite Sichtbarkeitslogik.
-- **Ein** zentraler Render-/Sanitize-Pfad für Übungstexte; keine verstreuten „roh `dangerouslySetInnerHTML`“-Pfade.
-- XSS/CSP: nur Allowlist-HTML und kontrollierte Player-Komponenten.
-
-**Reihenfolge:** Archiv & aktuelle Governance gelten als Basis; Inline ist die **nächste** inhaltliche Ausbaustufe für Medien (siehe **`PROJECT_STATUS.md`** Nächste Schritte).
+**Weiteres:** UX-Politik, ggf. strategische Vereinheitlichung der Referenzmodellierung (reine Asset-Referenz vs. `exercise_media`) — siehe Nächste Schritte in **`PROJECT_STATUS.md`**.
---
-## 5b. P-13: Content-Meldeverfahren (vollständig implementiert, 2026-05-11)
+## 6. P-13: Content-Meldeverfahren (vollständig implementiert, 2026-05-11)
**DSA-konformes Meldeverfahren (KRIT-03) — App 0.8.87–0.8.94.**
@@ -148,18 +143,19 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
-## 6. Nächste Session — sinnvolle Arbeitspakete
+## 7. Nächste Session — sinnvolle Arbeitspakete
1. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
-2. **Inline §11:** Syntax festlegen (`{{exerciseMedia:id}}` → kanonisches HTML), Server normalisieren bei Speichern, einen `renderExerciseRichText()`-Pfad im Frontend.
+2. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
3. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
4. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
-5. **S3/Adapter:** Speicher-Abstraktion §7 — wenn Produkt es verlangt.
+5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
+7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
---
-## 7. Technische Referenz (kurz)
+## 8. Technische Referenz (kurz)
| Bereich | Einstieg |
|---------|----------|
@@ -171,7 +167,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
-## 8. Veraltete Hinweise
+## 9. Veraltete Hinweise
`.claude/docs/working/HANDOVER_NEXT_SESSION.md` verweist auf **dieses** Dokument (`docs/HANDOVER.md`) als aktuelle Basis.
--
2.43.0
From c1243651bb4be3ee9cdc971ef649783c0f5bc886 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 21:35:07 +0200
Subject: [PATCH 04/27] feat(training-modules): implement training module
functionality and UI integration
- Added new API endpoints for managing training modules, including listing, creating, updating, and deleting modules.
- Implemented the ability to apply training modules to training units, allowing users to copy module content into specific sections.
- Enhanced the frontend with new pages for managing training modules and integrated modal functionality for applying modules within the training planning page.
- Updated version to 0.8.97 and adjusted database schema version accordingly.
Co-Authored-By: Claude Sonnet 4.6
---
...e Kombinationsuebungen Spezifikation V2.md | 684 ++++++++++++++++++
.../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +-
.../TRAINING_MODULES_IMPLEMENTATION_PLAN.md | 30 +
backend/main.py | 3 +-
backend/migrations/054_training_modules.sql | 60 ++
backend/routers/training_modules.py | 381 ++++++++++
backend/routers/training_planning.py | 128 ++++
backend/version.py | 15 +-
frontend/src/App.jsx | 5 +
frontend/src/pages/TrainingModuleEditPage.jsx | 443 ++++++++++++
.../src/pages/TrainingModulesListPage.jsx | 131 ++++
frontend/src/pages/TrainingPlanningPage.jsx | 200 ++++-
frontend/src/utils/api.js | 40 +
13 files changed, 2114 insertions(+), 10 deletions(-)
create mode 100644 .claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
create mode 100644 .claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
create mode 100644 backend/migrations/054_training_modules.sql
create mode 100644 backend/routers/training_modules.py
create mode 100644 frontend/src/pages/TrainingModuleEditPage.jsx
create mode 100644 frontend/src/pages/TrainingModulesListPage.jsx
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 …
+ ) : (
+
+ )}
+
+
setPickerOpen(false)} onSelectExercise={pickExercise} />
+
+ )
+}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
new file mode 100644
index 0000000..e2a85a4
--- /dev/null
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -0,0 +1,131 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+
+export default function TrainingModulesListPage() {
+ const [rows, setRows] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const list = await api.listTrainingModules()
+ setRows(Array.isArray(list) ? list : [])
+ } catch (e) {
+ setError(e.message || 'Laden fehlgeschlagen')
+ setRows([])
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ async function handleDelete(id, title) {
+ if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
+ try {
+ await api.deleteTrainingModule(id)
+ await load()
+ } catch (e) {
+ alert(e.message || 'Löschen fehlgeschlagen')
+ }
+ }
+
+ return (
+
+
+
+
+ Trainingsmodule
+
+
+ Wiederverwendbare Übungsfolgen für die{' '}
+
+ Trainingsplanung
+
+ . Übernahme in eine Einheit erfolgt dort als lokale Kopie (mit Herkunftsmarkierung).
+
+
+
+ Neues Modul
+
+
+
+ {error ? (
+
{error}
+ ) : null}
+ {loading ? (
+
Laden …
+ ) : rows.length === 0 ? (
+
+
Noch keine Module angelegt.
+
+ ) : (
+
+ {rows.map((r) => (
+
+
+
+
+ {(r.title || '').trim() || `Modul #${r.id}`}
+
+
+ {(r.summary || '').trim() || '—'}{' '}
+
+ ({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
+
+
+
+ Sichtbarkeit: {r.visibility || '—'}
+
+
+
+
+ Bearbeiten
+
+ handleDelete(r.id, r.title)}>
+ Löschen
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 77ffa61..0a04cbc 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -144,6 +144,13 @@ function TrainingPlanningPage() {
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
+ const [moduleApplyOpen, setModuleApplyOpen] = useState(false)
+ const [moduleApplyBusy, setModuleApplyBusy] = useState(false)
+ const [moduleApplyList, setModuleApplyList] = useState([])
+ const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
+ const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
+ const [moduleApplyErr, setModuleApplyErr] = useState('')
+
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
const [planView, setPlanView] = useState('list')
@@ -662,6 +669,58 @@ function TrainingPlanningPage() {
}
}
+ const openModuleApplyModal = useCallback(async () => {
+ setModuleApplyErr('')
+ setModuleApplySectionIx(0)
+ setModuleApplyOpen(true)
+ try {
+ const list = await api.listTrainingModules()
+ const arr = Array.isArray(list) ? list : []
+ setModuleApplyList(arr)
+ setModuleApplyModuleId(arr.length ? String(arr[0].id) : '')
+ } catch (e) {
+ setModuleApplyErr(e.message || 'Module konnten nicht geladen werden')
+ setModuleApplyList([])
+ }
+ }, [])
+
+ const handleApplyTrainingModuleConfirm = useCallback(async () => {
+ if (!editingUnit?.id) return
+ const mid = parseInt(moduleApplyModuleId, 10)
+ if (!Number.isFinite(mid)) {
+ alert('Bitte ein Trainingsmodul wählen.')
+ return
+ }
+ let secIx = parseInt(moduleApplySectionIx, 10)
+ if (!Number.isFinite(secIx)) secIx = 0
+ if (!formData.sections?.length) {
+ alert('Keine Abschnitte im Formular.')
+ return
+ }
+ if (secIx < 0 || secIx >= formData.sections.length) secIx = 0
+
+ setModuleApplyBusy(true)
+ setModuleApplyErr('')
+ try {
+ await api.applyTrainingModuleToTrainingUnit(editingUnit.id, {
+ module_id: mid,
+ section_order_index: secIx,
+ })
+ await handleEdit({ id: editingUnit.id })
+ setModuleApplyOpen(false)
+ } catch (e) {
+ setModuleApplyErr(e.message || 'Übernehmen fehlgeschlagen')
+ } finally {
+ setModuleApplyBusy(false)
+ }
+ }, [
+ editingUnit?.id,
+ moduleApplyModuleId,
+ moduleApplySectionIx,
+ formData.sections?.length,
+ handleEdit,
+ ])
+
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
@@ -972,12 +1031,19 @@ function TrainingPlanningPage() {
-
+
Mehrere Einheiten strukturieren auf einmal:{' '}
Trainingsrahmenprogramme
{' '}
- (Ziele, Slots, Übungen als Vorlage).
+ (Ziele, Sessions, Vorlagen‑Ablauf).
+
+
+ Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
+
+ Trainingsmodule
+ {' '}
+ (übernahme als Kopie beim Bearbeiten einer Einheit).
{!loading && groups.length === 0 && (
@@ -1796,6 +1862,118 @@ function TrainingPlanningPage() {
) : null}
+ {moduleApplyOpen && (
+ ev.target === ev.currentTarget && !moduleApplyBusy && setModuleApplyOpen(false)}
+ >
+
+
+ Trainingsmodul übernehmen
+
+
+ Der Inhalt wird kopiert und ans Ende des gewählten Abschnitts angehängt (Herkunft wird
+ gespeichert). Anschließend kannst du ihn lokal bearbeiten.
+
+
+ {moduleApplyErr ? (
+
{moduleApplyErr}
+ ) : null}
+
+
+ Modul
+ setModuleApplyModuleId(e.target.value)}
+ disabled={moduleApplyBusy || !moduleApplyList.length}
+ >
+ {!moduleApplyList.length ? (
+ Keine Module verfügbar
+ ) : null}
+ {moduleApplyList.map((m) => (
+
+ {(m.title || '').trim() || `Modul #${m.id}`}
+
+ ))}
+
+
+
+
+ Ziel‑Abschnitt (Reihenfolge wie im Editor)
+ setModuleApplySectionIx(parseInt(e.target.value, 10))}
+ disabled={moduleApplyBusy || !formData.sections?.length}
+ >
+ {(formData.sections || []).map((s, i) => (
+
+ {(s.title || `Abschnitt ${i + 1}`).trim()}
+
+ ))}
+
+
+
+
+ !moduleApplyBusy && setModuleApplyOpen(false)}
+ >
+ Abbrechen
+
+
+ {moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
+
+
+
+
+ Neue Module kannst du unter{' '}
+
+ Trainingsmodule
+ {' '}
+ anlegen.
+
+
+
+ )}
+
{frameworkImportOpen && (
- Vorlage aus Aufbau speichern
-
+ <>
+
+ Vorlage aus Aufbau speichern
+
+ {editingUnit?.id ? (
+
+ Aus Modul übernehmen…
+
+ ) : null}
+ >
}
sections={formData.sections}
wideExerciseGrid
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index ff79d6f..d7fa338 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -1327,6 +1327,40 @@ export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
+export async function listTrainingModules() {
+ return request('/api/training-modules')
+}
+
+export async function getTrainingModule(id) {
+ return request(`/api/training-modules/${id}`)
+}
+
+export async function createTrainingModule(data) {
+ return request('/api/training-modules', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ })
+}
+
+export async function updateTrainingModule(id, data) {
+ return request(`/api/training-modules/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ })
+}
+
+export async function deleteTrainingModule(id) {
+ return request(`/api/training-modules/${id}`, { method: 'DELETE' })
+}
+
+/** Kopiert Modul-Inhalte ans Ende eines Abschnitts (section_order_index 0-basiert). */
+export async function applyTrainingModuleToTrainingUnit(unitId, data) {
+ return request(`/api/training-units/${unitId}/apply-training-module`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ })
+}
+
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
@@ -1478,6 +1512,12 @@ export const api = {
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
+ listTrainingModules,
+ getTrainingModule,
+ createTrainingModule,
+ updateTrainingModule,
+ deleteTrainingModule,
+ applyTrainingModuleToTrainingUnit,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,
--
2.43.0
From e41908af738f5cfff9c2eaa8a2b9e0b462054421 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 21:53:41 +0200
Subject: [PATCH 05/27] feat(training-planning): enhance training module
integration and UI
- Introduced a new function to handle optional source training module IDs, ensuring proper validation and integration.
- Updated the backend to include source training module ID and title in section items, allowing for better tracking of module origins.
- Enhanced the frontend to display module bands in the Training Unit Sections Editor, improving user experience by indicating the source of exercises and notes.
- Added functionality to insert training modules at specified positions within sections, providing users with more control over their training plans.
Co-Authored-By: Claude Sonnet 4.6
---
backend/routers/training_planning.py | 72 +++++++----
frontend/src/app.css | 13 ++
.../components/TrainingUnitSectionsEditor.jsx | 48 ++++++--
frontend/src/pages/TrainingModuleEditPage.jsx | 106 ++++++++++++++--
frontend/src/pages/TrainingPlanningPage.jsx | 113 ++++++++++++-----
.../src/utils/trainingUnitSectionsForm.js | 116 +++++++++++++++++-
6 files changed, 385 insertions(+), 83 deletions(-)
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index d621e88..4294d5d 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -404,6 +404,19 @@ _ORIGIN_LINEAGE_FIELDS = """
"""
+def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
+ """Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich)."""
+ if raw_val is None or raw_val == "":
+ return None
+ try:
+ i = int(raw_val)
+ except (TypeError, ValueError):
+ return None
+ if i < 1:
+ return None
+ return i
+
+
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
cur.execute(
"""
@@ -429,10 +442,12 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS exercise_focus_area,
- ev.variant_name AS exercise_variant_name
+ ev.variant_name AS exercise_variant_name,
+ tm.title AS source_module_title
FROM training_unit_section_items tusi
LEFT JOIN exercises e ON tusi.exercise_id = e.id
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
+ LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
WHERE tusi.section_id = %s
ORDER BY tusi.order_index
""",
@@ -453,28 +468,32 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
oix = it.get("order_index")
if itype == "note":
- items_clean.append(
- {
- "item_type": "note",
- "order_index": oix,
- "note_body": it.get("note_body") or "",
- }
- )
+ note_item = {
+ "item_type": "note",
+ "order_index": oix,
+ "note_body": it.get("note_body") or "",
+ }
+ sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
+ if sm is not None:
+ note_item["source_training_module_id"] = sm
+ items_clean.append(note_item)
continue
if itype != "exercise" or not it.get("exercise_id"):
continue
- items_clean.append(
- {
- "item_type": "exercise",
- "order_index": oix,
- "exercise_id": it["exercise_id"],
- "exercise_variant_id": it.get("exercise_variant_id"),
- "planned_duration_min": it.get("planned_duration_min"),
- "actual_duration_min": it.get("actual_duration_min"),
- "notes": it.get("notes"),
- "modifications": it.get("modifications"),
- }
- )
+ ex_item = {
+ "item_type": "exercise",
+ "order_index": oix,
+ "exercise_id": it["exercise_id"],
+ "exercise_variant_id": it.get("exercise_variant_id"),
+ "planned_duration_min": it.get("planned_duration_min"),
+ "actual_duration_min": it.get("actual_duration_min"),
+ "notes": it.get("notes"),
+ "modifications": it.get("modifications"),
+ }
+ sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
+ if sm is not None:
+ ex_item["source_training_module_id"] = sm
+ items_clean.append(ex_item)
out.append(
{
"title": sec.get("title"),
@@ -670,18 +689,19 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
body = raw.get("note_body")
if body is None:
body = ""
+ src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
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
+ notes, modifications, note_body, source_training_module_id
) VALUES (%s, %s, 'note',
- NULL, NULL, NULL, NULL, NULL, NULL, %s
+ NULL, NULL, NULL, NULL, NULL, NULL, %s, %s
)
""",
- (section_id, order_ix, body),
+ (section_id, order_ix, body, src_mod),
)
continue
@@ -691,15 +711,16 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
eid = int(eid)
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
_validate_variant_for_exercise(cur, eid, vid)
+ src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
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
+ notes, modifications, note_body, source_training_module_id
) VALUES (%s, %s, 'exercise',
- %s, %s, %s, %s, %s, %s, NULL
+ %s, %s, %s, %s, %s, %s, NULL, %s
)
""",
(
@@ -711,6 +732,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
raw.get("actual_duration_min"),
raw.get("notes"),
raw.get("modifications"),
+ src_mod,
),
)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9379619..44d1318 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5115,6 +5115,19 @@ a.analysis-split__nav-item {
max-width: 100%;
}
+.tu-planning-module-band {
+ margin-top: 0.85rem;
+ margin-bottom: 0.05rem;
+ padding: 0.35rem 0.65rem;
+ border-radius: 8px;
+ border: 1px solid var(--border2);
+ background: linear-gradient(to right, var(--accent-light), transparent 140%);
+ font-size: 0.78rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+ letter-spacing: 0.01em;
+}
+
.tu-item-row {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 31daf70..27f790d 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -10,6 +10,12 @@ import {
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
+function normalizedPlanningModuleChainId(raw) {
+ if (raw == null || raw === '') return null
+ const n = typeof raw === 'number' ? raw : Number(raw)
+ return Number.isFinite(n) && n >= 1 ? n : null
+}
+
function dtHasType(e, mime) {
const t = e?.dataTransfer?.types
if (!t || !mime) return false
@@ -521,15 +527,29 @@ export default function TrainingUnitSectionsEditor({
}
: {}
+ const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
+ const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
+ const showModuleBand =
+ curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
+ const modBandTitle =
+ (it.source_module_title || '').trim() ||
+ (curMn != null ? `Modul #${curMn}` : '')
+
if (it.item_type === 'note') {
const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim())
return (
-
+
+ {showModuleBand ? (
+
+ Baustein: {modBandTitle}
+
+ ) : null}
+
{enableItemDragReorder ? (
+
)
}
@@ -611,11 +632,17 @@ export default function TrainingUnitSectionsEditor({
: Number(it.exercise_variant_id)
return (
-
+
+ {showModuleBand ? (
+
+ Baustein: {modBandTitle}
+
+ ) : null}
+
{enableItemDragReorder ? (
) : null}
+
)
})}
diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx
index 08e88db..4700e12 100644
--- a/frontend/src/pages/TrainingModuleEditPage.jsx
+++ b/frontend/src/pages/TrainingModuleEditPage.jsx
@@ -1,8 +1,10 @@
-import React, { useCallback, useEffect, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, 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'
+import { useAuth } from '../context/AuthContext'
+import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
function nextLocalKey() {
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@@ -38,6 +40,19 @@ export default function TrainingModuleEditPage() {
const [primaryMethodId, setPrimaryMethodId] = useState('')
const [items, setItems] = useState([])
+ const { user } = useAuth()
+ const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
+
+ useEffect(() => {
+ if (!isNew || visibility !== 'club') return
+ if ((clubIdField || '').trim() !== '') return
+ if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
+ else {
+ const r = getResolvedActiveClubIdForUi(user)
+ if (r) setClubIdField(String(r))
+ }
+ }, [isNew, visibility, clubIdField, clubChoices, user])
+
const itemsPayload = items.map((it, i) => {
if (it.item_type === 'note') {
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
@@ -133,8 +148,16 @@ export default function TrainingModuleEditPage() {
}, [isNew, moduleId])
const buildBody = () => {
- const cid =
- visibility === 'club' && clubIdField !== '' ? parseInt(clubIdField, 10) : null
+ let cid = null
+ if (visibility === 'club') {
+ const raw = (clubIdField || '').trim()
+ if (raw !== '') {
+ const p = parseInt(raw, 10)
+ if (Number.isFinite(p) && p >= 1) cid = p
+ } else if (clubChoices.length === 1) {
+ cid = clubChoices[0].id
+ }
+ }
const pm =
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
return {
@@ -270,22 +293,79 @@ export default function TrainingModuleEditPage() {
Sichtbarkeit
- setVisibility(e.target.value)}>
+ {
+ const v = e.target.value
+ setVisibility(v)
+ if (v !== 'club') {
+ setClubIdField('')
+ return
+ }
+ const xs = clubChoices
+ if (xs.length === 1) setClubIdField(String(xs[0].id))
+ else if (xs.length === 0) setClubIdField('')
+ else {
+ const resolved = getResolvedActiveClubIdForUi(user)
+ setClubIdField(resolved != null ? String(resolved) : '')
+ }
+ }}
+ >
Privat
Vereinsintern
Offiziell
-
Vereins‑ID (optional, bei Vereins‑Sichtbarkeit)
-
setClubIdField(e.target.value)}
- placeholder="Leer = aktiver Verein (Server)"
- />
+
Verein (bei „Vereinsintern“)
+ {visibility !== 'club' ? (
+
+ Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
+ Vereinsbindung fest).
+
+ ) : clubChoices.length === 0 ? (
+
+ Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten.
+
+ ) : clubChoices.length === 1 ? (
+ <>
+
+
+ Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
+
+ >
+ ) : (
+ <>
+
setClubIdField(e.target.value)}
+ >
+ Automatisch (aktueller Verein im Profil)
+ {clubChoices.map((c) => {
+ const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
+ return (
+
+ {ln}
+
+ )
+ })}
+
+
+ Bei „Automatisch“ entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
+ Bibliotheksinhalten).
+
+ >
+ )}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 0a04cbc..a4e97a5 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -14,6 +14,7 @@ import {
enrichSectionsWithVariants,
buildSectionsPayload,
hydrateExercisePlanningRow,
+ insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm'
function addDaysIsoDate(isoDay, daysDelta) {
@@ -149,6 +150,7 @@ function TrainingPlanningPage() {
const [moduleApplyList, setModuleApplyList] = useState([])
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
+ const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('__end__')
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [startDate, setStartDate] = useState(today)
@@ -196,6 +198,19 @@ function TrainingPlanningPage() {
return Number.isFinite(c) ? c : null
}, [groups, formData.group_id])
+ const moduleApplyTargetItems = useMemo(() => {
+ const secs = formData.sections || []
+ if (!secs.length) return []
+ let ix =
+ typeof moduleApplySectionIx === 'number'
+ ? moduleApplySectionIx
+ : parseInt(String(moduleApplySectionIx), 10)
+ if (!Number.isFinite(ix)) ix = 0
+ if (ix < 0 || ix >= secs.length) return []
+ const sec = secs[ix]
+ return Array.isArray(sec?.items) ? sec.items : []
+ }, [formData.sections, moduleApplySectionIx])
+
const refreshPlanningSectionMeta = useCallback(async () => {
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
setFormData((prev) => ({ ...prev, sections: next }))
@@ -672,6 +687,7 @@ function TrainingPlanningPage() {
const openModuleApplyModal = useCallback(async () => {
setModuleApplyErr('')
setModuleApplySectionIx(0)
+ setModuleApplyInsertSlot('__end__')
setModuleApplyOpen(true)
try {
const list = await api.listTrainingModules()
@@ -685,41 +701,48 @@ function TrainingPlanningPage() {
}, [])
const handleApplyTrainingModuleConfirm = useCallback(async () => {
- if (!editingUnit?.id) return
const mid = parseInt(moduleApplyModuleId, 10)
if (!Number.isFinite(mid)) {
alert('Bitte ein Trainingsmodul wählen.')
return
}
- let secIx = parseInt(moduleApplySectionIx, 10)
+ let secIx = parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(secIx)) secIx = 0
- if (!formData.sections?.length) {
+
+ const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
+ if (!baseSections.length) {
alert('Keine Abschnitte im Formular.')
return
}
- if (secIx < 0 || secIx >= formData.sections.length) secIx = 0
+ if (secIx < 0 || secIx >= baseSections.length) secIx = 0
+
+ let insertBefore = null
+ if (moduleApplyInsertSlot === '__end__') insertBefore = 'end'
+ else if (moduleApplyInsertSlot === '__start__') insertBefore = 'start'
+ else if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
+ const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
+ insertBefore = Number.isFinite(zi) ? zi : 'end'
+ } else insertBefore = 'end'
setModuleApplyBusy(true)
setModuleApplyErr('')
try {
- await api.applyTrainingModuleToTrainingUnit(editingUnit.id, {
- module_id: mid,
- section_order_index: secIx,
+ const detail = await api.getTrainingModule(mid)
+ let nextSections = await insertTrainingModuleIntoPlanningSections({
+ sections: baseSections,
+ moduleDetail: detail,
+ sectionIndex: secIx,
+ insertBeforeItemIndex: insertBefore,
})
- await handleEdit({ id: editingUnit.id })
+ nextSections = await enrichSectionsWithVariants(nextSections)
+ setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false)
} catch (e) {
- setModuleApplyErr(e.message || 'Übernehmen fehlgeschlagen')
+ setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally {
setModuleApplyBusy(false)
}
- }, [
- editingUnit?.id,
- moduleApplyModuleId,
- moduleApplySectionIx,
- formData.sections?.length,
- handleEdit,
- ])
+ }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, formData.sections])
const handleTakeLead = async (unit) => {
if (!user?.id) return
@@ -1895,11 +1918,12 @@ function TrainingPlanningPage() {
aria-labelledby="module-apply-title"
>
- Trainingsmodul übernehmen
+ Modul einfügen
- Der Inhalt wird kopiert und ans Ende des gewählten Abschnitts angehängt (Herkunft wird
- gespeichert). Anschließend kannst du ihn lokal bearbeiten.
+ Übungen und Notizen des Moduls werden kopiert wie bei einer einzelnen Übung —
+ ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt
+ am Block sichtbar; du kannst alles weiter anpassen.
{moduleApplyErr ? (
@@ -1930,7 +1954,10 @@ function TrainingPlanningPage() {
setModuleApplySectionIx(parseInt(e.target.value, 10))}
+ onChange={(e) => {
+ setModuleApplySectionIx(parseInt(e.target.value, 10))
+ setModuleApplyInsertSlot('__end__')
+ }}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
@@ -1941,6 +1968,32 @@ function TrainingPlanningPage() {
+
+ Position in diesem Abschnitt
+ setModuleApplyInsertSlot(e.target.value)}
+ disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
+ >
+ Ans Ende einfügen (nach allen Einträgen)
+ An den Anfang (vor dem ersten Eintrag)
+ {moduleApplyTargetItems.map((row, xi) => {
+ const labelPart =
+ row.item_type === 'note'
+ ? 'Zwischen-Anmerkung'
+ : (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
+ const clipped =
+ labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
+ return (
+
+ Vor Eintrag {xi + 1}: {clipped}
+
+ )
+ })}
+
+
+
- {moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
+ {moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
@@ -2473,16 +2526,14 @@ function TrainingPlanningPage() {
Vorlage aus Aufbau speichern
- {editingUnit?.id ? (
-
- Aus Modul übernehmen…
-
- ) : null}
+
+ Modul einfügen…
+
>
}
sections={formData.sections}
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index de9670e..f99851e 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -15,6 +15,8 @@ export function exerciseRow() {
actual_duration_min: '',
notes: '',
modifications: '',
+ source_training_module_id: '',
+ source_module_title: '',
}
}
@@ -69,7 +71,14 @@ export async function hydrateExercisePlanningRow(exercise) {
}
export function noteRow() {
- return { item_type: 'note', note_body: '' }
+ return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
+}
+
+/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
+function parseOptionalSourceTrainingModuleIdForPayload(v) {
+ if (v === null || v === undefined || v === '') return null
+ const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
+ return Number.isFinite(n) && n >= 1 ? n : null
}
export function normalizeUnitToForm(fullUnit) {
@@ -79,8 +88,24 @@ export function normalizeUnitToForm(fullUnit) {
guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') {
- return { item_type: 'note', note_body: it.note_body || '' }
+ const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
+ const rowNote = {
+ item_type: 'note',
+ note_body: it.note_body || '',
+ source_training_module_id: '',
+ source_module_title: '',
+ }
+ if (sm != null) {
+ rowNote.source_training_module_id = sm
+ rowNote.source_module_title = (
+ it.source_module_title ||
+ it.source_training_module_title ||
+ ''
+ ).trim()
+ }
+ return rowNote
}
+ const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
@@ -97,6 +122,16 @@ export function normalizeUnitToForm(fullUnit) {
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
+ ...(smEx != null
+ ? {
+ source_training_module_id: smEx,
+ source_module_title: (
+ it.source_module_title ||
+ it.source_training_module_title ||
+ ''
+ ).trim(),
+ }
+ : {}),
}
}),
}))
@@ -199,17 +234,21 @@ export function buildSectionsPayload(sections) {
items: (sec.items || [])
.map((it, ii) => {
if (it.item_type === 'note') {
- return {
+ const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
+ const row = {
item_type: 'note',
order_index: ii,
note_body: it.note_body ?? '',
}
+ if (sm != null) row.source_training_module_id = sm
+ return row
}
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
return null
}
const vid = it.exercise_variant_id
- return {
+ const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
+ const rowEx = {
item_type: 'exercise',
order_index: ii,
exercise_id: parseInt(it.exercise_id, 10),
@@ -220,11 +259,80 @@ export function buildSectionsPayload(sections) {
notes: it.notes?.trim() ? it.notes.trim() : null,
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
}
+ if (smEx != null) rowEx.source_training_module_id = smEx
+ return rowEx
})
.filter(Boolean),
}))
}
+/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
+export async function insertTrainingModuleIntoPlanningSections({
+ sections,
+ moduleDetail,
+ sectionIndex,
+ insertBeforeItemIndex,
+}) {
+ const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
+ if (
+ !Array.isArray(sections) ||
+ !Number.isFinite(secIx) ||
+ secIx < 0 ||
+ secIx >= sections.length ||
+ !moduleDetail ||
+ typeof moduleDetail !== 'object'
+ ) {
+ return sections
+ }
+ const prev = [...(sections[secIx].items || [])]
+ let beforeIx
+ if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
+ beforeIx = prev.length
+ } else if (insertBeforeItemIndex === 'start') {
+ beforeIx = 0
+ } else {
+ const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
+ beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
+ }
+
+ const midRaw = moduleDetail.id
+ const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
+ if (!Number.isFinite(midNum) || midNum < 1) return sections
+
+ const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
+ const modItems = [...(moduleDetail.items || [])].sort(
+ (a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
+ )
+
+ const appendRows = []
+ for (const mi of modItems) {
+ if (mi.item_type === 'note') {
+ appendRows.push({
+ item_type: 'note',
+ note_body: mi.note_body || '',
+ source_training_module_id: midNum,
+ source_module_title: modTitle,
+ })
+ continue
+ }
+ if (!mi.exercise_id) continue
+ const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
+ if (!hydrated) continue
+ hydrated.source_training_module_id = midNum
+ hydrated.source_module_title = modTitle
+ if (mi.exercise_variant_id) hydrated.exercise_variant_id = String(mi.exercise_variant_id)
+ hydrated.planned_duration_min =
+ mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
+ ? String(mi.planned_duration_min)
+ : ''
+ hydrated.notes = mi.notes ?? ''
+ appendRows.push(hydrated)
+ }
+
+ const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
+ return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
+}
+
export function sectionPlannedMinutes(sec) {
return (sec.items || []).reduce((sum, it) => {
if (it.item_type !== 'exercise') return sum
--
2.43.0
From bfaf532ab2554ed1c3109878978495421abf72d0 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 22:05:22 +0200
Subject: [PATCH 06/27] feat(training-units): enhance section editing with
insert functionality
- Added new CSS styles for insert slots and buttons to improve UI for adding items between sections.
- Implemented functionality in the TrainingUnitSectionsEditor to allow users to insert notes and exercises at specified positions within sections.
- Updated the TrainingFrameworkProgramEditPage to support the new insert functionality, ensuring seamless integration with existing features.
- Enhanced state management to handle insert positions effectively, improving user experience during section editing.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/app.css | 58 ++++
.../components/TrainingUnitSectionsEditor.jsx | 250 +++++++++++++++---
.../TrainingFrameworkProgramEditPage.jsx | 17 +-
frontend/src/pages/TrainingPlanningPage.jsx | 79 ++++--
4 files changed, 338 insertions(+), 66 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 44d1318..9f93655 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5128,6 +5128,64 @@ a.analysis-split__nav-item {
letter-spacing: 0.01em;
}
+/* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */
+.tu-insert-slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 11px;
+ margin: -1px 0 3px;
+ padding: 0 4px;
+}
+
+.tu-insert-slot__btn {
+ appearance: none;
+ margin: 0;
+ cursor: pointer;
+ border: 1px dashed var(--border2);
+ background: color-mix(in srgb, var(--surface2) 90%, transparent);
+ color: var(--accent-dark);
+ font-size: 0.9rem;
+ font-weight: 700;
+ line-height: 1;
+ padding: 2px 9px;
+ border-radius: 999px;
+ opacity: 0.78;
+}
+
+.tu-insert-slot__btn:hover,
+.tu-insert-slot__btn:focus-visible {
+ opacity: 1;
+ outline: none;
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent-light) 40%, var(--surface2));
+}
+
+.tu-insert-chooser-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.tu-insert-chooser-actions__full {
+ width: 100%;
+ justify-content: center;
+}
+
+.tu-item-row--separator-note {
+ padding-top: 0.35rem;
+ padding-bottom: 0.35rem;
+}
+
+.tu-item-row__separator-line {
+ width: 100%;
+ margin: 0.2rem 0 0;
+ min-height: 1px;
+ border: none;
+ border-top: 2px solid var(--border);
+ opacity: 0.92;
+}
+
.tu-item-row {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 27f790d..fee6313 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -10,6 +10,9 @@ import {
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
+/** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */
+const SECTION_INSERT_SEPARATOR_BODY = '---'
+
function normalizedPlanningModuleChainId(raw) {
if (raw == null || raw === '') return null
const n = typeof raw === 'number' ? raw : Number(raw)
@@ -48,6 +51,7 @@ export default function TrainingUnitSectionsEditor({
sections,
onSectionsChange,
onRequestExercisePick,
+ onRequestTrainingModulePick,
onPeekExercise,
showExecutionExtras = false,
heading = 'Abschnitte & Übungen',
@@ -58,6 +62,8 @@ export default function TrainingUnitSectionsEditor({
enableSectionDragReorder = true,
slotIndex = null,
onMoveSectionsAcrossSlots = null,
+ /** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
+ betweenInsertMenus = true,
}) {
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
@@ -99,16 +105,32 @@ export default function TrainingUnitSectionsEditor({
})
}
+ const insertItemAt = useCallback(
+ (sIdx, beforeIx, row) => {
+ patch((prev) =>
+ prev.map((s, i) => {
+ if (i !== sIdx) return s
+ const items = [...(s.items || [])]
+ const ix = Math.max(
+ 0,
+ Math.min(Number(beforeIx) || 0, items.length)
+ )
+ items.splice(ix, 0, row)
+ return { ...s, items }
+ })
+ )
+ },
+ [patch]
+ )
+
const addItem = (sIdx, kind) => {
patch((prev) =>
- prev.map((s, i) =>
- i !== sIdx
- ? s
- : {
- ...s,
- items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()],
- }
- )
+ prev.map((s, i) => {
+ if (i !== sIdx) return s
+ const items = [...(s.items || [])]
+ items.push(kind === 'note' ? noteRow() : exerciseRow())
+ return { ...s, items }
+ })
)
}
@@ -149,6 +171,8 @@ export default function TrainingUnitSectionsEditor({
}
const [textEdit, setTextEdit] = useState(null)
+ /** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
+ const [insertChooser, setInsertChooser] = useState(null)
const [draggingPos, setDraggingPos] = useState(null)
const [dropTargetPos, setDropTargetPos] = useState(null)
@@ -164,6 +188,20 @@ export default function TrainingUnitSectionsEditor({
return () => window.removeEventListener('keydown', onKey)
}, [textEdit])
+ useEffect(() => {
+ if (!insertChooser) return
+ const onKey = (e) => {
+ if (e.key === 'Escape') setInsertChooser(null)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [insertChooser])
+
+ const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
+
+ const insertSlotKeyPrefix =
+ slotIndex !== null && slotIndex !== undefined ? `sl${slotIndex}-` : ''
+
const clearSectionDnD = () => setDropSectionBand(null)
const onSectionDragStart = (e, sIdx) => {
@@ -351,6 +389,29 @@ export default function TrainingUnitSectionsEditor({
setTextEdit(null)
}
+ const renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => {
+ const posLabel =
+ beforeIx === 0
+ ? 'vor dem ersten Eintrag'
+ : beforeIx >= itemCount
+ ? 'am Ende des Abschnitts'
+ : `vor Eintrag ${beforeIx + 1}`
+ return (
+
+ setInsertChooser({ sIdx, beforeIx })}
+ >
+ +
+
+
+ )
+ }
+
const list = ensure(sections)
return (
@@ -506,6 +567,8 @@ export default function TrainingUnitSectionsEditor({
)}
+ {betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null}
+
{(sec.items || []).map((it, iIdx) => {
const dropHere =
enableItemDragReorder &&
@@ -536,10 +599,11 @@ export default function TrainingUnitSectionsEditor({
(curMn != null ? `Modul #${curMn}` : '')
if (it.item_type === 'note') {
+ const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
const notePv = truncatePreview(it.note_body || '', 260)
- const noteHasText = Boolean((it.note_body || '').trim())
+ const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
-
+
{showModuleBand ? (
) : null}
-
+
{enableItemDragReorder ? (
-
Zwischen-Anmerkung
-
- {noteHasText ? notePv : '—'}
-
+
+ {isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
+
+ {isSepLine ? (
+
+ ) : (
+
+ {noteHasText ? notePv : '—'}
+
+ )}
setTextEdit({
kind: 'zwischen-note',
@@ -610,12 +690,13 @@ export default function TrainingUnitSectionsEditor({
type="button"
className="tu-item-row__remove"
title="Entfernen"
- aria-label="Zwischen-Anmerkung entfernen"
+ aria-label={isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen'}
onClick={() => removeItem(sIdx, iIdx)}
>
✗
+ {betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
)
}
@@ -632,7 +713,7 @@ export default function TrainingUnitSectionsEditor({
: Number(it.exercise_variant_id)
return (
-
+
{showModuleBand ? (
) : null}
+ {betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
)
})}
@@ -837,21 +919,30 @@ export default function TrainingUnitSectionsEditor({
/>
) : null}
-
-
onRequestExercisePick?.({ sectionIndex: sIdx })}
- >
- + Übung
-
-
addItem(sIdx, 'note')}
- >
- + Anmerkung
-
+
+ {betweenInsertMenus ? (
+
+ Über die +-Zeilen zwischen den Einträgen fügst du an der gewünschten Stelle Inhalte ein. Reihenfolge
+ weiter per Ziehen oder den Pfeiltasten ändern.
+
+ ) : (
+
+ onRequestExercisePick?.({ sectionIndex: sIdx })}
+ >
+ + Übung
+
+ addItem(sIdx, 'note')}
+ >
+ + Anmerkung
+
+
+ )}
@@ -889,6 +980,91 @@ export default function TrainingUnitSectionsEditor({
+ Abschnitt hinzufügen
+ {insertChooser ? (
+
{
+ if (e.target === e.currentTarget) closeInsertChooser()
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+ An dieser Stelle einfügen
+
+
+ Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen
+ ändern.
+
+
+ {
+ const { sIdx, beforeIx } = insertChooser
+ closeInsertChooser()
+ onRequestExercisePick?.({
+ sectionIndex: sIdx,
+ insertBeforeIndex: beforeIx,
+ })
+ }}
+ >
+ Übung auswählen …
+
+ {onRequestTrainingModulePick ? (
+ {
+ const ctx = { ...insertChooser }
+ closeInsertChooser()
+ onRequestTrainingModulePick({
+ sectionIndex: ctx.sIdx,
+ insertBeforeIndex: ctx.beforeIx,
+ })
+ }}
+ >
+ Trainingsmodul …
+
+ ) : null}
+ {
+ const { sIdx, beforeIx } = insertChooser
+ insertItemAt(sIdx, beforeIx, noteRow())
+ closeInsertChooser()
+ }}
+ >
+ Zwischen-Anmerkung
+
+ {
+ const { sIdx, beforeIx } = insertChooser
+ const r = noteRow()
+ r.note_body = SECTION_INSERT_SEPARATOR_BODY
+ insertItemAt(sIdx, beforeIx, r)
+ closeInsertChooser()
+ }}
+ >
+ Trennlinie
+
+
+ Abbrechen
+
+
+
+
+ ) : null}
+
{textEdit ? (
+ onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
+ insertBeforeIndex:
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : undefined,
})
}
onPeekExercise={(id, variantId) =>
@@ -1096,7 +1101,7 @@ export default function TrainingFrameworkProgramEditPage() {
if (row) rows.push(row)
}
if (!rows.length) return
- const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
+ const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex } = sectionPickerCtx
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) => {
@@ -1121,7 +1126,13 @@ export default function TrainingFrameworkProgramEditPage() {
if (tail.length) items.splice(iIdx + 1, 0, ...tail)
return { ...sec, items }
}
- return { ...sec, items: [...items, ...rows] }
+ const rawAt =
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : items.length
+ const at = Math.max(0, Math.min(rawAt, items.length))
+ items.splice(at, 0, ...rows)
+ return { ...sec, items }
}),
}
}),
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index a4e97a5..5263c2b 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -150,7 +150,7 @@ function TrainingPlanningPage() {
const [moduleApplyList, setModuleApplyList] = useState([])
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
- const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('__end__')
+ const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [startDate, setStartDate] = useState(today)
@@ -684,10 +684,27 @@ function TrainingPlanningPage() {
}
}
- const openModuleApplyModal = useCallback(async () => {
+ const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('')
- setModuleApplySectionIx(0)
- setModuleApplyInsertSlot('__end__')
+ const secs = planningFormRef.current?.sections ?? []
+ let secIx = 0
+ let before = 0
+ if (secs.length) {
+ if (placement && typeof placement.sectionIndex === 'number') {
+ secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1)
+ const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : []
+ const cap = items.length
+ if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) {
+ before = Math.min(Math.max(0, placement.insertBeforeIndex), cap)
+ } else before = cap
+ } else {
+ const items = Array.isArray(secs[0]?.items) ? secs[0].items : []
+ before = items.length
+ secIx = 0
+ }
+ }
+ setModuleApplySectionIx(secIx)
+ setModuleApplyInsertSlot(`before:${before}`)
setModuleApplyOpen(true)
try {
const list = await api.listTrainingModules()
@@ -716,13 +733,13 @@ function TrainingPlanningPage() {
}
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
- let insertBefore = null
- if (moduleApplyInsertSlot === '__end__') insertBefore = 'end'
- else if (moduleApplyInsertSlot === '__start__') insertBefore = 'start'
- else if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
+ const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : []
+ const itemCap = secItems.length
+ let insertBefore = itemCap
+ if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
- insertBefore = Number.isFinite(zi) ? zi : 'end'
- } else insertBefore = 'end'
+ if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap)
+ }
setModuleApplyBusy(true)
setModuleApplyErr('')
@@ -742,7 +759,7 @@ function TrainingPlanningPage() {
} finally {
setModuleApplyBusy(false)
}
- }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, formData.sections])
+ }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot])
const handleTakeLead = async (unit) => {
if (!user?.id) return
@@ -1955,8 +1972,11 @@ function TrainingPlanningPage() {
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => {
- setModuleApplySectionIx(parseInt(e.target.value, 10))
- setModuleApplyInsertSlot('__end__')
+ const newIx = parseInt(e.target.value, 10)
+ setModuleApplySectionIx(newIx)
+ const secsNow = planningFormRef.current?.sections ?? []
+ const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
+ setModuleApplyInsertSlot(`before:${len}`)
}}
disabled={moduleApplyBusy || !formData.sections?.length}
>
@@ -1976,8 +1996,10 @@ function TrainingPlanningPage() {
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
-
Ans Ende einfügen (nach allen Einträgen)
-
An den Anfang (vor dem ersten Eintrag)
+
+ Ans Ende einfügen (nach allen Einträgen)
+
+
An den Anfang (vor dem ersten Eintrag)
{moduleApplyTargetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
@@ -2526,14 +2548,6 @@ function TrainingPlanningPage() {
Vorlage aus Aufbau speichern
-
- Modul einfügen…
-
>
}
sections={formData.sections}
@@ -2544,10 +2558,17 @@ function TrainingPlanningPage() {
sections: updater(prev.sections),
}))
}
- onRequestExercisePick={({ sectionIndex, itemIndex }) => {
+ onRequestTrainingModulePick={(ctx) => {
+ void openModuleApplyModal(ctx)
+ }}
+ onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
setExercisePickerTarget({
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
+ insertBeforeIndex:
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : undefined,
})
setExercisePickerOpen(true)
}}
@@ -2700,7 +2721,7 @@ function TrainingPlanningPage() {
if (row) rows.push(row)
}
if (!rows.length) return
- const { sIdx, iIdx } = exercisePickerTarget
+ const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, si) => {
@@ -2720,7 +2741,13 @@ function TrainingPlanningPage() {
if (tail.length) items.splice(iIdx + 1, 0, ...tail)
return { ...s, items }
}
- return { ...s, items: [...items, ...rows] }
+ const rawAt =
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : items.length
+ const at = Math.max(0, Math.min(rawAt, items.length))
+ items.splice(at, 0, ...rows)
+ return { ...s, items }
}),
}))
setExercisePickerOpen(false)
--
2.43.0
From e96951728da0f16eaea894a9227efdc147d10c22 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 22:17:44 +0200
Subject: [PATCH 07/27] feat(training-units): enhance training unit sections
with new module display and functionality
- Added new CSS styles for module display, including a structured layout for exercises and notes within the Training Unit Sections Editor.
- Implemented a function to gather and render module outlines, improving the visibility of exercises and notes associated with training modules.
- Enhanced the TrainingPlanningPage to support module search and preview functionality, allowing users to filter and view module details before applying them.
- Improved state management for module application, ensuring a smoother user experience when inserting modules into training plans.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/app.css | 216 ++++++++
.../components/TrainingUnitSectionsEditor.jsx | 86 +++-
frontend/src/pages/TrainingPlanningPage.jsx | 466 +++++++++++++++---
3 files changed, 675 insertions(+), 93 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9f93655..72415e5 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5116,6 +5116,7 @@ a.analysis-split__nav-item {
}
.tu-planning-module-band {
+ /* Legacy — Ersetzt durch .tu-module-bundle-head; wird aus Kompatibilität vorerst beibehalten. */
margin-top: 0.85rem;
margin-bottom: 0.05rem;
padding: 0.35rem 0.65rem;
@@ -5128,6 +5129,180 @@ a.analysis-split__nav-item {
letter-spacing: 0.01em;
}
+.tu-module-bundle-head {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ margin: 0.65rem 0 0.15rem;
+ border-radius: 14px;
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border2));
+ background: linear-gradient(
+ 134deg,
+ color-mix(in srgb, var(--accent-light) 55%, var(--surface)) 0%,
+ var(--surface) 68%
+ );
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+}
+
+.tu-module-bundle-head__stripe {
+ width: 5px;
+ flex-shrink: 0;
+ background: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent-dark) 92%, black) 100%);
+}
+
+.tu-module-bundle-head__main {
+ flex: 1;
+ min-width: 0;
+ padding: 0.6rem 0.75rem 0.72rem;
+}
+
+.tu-module-bundle-head__kicker {
+ display: block;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: color-mix(in srgb, var(--accent-dark) 88%, var(--text3));
+ margin-bottom: 0.12rem;
+}
+
+.tu-module-bundle-head__title {
+ display: block;
+ font-size: 1.02rem;
+ font-weight: 700;
+ color: var(--text1);
+ line-height: 1.25;
+ margin-bottom: 0.5rem;
+}
+
+.tu-module-bundle-head__list {
+ margin: 0;
+ padding-left: 1.15rem;
+ font-size: 0.84rem;
+ line-height: 1.48;
+ color: var(--text2);
+}
+
+.tu-module-bundle-head__list li {
+ margin-bottom: 0.12rem;
+}
+
+.tu-module-bundle-head__list li::marker {
+ color: var(--accent-dark);
+}
+
+.tu-module-bundle-head__more,
+.tu-module-bundle-head__meta,
+.tu-module-bundle-head__empty {
+ margin: 0.25rem 0 0;
+ font-size: 0.79rem;
+ line-height: 1.42;
+ color: var(--text3);
+}
+
+.tu-module-bundle-head__empty {
+ margin-top: 0.15rem;
+}
+
+.tu-modulepick-search {
+ width: 100%;
+ margin-bottom: 0.55rem;
+}
+
+.tu-modulepick-list {
+ max-height: 240px;
+ overflow-y: auto;
+ padding: 2px;
+ margin: 0 -2px;
+ border-radius: 10px;
+ border: 1px solid var(--border2);
+ background: color-mix(in srgb, var(--surface2) 22%, var(--surface));
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.tu-modulepick-item {
+ text-align: left;
+ appearance: none;
+ margin: 0;
+ cursor: pointer;
+ width: 100%;
+ padding: 0.45rem 0.62rem;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ background: var(--surface);
+ color: var(--text1);
+ transition:
+ border-color 0.12s ease,
+ box-shadow 0.12s ease;
+}
+
+.tu-modulepick-item:hover {
+ border-color: color-mix(in srgb, var(--accent) 28%, transparent);
+}
+
+.tu-modulepick-item--active {
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent),
+ 0 2px 10px rgba(29, 158, 117, 0.08);
+}
+
+.tu-modulepick-item__title {
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 700;
+}
+
+.tu-modulepick-item__meta {
+ display: block;
+ margin-top: 0.08rem;
+ font-size: 0.74rem;
+ color: var(--text3);
+ line-height: 1.38;
+}
+
+.tu-modulepick-preview {
+ margin-top: 0.85rem;
+ padding: 0.55rem 0.72rem;
+ border-radius: 10px;
+ border: 1px dashed var(--border2);
+ background: color-mix(in srgb, var(--surface2) 38%, transparent);
+}
+
+.tu-modulepick-preview__title {
+ margin: 0 0 0.45rem;
+ font-size: 0.74rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text3);
+}
+
+.tu-modulepick-preview__list {
+ margin: 0;
+ padding-left: 1.1rem;
+ font-size: 0.82rem;
+ color: var(--text2);
+ line-height: 1.42;
+}
+
+.tu-modulepick-preview__more {
+ margin: 0.35rem 0 0;
+ font-size: 0.78rem;
+ color: var(--text3);
+}
+
+.tu-module-apply-placement-details summary {
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.82rem;
+ color: var(--accent-dark);
+ margin: 0.25rem 0 0;
+}
+
/* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */
.tu-insert-slot {
display: flex;
@@ -5186,6 +5361,47 @@ a.analysis-split__nav-item {
opacity: 0.92;
}
+.training-unit-sections-editor .tu-item-row {
+ border-top: none;
+ margin-top: 0;
+}
+
+.training-unit-sections-editor .tu-item-row--exercise {
+ margin-top: 0.58rem;
+ padding: 0.55rem 0.72rem 0.55rem;
+ border-radius: 14px;
+ border: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 15%);
+ background: linear-gradient(160deg, var(--surface) 0%, color-mix(in srgb, var(--surface2) 35%, var(--surface)) 100%);
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.56) inset,
+ 0 2px 10px rgba(15, 23, 42, 0.05);
+}
+
+.training-unit-sections-editor .tu-item-row--note:not(.tu-item-row--separator-note) {
+ margin-top: 0.52rem;
+ padding: 0.48rem 0.72rem;
+ border-radius: 11px;
+ border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent);
+ background: color-mix(in srgb, var(--surface2) 28%, var(--surface));
+}
+
+.training-unit-sections-editor .tu-item-row--separator-note {
+ margin-top: 0.42rem;
+ border-radius: 10px;
+ border: 1px dashed color-mix(in srgb, var(--border2) 75%, transparent);
+ background: color-mix(in srgb, var(--surface2) 15%, transparent);
+}
+
+.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--exercise,
+.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--note:not(.tu-item-row--separator-note) {
+ border-left: 4px solid var(--accent);
+ padding-left: 0.6rem;
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.52) inset,
+ 0 2px 12px rgba(29, 158, 117, 0.07),
+ inset 10px 0 0 color-mix(in srgb, var(--accent) 14%, transparent);
+}
+
.tu-item-row {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index fee6313..d12002d 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -32,6 +32,63 @@ function truncatePreview(text, max = 160) {
return `${t.slice(0, max - 1)}…`
}
+/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */
+function gatherPlanningModuleOutline(items, startIdx, moduleId) {
+ const exercises = []
+ let notes = 0
+ for (let j = startIdx; j < (items?.length ?? 0); j++) {
+ const row = items[j]
+ if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break
+ if (row.item_type === 'note') {
+ const bod = (row.note_body || '').trim()
+ if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
+ notes += 1
+ continue
+ }
+ const t =
+ (row.exercise_title || '').trim() ||
+ (row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung')
+ exercises.push(t)
+ }
+ return { exercises, notes }
+}
+
+const MODULE_OUTLINE_PREVIEW_MAX = 8
+
+function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
+ if (!showModuleBand || !modOutline) return null
+ return (
+
+
+
+
Aus Modul
+
{modBandTitle}
+ {modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
+
Ohne strukturierten Inhalt angezeigt.
+ ) : (
+
+ {modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
+ {tx}
+ ))}
+
+ )}
+ {modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
+
+ … und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
+ {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
+
+ ) : null}
+ {modOutline.notes > 0 ? (
+
+ sowie {modOutline.notes}{' '}
+ {modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
+
+ ) : null}
+
+
+ )
+}
+
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
const b = [...blocks]
if (fromI < 0 || fromI >= b.length) return blocks
@@ -598,25 +655,22 @@ export default function TrainingUnitSectionsEditor({
(it.source_module_title || '').trim() ||
(curMn != null ? `Modul #${curMn}` : '')
+ const modOutline =
+ showModuleBand && curMn != null ? gatherPlanningModuleOutline(sec.items, iIdx, curMn) : null
+ const fromModClass = curMn != null ? ' tu-item-row--from-module' : ''
+
if (it.item_type === 'note') {
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
- {showModuleBand ? (
-
- Baustein: {modBandTitle}
-
- ) : null}
+ {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
@@ -714,16 +768,8 @@ export default function TrainingUnitSectionsEditor({
return (
- {showModuleBand ? (
-
- Baustein: {modBandTitle}
-
- ) : null}
-
+ {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
+
{enableItemDragReorder ? (
{
+ const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ')
+ const words = q ? q.split(' ').filter(Boolean) : []
+ const list = Array.isArray(moduleApplyList) ? moduleApplyList : []
+ if (!words.length) return list
+ return list.filter((m) => {
+ const blob = [
+ m.title,
+ m.summary,
+ m.goal,
+ m.target_group_notes,
+ m.deployment_context_notes,
+ ]
+ .map((x) => String(x ?? '').toLowerCase())
+ .join('\n')
+ return words.every((w) => blob.includes(w))
+ })
+ }, [moduleApplySearchQuery, moduleApplyList])
+
+ const modulePlacementSummary = useMemo(() => {
+ const secs = Array.isArray(formData.sections) ? formData.sections : []
+ let si =
+ typeof moduleApplySectionIx === 'number'
+ ? moduleApplySectionIx
+ : parseInt(String(moduleApplySectionIx), 10)
+ if (!Number.isFinite(si)) si = 0
+ si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0))
+ const cap = secs[si]?.items?.length ?? 0
+ let beforeIx = cap
+ if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
+ const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
+ if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap)
+ }
+ const rawTitle = (secs[si]?.title || '').trim()
+ const secTitle = rawTitle || `Abschnitt ${si + 1}`
+ let positionDescription
+ if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts'
+ else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts'
+ else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts'
+ else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)`
+ return { secTitle, positionDescription }
+ }, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot])
+
+ useEffect(() => {
+ if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
+ if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
+ setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
+ }, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
+
const planningModalClubId = useMemo(() => {
const gid = Number(formData.group_id)
if (!Number.isFinite(gid) || gid < 1) return null
@@ -686,6 +753,12 @@ function TrainingPlanningPage() {
const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('')
+ setModuleApplySearchQuery('')
+ const placementLocked =
+ placement != null &&
+ typeof placement.sectionIndex === 'number' &&
+ typeof placement.insertBeforeIndex === 'number'
+ setModuleApplyPlacementLocked(placementLocked)
const secs = planningFormRef.current?.sections ?? []
let secIx = 0
let before = 0
@@ -754,6 +827,7 @@ function TrainingPlanningPage() {
nextSections = await enrichSectionsWithVariants(nextSections)
setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false)
+ setModuleApplyPlacementLocked(false)
} catch (e) {
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally {
@@ -761,6 +835,98 @@ function TrainingPlanningPage() {
}
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot])
+ useEffect(() => {
+ if (!moduleApplyOpen) {
+ setModulePickPreview({
+ loading: false,
+ moduleId: '',
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+ return undefined
+ }
+ const mid = parseInt(String(moduleApplyModuleId), 10)
+ if (!Number.isFinite(mid) || mid < 1) {
+ setModulePickPreview({
+ loading: false,
+ moduleId: '',
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+ return undefined
+ }
+ let cancelled = false
+ setModulePickPreview({
+ loading: true,
+ moduleId: String(mid),
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+ ;(async () => {
+ try {
+ const detail = await api.getTrainingModule(mid)
+ if (cancelled) return
+ const itemsSorted = [...(detail.items ?? [])].sort(
+ (a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
+ )
+ const uniqueEx = new Set()
+ let notes = 0
+ for (const row of itemsSorted) {
+ if ((row.item_type || '') !== 'note') {
+ const eid = row.exercise_id
+ if (eid) uniqueEx.add(Number(eid))
+ continue
+ }
+ const b = String(row.note_body ?? '').trim()
+ if (b === '---') continue
+ notes += 1
+ }
+ const titleById = new Map()
+ await Promise.all(
+ [...uniqueEx].map(async (eid) => {
+ try {
+ const ex = await api.getExercise(eid)
+ titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`)
+ } catch {
+ titleById.set(eid, `Übung #${eid}`)
+ }
+ })
+ )
+ if (cancelled) return
+ const exTitlesInOrder = []
+ for (const row of itemsSorted) {
+ if ((row.item_type || '') !== 'exercise') continue
+ const eid = Number(row.exercise_id)
+ if (!Number.isFinite(eid)) continue
+ exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`)
+ }
+ setModulePickPreview({
+ loading: false,
+ moduleId: String(mid),
+ exercises: exTitlesInOrder,
+ notes,
+ err: '',
+ })
+ } catch (e) {
+ if (!cancelled) {
+ setModulePickPreview({
+ loading: false,
+ moduleId: String(mid),
+ exercises: [],
+ notes: 0,
+ err: e?.message || 'Vorschau fehlgeschlagen',
+ })
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [moduleApplyOpen, moduleApplyModuleId])
+
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
@@ -1919,13 +2085,17 @@ function TrainingPlanningPage() {
overflowY: 'auto',
}}
role="presentation"
- onMouseDown={(ev) => ev.target === ev.currentTarget && !moduleApplyBusy && setModuleApplyOpen(false)}
+ onMouseDown={(ev) => {
+ if (ev.target !== ev.currentTarget || moduleApplyBusy) return
+ setModuleApplyOpen(false)
+ setModuleApplyPlacementLocked(false)
+ }}
>
- Modul einfügen
+ Trainingsmodul einfügen
-
- Übungen und Notizen des Moduls werden kopiert wie bei einer einzelnen Übung —
- ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt
- am Block sichtbar; du kannst alles weiter anpassen.
+
+ Alle Positionen des gewählten Moduls werden als neue Zeilen eingefügt (Kopie, mit klarer
+ Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben — Speichern am Ende
+ wie gewohnt. Vollständige Textsuche oder Modulkategorien planen wir serverseitig für
+ eine spätere Iteration; vorerst steht hier eine{' '}
+ Schnellsuche über Titel und Freitext-Felder zur Verfügung.
{moduleApplyErr ? (
{moduleApplyErr}
) : null}
-
- Modul
- setModuleApplyModuleId(e.target.value)}
- disabled={moduleApplyBusy || !moduleApplyList.length}
- >
- {!moduleApplyList.length ? (
- Keine Module verfügbar
- ) : null}
- {moduleApplyList.map((m) => (
-
- {(m.title || '').trim() || `Modul #${m.id}`}
-
- ))}
-
-
+ {moduleApplyPlacementLocked ? (
+ <>
+
+ Aktuelle Einfügeposition: Abschnitt {modulePlacementSummary.secTitle} {' '}
+ / {modulePlacementSummary.positionDescription}
+
+
+ Abschnitt oder Position ändern
+
+
+ Abschnitt (Reihenfolge wie im Editor)
+ {
+ const newIx = parseInt(e.target.value, 10)
+ setModuleApplySectionIx(newIx)
+ const secsNow = planningFormRef.current?.sections ?? []
+ const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
+ setModuleApplyInsertSlot(`before:${len}`)
+ }}
+ disabled={moduleApplyBusy || !formData.sections?.length}
+ >
+ {(formData.sections || []).map((s, i) => (
+
+ {(s.title || `Abschnitt ${i + 1}`).trim()}
+
+ ))}
+
+
+
+ Position in diesem Abschnitt
+ setModuleApplyInsertSlot(e.target.value)}
+ disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
+ >
+
+ Am Ende einfügen (nach allen Einträgen)
+
+ An den Anfang (vor dem ersten Eintrag)
+ {moduleApplyTargetItems.map((row, xi) => {
+ const labelPart =
+ row.item_type === 'note'
+ ? 'Zwischen-Anmerkung'
+ : (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
+ const clipped =
+ labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
+ return (
+
+ Vor Eintrag {xi + 1}: {clipped}
+
+ )
+ })}
+
+
+
+
+ >
+ ) : (
+ <>
+
+ Abschnitt (Reihenfolge wie im Editor)
+ {
+ const newIx = parseInt(e.target.value, 10)
+ setModuleApplySectionIx(newIx)
+ const secsNow = planningFormRef.current?.sections ?? []
+ const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
+ setModuleApplyInsertSlot(`before:${len}`)
+ }}
+ disabled={moduleApplyBusy || !formData.sections?.length}
+ >
+ {(formData.sections || []).map((s, i) => (
+
+ {(s.title || `Abschnitt ${i + 1}`).trim()}
+
+ ))}
+
+
-
- Ziel‑Abschnitt (Reihenfolge wie im Editor)
- {
- const newIx = parseInt(e.target.value, 10)
- setModuleApplySectionIx(newIx)
- const secsNow = planningFormRef.current?.sections ?? []
- const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
- setModuleApplyInsertSlot(`before:${len}`)
- }}
- disabled={moduleApplyBusy || !formData.sections?.length}
- >
- {(formData.sections || []).map((s, i) => (
-
- {(s.title || `Abschnitt ${i + 1}`).trim()}
-
- ))}
-
-
-
-
-
Position in diesem Abschnitt
-
setModuleApplyInsertSlot(e.target.value)}
- disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
- >
-
- Ans Ende einfügen (nach allen Einträgen)
-
- An den Anfang (vor dem ersten Eintrag)
- {moduleApplyTargetItems.map((row, xi) => {
- const labelPart =
- row.item_type === 'note'
- ? 'Zwischen-Anmerkung'
- : (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
- const clipped =
- labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
- return (
-
- Vor Eintrag {xi + 1}: {clipped}
+
+ Position in diesem Abschnitt
+ setModuleApplyInsertSlot(e.target.value)}
+ disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
+ >
+
+ Am Ende einfügen (nach allen Einträgen)
- )
- })}
-
+ An den Anfang (vor dem ersten Eintrag)
+ {moduleApplyTargetItems.map((row, xi) => {
+ const labelPart =
+ row.item_type === 'note'
+ ? 'Zwischen-Anmerkung'
+ : (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
+ const clipped =
+ labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
+ return (
+
+ Vor Eintrag {xi + 1}: {clipped}
+
+ )
+ })}
+
+
+ >
+ )}
+
+
+ Suche Module
+ setModuleApplySearchQuery(e.target.value)}
+ disabled={moduleApplyBusy}
+ aria-label="Module durch Freitext filtern"
+ />
+
+
+ Modulliste
+
+
+
+ {!moduleApplyFilteredList.length ? (
+
+ {!moduleApplyList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
+
+ ) : (
+ moduleApplyFilteredList.map((m) => {
+ const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
+ const visLbl = trainingVisibilityShortDE(m.visibility)
+ const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
+ const selected = String(m.id) === String(moduleApplyModuleId)
+ return (
+
setModuleApplyModuleId(String(m.id))}
+ >
+ {title}
+
+ {nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
+ {visLbl ? <> · {visLbl}> : null}
+ {m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}> : null}
+
+
+ )
+ })
+ )}
+
+
+ {moduleApplyModuleId ? (
+
+
Ablauf-Vorschau (Bibliotheksmodul)
+ {modulePickPreview.loading ? (
+
+ Übungen und Hinweise laden …
+
+ ) : modulePickPreview.err ? (
+
+ {modulePickPreview.err}
+
+ ) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
+
+ Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
+
+ ) : (
+ <>
+
+ {(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
+ {t}
+ ))}
+
+ {modulePickPreview.exercises.length > 12 ? (
+
+ … und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
+
+ ) : null}
+ {modulePickPreview.notes > 0 ? (
+
+ zusätzlich {modulePickPreview.notes}{' '}
+ {modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
+ (ohne Aufzählung)
+
+ ) : null}
+ >
+ )}
+
+ ) : null}
+
!moduleApplyBusy && setModuleApplyOpen(false)}
+ onClick={() => {
+ if (moduleApplyBusy) return
+ setModuleApplyOpen(false)
+ setModuleApplyPlacementLocked(false)
+ }}
>
Abbrechen
--
2.43.0
From 05042ee9ecfb5ffd97d5c8bf6fb7f01cba3428f0 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 22:30:55 +0200
Subject: [PATCH 08/27] feat(training-units): add compact legend and module
styling in Training Unit Sections Editor
- Introduced new CSS styles for compact module display, enhancing the visual structure of training unit sections.
- Implemented functionality to conditionally render module tags and borders based on the selected UX mode.
- Enhanced the section module legend model to aggregate and display module information effectively.
- Improved the rendering logic to support both compact and standard views, ensuring a flexible user experience.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/app.css | 97 +++++++++++++
.../components/TrainingUnitSectionsEditor.jsx | 132 +++++++++++++++++-
frontend/src/config/planningModuleUx.js | 11 ++
3 files changed, 235 insertions(+), 5 deletions(-)
create mode 100644 frontend/src/config/planningModuleUx.js
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 72415e5..ff76e8b 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5402,6 +5402,103 @@ a.analysis-split__nav-item {
inset 10px 0 0 color-mix(in srgb, var(--accent) 14%, transparent);
}
+/* Kompakt: Modulfarbe über CSS-Variable --tu-mod-border (pro Zeile gesetzt). */
+.training-unit-sections-editor .tu-item-row--from-module-soft.tu-item-row--exercise,
+.training-unit-sections-editor .tu-item-row--from-module-soft.tu-item-row--note:not(.tu-item-row--separator-note) {
+ border-left: 4px solid var(--tu-mod-border, var(--accent));
+ padding-left: 0.62rem;
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.52) inset,
+ 0 2px 12px rgba(15, 23, 42, 0.05);
+}
+
+.tu-planning-mod-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex: 0 1 auto;
+ max-width: 100%;
+ margin: 0;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent);
+ font-size: 0.72rem;
+ font-weight: 700;
+ line-height: 1.35;
+ color: var(--text1);
+}
+
+.tu-planning-mod-tag__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.tu-planning-mod-tag__text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tu-section-mod-legend {
+ margin-top: 0.75rem;
+ padding: 0.5rem 0.62rem;
+ border-radius: 11px;
+ border: 1px solid color-mix(in srgb, var(--border2) 80%, transparent);
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
+}
+
+.tu-section-mod-legend__caption {
+ font-size: 0.65rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text3);
+ margin-bottom: 0.42rem;
+}
+
+.tu-section-mod-legend__list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.42rem 0.72rem;
+}
+
+.tu-section-mod-legend__item {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: 8px;
+ flex: 1 1 200px;
+ min-width: 0;
+}
+
+.tu-section-mod-legend__swatch {
+ width: 11px;
+ height: 11px;
+ border-radius: 3px;
+ flex-shrink: 0;
+ margin-top: 0.26rem;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
+}
+
+.tu-section-mod-legend__title {
+ display: block;
+ font-size: 0.82rem;
+ font-weight: 700;
+ line-height: 1.35;
+ color: var(--text1);
+}
+
+.tu-section-mod-legend__meta {
+ display: block;
+ font-size: 0.72rem;
+ line-height: 1.42;
+ color: var(--text3);
+}
+
.tu-item-row {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index d12002d..281d877 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -6,6 +6,7 @@ import {
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
+import { PLANNING_MODULE_UX_MODE } from '../config/planningModuleUx'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
@@ -55,6 +56,61 @@ function gatherPlanningModuleOutline(items, startIdx, moduleId) {
const MODULE_OUTLINE_PREVIEW_MAX = 8
+const PLANNING_USE_COMPACT_LEGEND = PLANNING_MODULE_UX_MODE === 'compact_tag_legend'
+
+/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
+function planningModulePalette(moduleId) {
+ const id = normalizedPlanningModuleChainId(moduleId)
+ const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
+ const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
+ const h = golden % 360
+ const border = `hsl(${h} 52% 36%)`
+ const soft = `hsl(${h} 42% 94%)`
+ return { border, soft, hue: h }
+}
+
+function PlanningModuleRowTag({ moduleId, title }) {
+ const p = planningModulePalette(moduleId)
+ const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
+ const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
+ return (
+
+
+ Aus Modul: {lbl}
+
+ )
+}
+
+/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
+function sectionModuleLegendModel(items) {
+ const map = new Map()
+ for (const row of items || []) {
+ const id = normalizedPlanningModuleChainId(row.source_training_module_id)
+ if (id == null) continue
+ if (!map.has(id)) {
+ map.set(id, {
+ id,
+ title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
+ exercises: 0,
+ notes: 0,
+ })
+ }
+ const agg = map.get(id)
+ if ((row.item_type || '') === 'note') {
+ const bod = ((row.note_body || '').trim() || '').trim()
+ if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
+ agg.notes += 1
+ } else {
+ agg.exercises += 1
+ }
+ }
+ return [...map.values()].sort((a, b) => a.id - b.id)
+}
+
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
if (!showModuleBand || !modOutline) return null
return (
@@ -515,6 +571,7 @@ export default function TrainingUnitSectionsEditor({
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
+ const moduleLegend = PLANNING_USE_COMPACT_LEGEND ? sectionModuleLegendModel(sec.items) : []
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
@@ -656,8 +713,21 @@ export default function TrainingUnitSectionsEditor({
(curMn != null ? `Modul #${curMn}` : '')
const modOutline =
- showModuleBand && curMn != null ? gatherPlanningModuleOutline(sec.items, iIdx, curMn) : null
- const fromModClass = curMn != null ? ' tu-item-row--from-module' : ''
+ !PLANNING_USE_COMPACT_LEGEND &&
+ showModuleBand &&
+ curMn != null
+ ? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
+ : null
+ const fromModClass =
+ curMn != null
+ ? PLANNING_USE_COMPACT_LEGEND
+ ? ' tu-item-row--from-module-soft'
+ : ' tu-item-row--from-module'
+ : ''
+ const modBorderVarStyle =
+ PLANNING_USE_COMPACT_LEGEND && curMn != null
+ ? { '--tu-mod-border': planningModulePalette(curMn).border }
+ : undefined
if (it.item_type === 'note') {
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
@@ -665,7 +735,8 @@ export default function TrainingUnitSectionsEditor({
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
- {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
+ {!PLANNING_USE_COMPACT_LEGEND &&
+ renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
{enableItemDragReorder ? (
+ {!isSepLine && PLANNING_USE_COMPACT_LEGEND && curMn ? (
+
+ ) : null}
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
@@ -768,8 +843,13 @@ export default function TrainingUnitSectionsEditor({
return (
- {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
-
+ {!PLANNING_USE_COMPACT_LEGEND &&
+ renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
+
{enableItemDragReorder ? (
Keine Übung gewählt
)}
+ {PLANNING_USE_COMPACT_LEGEND && curMn ? (
+
+ ) : null}
)}
+
+ {moduleLegend.length ? (
+
+
Übernommene Module im Abschnitt
+
+ {moduleLegend.map((e) => {
+ const pal = planningModulePalette(e.id)
+ return (
+
+
+
+
+ {(e.title || '').trim() || `Modul #${e.id}`}
+
+
+ ID {e.id} · {e.exercises}{' '}
+ {e.exercises === 1 ? 'Übung' : 'Übungen'}
+ {e.notes > 0 ? (
+ <>
+ {' '}
+ · {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
+ >
+ ) : null}
+
+
+
+ )
+ })}
+
+
+ ) : null}
)
diff --git a/frontend/src/config/planningModuleUx.js b/frontend/src/config/planningModuleUx.js
new file mode 100644
index 0000000..22dad26
--- /dev/null
+++ b/frontend/src/config/planningModuleUx.js
@@ -0,0 +1,11 @@
+/**
+ * Darstellung „Herkunft Trainingsmodul“ in Abschnitten (Planungs-Editor).
+ *
+ * - compact_tag_legend (Standard): wenig Höhe — farbige Leiste am Eintrag,
+ * kleiner Modul-Tag in der Zeile, Legende pro Abschnitt unten (Farbe ⇄ Modul).
+ * - full_outline_headers: früheres Verhalten mit großem Kopf-Bereich inkl.
+ * Auflistung der Übungen (viel Platz, maximale Orientierung ohne Scroll).
+ *
+ * Zum Zurückschalten: Wert hier auf `'full_outline_headers'` setzen oder Datei reverten.
+ */
+export const PLANNING_MODULE_UX_MODE = 'compact_tag_legend'
--
2.43.0
From f4f5642c21315c642e90dd9ff253e1d614997196 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 12 May 2026 22:36:19 +0200
Subject: [PATCH 09/27] feat(profiles): add training planning preferences to
user profile
- Introduced `training_planning_prefs` field in the ProfileUpdate model to store user-specific UI options for training planning.
- Updated the backend to handle the new preferences during profile updates, ensuring proper validation and storage.
- Enhanced the frontend to allow users to select their preferred display mode for training modules in the Account Settings page.
- Updated version to 0.8.98 and adjusted database schema version accordingly, reflecting the new feature integration.
Co-Authored-By: Claude Sonnet 4.6
---
.../055_profiles_training_planning_prefs.sql | 3 +
backend/models.py | 4 +
backend/routers/profiles.py | 9 +++
backend/version.py | 13 +++-
.../components/TrainingUnitSectionsEditor.jsx | 26 ++++---
frontend/src/config/planningModuleUx.js | 27 +++++--
frontend/src/pages/AccountSettingsPage.jsx | 78 +++++++++++++++++++
7 files changed, 140 insertions(+), 20 deletions(-)
create mode 100644 backend/migrations/055_profiles_training_planning_prefs.sql
diff --git a/backend/migrations/055_profiles_training_planning_prefs.sql b/backend/migrations/055_profiles_training_planning_prefs.sql
new file mode 100644
index 0000000..b5db616
--- /dev/null
+++ b/backend/migrations/055_profiles_training_planning_prefs.sql
@@ -0,0 +1,3 @@
+-- Persönliche Planungs-UI-Präferenzen (JSONB, selbst vom Nutzer setzbar)
+ALTER TABLE profiles
+ ADD COLUMN IF NOT EXISTS training_planning_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
diff --git a/backend/models.py b/backend/models.py
index ded7613..c2dc7b0 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -47,6 +47,10 @@ class ProfileUpdate(BaseModel):
default=None,
description="JSON: gespeicherte Standardfilter für die Übungsliste",
)
+ training_planning_prefs: Optional[Dict[str, Any]] = Field(
+ default=None,
+ description="JSON: UI-Optionen Trainingsplanung (z.B. Darstellung kopierter Module)",
+ )
class ProfileResponse(BaseModel):
id: int
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
index 3785b84..c554b3e 100644
--- a/backend/routers/profiles.py
+++ b/backend/routers/profiles.py
@@ -384,6 +384,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
else:
raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein")
+ if "training_planning_prefs" in patch:
+ tp = patch.pop("training_planning_prefs")
+ if tp is None:
+ data["training_planning_prefs"] = Json({})
+ elif isinstance(tp, dict):
+ data["training_planning_prefs"] = Json(tp)
+ else:
+ raise HTTPException(400, "training_planning_prefs muss ein JSON-Objekt sein")
+
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items():
if k == "email":
diff --git a/backend/version.py b/backend/version.py
index e23c351..03d94db 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.97"
+APP_VERSION = "0.8.98"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260512054"
+DB_SCHEMA_VERSION = "20260512055"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
- "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
+ "profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.98",
+ "date": "2026-05-12",
+ "changes": [
+ "profiles: `training_planning_prefs` JSONB (Migration 055), Patch via PUT Profil — z.B. Darstellung kopierter Trainingsmodule in der Planungs-UI (nutzerspezifisch).",
+ ],
+ },
{
"version": "0.8.97",
"date": "2026-05-12",
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 281d877..bd812c7 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -6,7 +6,8 @@ import {
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
-import { PLANNING_MODULE_UX_MODE } from '../config/planningModuleUx'
+import { isCompactTagLegendMode } from '../config/planningModuleUx'
+import { useAuth } from '../context/AuthContext'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
@@ -56,8 +57,6 @@ function gatherPlanningModuleOutline(items, startIdx, moduleId) {
const MODULE_OUTLINE_PREVIEW_MAX = 8
-const PLANNING_USE_COMPACT_LEGEND = PLANNING_MODULE_UX_MODE === 'compact_tag_legend'
-
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@@ -178,6 +177,11 @@ export default function TrainingUnitSectionsEditor({
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
betweenInsertMenus = true,
}) {
+ const { user } = useAuth()
+ const planningCompactLegend = isCompactTagLegendMode(
+ user?.training_planning_prefs?.module_display_mode
+ )
+
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
@@ -571,7 +575,7 @@ export default function TrainingUnitSectionsEditor({
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
- const moduleLegend = PLANNING_USE_COMPACT_LEGEND ? sectionModuleLegendModel(sec.items) : []
+ const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
@@ -713,19 +717,19 @@ export default function TrainingUnitSectionsEditor({
(curMn != null ? `Modul #${curMn}` : '')
const modOutline =
- !PLANNING_USE_COMPACT_LEGEND &&
+ !planningCompactLegend &&
showModuleBand &&
curMn != null
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
: null
const fromModClass =
curMn != null
- ? PLANNING_USE_COMPACT_LEGEND
+ ? planningCompactLegend
? ' tu-item-row--from-module-soft'
: ' tu-item-row--from-module'
: ''
const modBorderVarStyle =
- PLANNING_USE_COMPACT_LEGEND && curMn != null
+ planningCompactLegend && curMn != null
? { '--tu-mod-border': planningModulePalette(curMn).border }
: undefined
@@ -735,7 +739,7 @@ export default function TrainingUnitSectionsEditor({
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
- {!PLANNING_USE_COMPACT_LEGEND &&
+ {!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
- {!isSepLine && PLANNING_USE_COMPACT_LEGEND && curMn ? (
+ {!isSepLine && planningCompactLegend && curMn ? (
) : null}
@@ -843,7 +847,7 @@ export default function TrainingUnitSectionsEditor({
return (
- {!PLANNING_USE_COMPACT_LEGEND &&
+ {!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
Keine Übung gewählt
)}
- {PLANNING_USE_COMPACT_LEGEND && curMn ? (
+ {planningCompactLegend && curMn ? (
) : null}
diff --git a/frontend/src/config/planningModuleUx.js b/frontend/src/config/planningModuleUx.js
index 22dad26..c233fca 100644
--- a/frontend/src/config/planningModuleUx.js
+++ b/frontend/src/config/planningModuleUx.js
@@ -1,11 +1,26 @@
/**
* Darstellung „Herkunft Trainingsmodul“ in Abschnitten (Planungs-Editor).
*
- * - compact_tag_legend (Standard): wenig Höhe — farbige Leiste am Eintrag,
- * kleiner Modul-Tag in der Zeile, Legende pro Abschnitt unten (Farbe ⇄ Modul).
- * - full_outline_headers: früheres Verhalten mit großem Kopf-Bereich inkl.
- * Auflistung der Übungen (viel Platz, maximale Orientierung ohne Scroll).
+ * Der Modus kommt aus dem Profil (`training_planning_prefs.module_display_mode`).
+ * Standard: kompakt (Tags + Legende). Voll: großer Kopf mit Auflistung der Übungen.
*
- * Zum Zurückschalten: Wert hier auf `'full_outline_headers'` setzen oder Datei reverten.
+ * Früher: Umschalter nur in dieser Datei. Jetzt: unter Einstellungen speichern;
+ * hier nur Konstanten und Resolver.
*/
-export const PLANNING_MODULE_UX_MODE = 'compact_tag_legend'
+export const PLANNING_MODULE_DISPLAY_MODES = /** @type {const} */ ({
+ COMPACT_TAG_LEGEND: 'compact_tag_legend',
+ FULL_OUTLINE_HEADERS: 'full_outline_headers',
+})
+
+/** @param {string | undefined | null} pref */
+export function resolvePlanningModuleDisplayMode(pref) {
+ if (pref === PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS) {
+ return PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS
+ }
+ return PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
+}
+
+/** @param {string | undefined | null} pref */
+export function isCompactTagLegendMode(pref) {
+ return resolvePlanningModuleDisplayMode(pref) === PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
+}
diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx
index 047e515..98381ab 100644
--- a/frontend/src/pages/AccountSettingsPage.jsx
+++ b/frontend/src/pages/AccountSettingsPage.jsx
@@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
+import {
+ PLANNING_MODULE_DISPLAY_MODES,
+ resolvePlanningModuleDisplayMode,
+} from '../config/planningModuleUx'
import api from '../utils/api'
/**
@@ -17,6 +21,12 @@ function AccountSettingsPage() {
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
+ const [planningPrefsBusy, setPlanningPrefsBusy] = useState(false)
+ /** @type {[string, React.Dispatch>]} */
+ const [moduleDisplayDraft, setModuleDisplayDraft] = useState(
+ PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
+ )
+
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@@ -29,6 +39,11 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
+ useEffect(() => {
+ const m = resolvePlanningModuleDisplayMode(user?.training_planning_prefs?.module_display_mode)
+ setModuleDisplayDraft(m)
+ }, [user?.id, user?.training_planning_prefs])
+
const refreshJoinRequests = () => {
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
@@ -93,6 +108,26 @@ function AccountSettingsPage() {
}
}
+ const handleSavePlanningPrefs = async (e) => {
+ e.preventDefault()
+ if (!user?.id) return
+ setPlanningPrefsBusy(true)
+ try {
+ const base =
+ user.training_planning_prefs && typeof user.training_planning_prefs === 'object'
+ ? { ...user.training_planning_prefs }
+ : {}
+ const merged = { ...base, module_display_mode: moduleDisplayDraft }
+ await api.updateProfile(user.id, { training_planning_prefs: merged })
+ await checkAuth()
+ showOk('Planungs-Anzeige gespeichert.')
+ } catch (err) {
+ showErr(err.message || 'Konnte nicht speichern.')
+ } finally {
+ setPlanningPrefsBusy(false)
+ }
+ }
+
const handleResendVerification = async () => {
const em = user?.email
if (!em) return
@@ -276,6 +311,49 @@ function AccountSettingsPage() {
+
+
Vereinsbeitritt
--
2.43.0
From 8a9f9f960fe85c2798489fa75f54ed40e81391c9 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 06:12:47 +0200
Subject: [PATCH 10/27] feat(exercises): introduce combination exercises and
enhance exercise management
- Updated app version to 0.8.99, reflecting the addition of combination exercises.
- Implemented new data structures and validation for combination slots and archetypes in the backend.
- Enhanced frontend components to support selection and display of combination exercises, including new UI elements for managing slots and archetypes.
- Updated API payload handling to accommodate new exercise types and their associated data.
Co-Authored-By: Claude Sonnet 4.6
---
.../migrations/056_combination_exercises.sql | 33 ++
backend/routers/exercises.py | 363 +++++++++++++++++-
backend/version.py | 13 +-
.../src/components/ExercisePickerModal.jsx | 2 +
frontend/src/pages/ExerciseDetailPage.jsx | 36 ++
frontend/src/pages/ExerciseFormPage.jsx | 217 ++++++++++-
frontend/src/pages/ExercisesListPage.jsx | 5 +
frontend/src/utils/api.js | 59 ++-
8 files changed, 716 insertions(+), 12 deletions(-)
create mode 100644 backend/migrations/056_combination_exercises.sql
diff --git a/backend/migrations/056_combination_exercises.sql b/backend/migrations/056_combination_exercises.sql
new file mode 100644
index 0000000..781ec1b
--- /dev/null
+++ b/backend/migrations/056_combination_exercises.sql
@@ -0,0 +1,33 @@
+-- Migration 056: Kombinationsübungen (Phase 2 MVP) — Slots + Pool-Kandidaten
+-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md §6
+
+ALTER TABLE exercises
+ ADD COLUMN IF NOT EXISTS exercise_kind VARCHAR(20) NOT NULL DEFAULT 'simple'
+ CHECK (exercise_kind IN ('simple', 'combination')),
+ ADD COLUMN IF NOT EXISTS method_archetype VARCHAR(80),
+ ADD COLUMN IF NOT EXISTS method_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
+
+CREATE INDEX IF NOT EXISTS idx_exercises_exercise_kind ON exercises(exercise_kind);
+CREATE INDEX IF NOT EXISTS idx_exercises_method_archetype ON exercises(method_archetype)
+ WHERE method_archetype IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS combination_exercise_slots (
+ id SERIAL PRIMARY KEY,
+ exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
+ slot_index INT NOT NULL,
+ title VARCHAR(200),
+ UNIQUE (exercise_id, slot_index)
+);
+
+CREATE INDEX IF NOT EXISTS idx_combination_exercise_slots_exercise ON combination_exercise_slots(exercise_id);
+
+CREATE TABLE IF NOT EXISTS combination_slot_candidates (
+ id SERIAL PRIMARY KEY,
+ slot_id INT NOT NULL REFERENCES combination_exercise_slots(id) ON DELETE CASCADE,
+ candidate_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
+ sort_order INT NOT NULL DEFAULT 0,
+ UNIQUE (slot_id, candidate_exercise_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_slot ON combination_slot_candidates(slot_id);
+CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_exercise ON combination_slot_candidates(candidate_exercise_id);
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 39e0075..5509383 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -10,12 +10,13 @@ import logging
import os
import re
from pathlib import Path
-from typing import Any, Dict, Iterator, List, Optional, Tuple
+from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator
+from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
from club_tenancy import (
@@ -198,6 +199,26 @@ def _upload_limit_bytes(tenant: TenantContext) -> int:
# Pydantic Models
# ============================================================================
+# Archetyp-IDs (Maschinenlesbare Ablaufmuster) — konsistent zur technischen Entwurfsspezifikation
+COMBINATION_ARCHETYPE_IDS = frozenset(
+ {
+ "circuit_rotate_time",
+ "circuit_all_parallel",
+ "sequence_linear",
+ "station_parcour",
+ "pair_superset",
+ "time_domain_interval",
+ "free_method_block",
+ }
+)
+
+
+class CombinationSlotIn(BaseModel):
+ slot_index: int = Field(ge=0, le=99)
+ title: Optional[str] = Field(None, max_length=200)
+ candidate_exercise_ids: list[int] = Field(default_factory=list)
+
+
class ExerciseCreate(BaseModel):
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
title: str = Field(..., min_length=3, max_length=300)
@@ -231,6 +252,12 @@ class ExerciseCreate(BaseModel):
status: str = "draft"
club_id: Optional[int] = None
+ # Kombinationsübung (Phase 2)
+ exercise_kind: Literal["simple", "combination"] = "simple"
+ method_archetype: Optional[str] = Field(None, max_length=80)
+ method_profile: Dict[str, Any] = Field(default_factory=dict)
+ combination_slots: list[CombinationSlotIn] = Field(default_factory=list)
+
@model_validator(mode="after")
def normalize_goal_execution(self):
g = (self.goal or "").strip() or None
@@ -270,6 +297,11 @@ class ExerciseUpdate(BaseModel):
# Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry)
default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000)
+ exercise_kind: Optional[Literal["simple", "combination"]] = None
+ method_archetype: Optional[str] = Field(None, max_length=80)
+ method_profile: Optional[Dict[str, Any]] = None
+ combination_slots: Optional[list[CombinationSlotIn]] = None
+
@model_validator(mode="after")
def normalize_goal_execution(self):
if self.goal is not None:
@@ -811,6 +843,137 @@ def _resolve_local_media_file(
return path_under_media_root(media_root, asset_storage_key)
return _abs_media_path(file_path_db or "", media_root) if file_path_db else None
+
+def _normalize_method_profile_store(raw: Any) -> Dict[str, Any]:
+ if raw is None:
+ return {}
+ if isinstance(raw, dict):
+ return raw
+ raise HTTPException(status_code=400, detail="method_profile muss ein JSON-Objekt sein")
+
+
+def _validate_archetype_for_kind(kind: str, archetype: Optional[str]) -> None:
+ if kind != "combination":
+ return
+ if archetype is None or not str(archetype).strip():
+ return
+ a = str(archetype).strip()
+ if a not in COMBINATION_ARCHETYPE_IDS:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ "Unbekannter method_archetype. Erlaubt: "
+ + ", ".join(sorted(COMBINATION_ARCHETYPE_IDS))
+ ),
+ )
+
+
+def _assert_candidate_exercises_for_combination(cur, tenant: TenantContext, ids: List[int]) -> None:
+ if not ids:
+ return
+ seen: set[int] = set()
+ for cid_raw in ids:
+ cid = int(cid_raw)
+ if cid in seen:
+ continue
+ seen.add(cid)
+ cur.execute(
+ """SELECT id, exercise_kind, visibility, club_id, created_by
+ FROM exercises WHERE id = %s""",
+ (cid,),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=400, detail=f"Slot-Verweis: Übung #{cid} nicht gefunden")
+ rd = r2d(row)
+ k = str(rd.get("exercise_kind") or "simple").strip().lower()
+ if k != "simple":
+ raise HTTPException(
+ status_code=400,
+ detail=f"Slot-Verweis: Übung #{cid} ist eine Kombinationsübung — nur Einzelübungen erlaubt",
+ )
+ if not library_content_visible_to_profile(
+ cur,
+ tenant.profile_id,
+ rd.get("visibility"),
+ rd.get("club_id"),
+ rd.get("created_by"),
+ tenant.global_role,
+ ):
+ raise HTTPException(status_code=403, detail=f"Slot-Verweis: keine Leserechte für Übung #{cid}")
+
+
+def _validate_and_normalize_combination_slots_payload(
+ cur,
+ tenant: TenantContext,
+ slots: Optional[List[CombinationSlotIn]],
+) -> List[Tuple[int, Optional[str], List[int]]]:
+ if slots is None:
+ return []
+ normalized: Dict[int, Tuple[Optional[str], List[int]]] = {}
+ for s in sorted(slots, key=lambda x: x.slot_index):
+ cid_list_raw = list(s.candidate_exercise_ids or [])
+ cid_list: List[int] = []
+ for x in cid_list_raw:
+ cid_list.append(int(x))
+ title = ((s.title or "").strip()) or None
+ normalized[int(s.slot_index)] = (title, cid_list)
+ out: List[Tuple[int, Optional[str], List[int]]] = []
+ for idx in sorted(normalized.keys()):
+ title, cands = normalized[idx]
+ if not cands:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Station (Index {idx}): mindestens eine Einzelübung (Pool) ist erforderlich",
+ )
+ out.append((idx, title, cands))
+ return out
+
+
+def replace_combination_slots(
+ cur,
+ tenant: TenantContext,
+ exercise_id: int,
+ slots_norm: List[Tuple[int, Optional[str], List[int]]],
+) -> None:
+ flat_ids = [cid for _, __, xs in slots_norm for cid in xs]
+ _assert_candidate_exercises_for_combination(cur, tenant, flat_ids)
+ cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
+ for slot_index, title, cand_ids in slots_norm:
+ cur.execute(
+ """INSERT INTO combination_exercise_slots (exercise_id, slot_index, title)
+ VALUES (%s, %s, %s) RETURNING id""",
+ (exercise_id, slot_index, title),
+ )
+ row = cur.fetchone()
+ sid = row["id"] if isinstance(row, dict) else row[0]
+ for so, cid in enumerate(cand_ids):
+ cur.execute(
+ """INSERT INTO combination_slot_candidates (slot_id, candidate_exercise_id, sort_order)
+ VALUES (%s, %s, %s)""",
+ (sid, int(cid), int(so)),
+ )
+
+
+def wipe_combination_structure(cur, exercise_id: int) -> None:
+ cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
+
+
+def assert_exercise_not_combination(cur, exercise_id: int) -> None:
+ cur.execute(
+ "SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s",
+ (exercise_id,),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Übung nicht gefunden")
+ if str(r2d(row).get("exercise_kind") or "simple").strip().lower() == "combination":
+ raise HTTPException(
+ status_code=400,
+ detail="Kombinationsübungen unterstützen keine Varianten.",
+ )
+
+
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
@@ -933,6 +1096,48 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
)
exercise["media"] = [r2d(r) for r in cur.fetchall()]
+ mp_raw = exercise.get("method_profile")
+ exercise["method_profile"] = mp_raw if isinstance(mp_raw, dict) else {}
+ exercise["exercise_kind"] = str(exercise.get("exercise_kind") or "simple").strip().lower()
+
+ exercise["combination_slots"] = []
+ if exercise["exercise_kind"] == "combination":
+ cur.execute(
+ """SELECT id, slot_index, title FROM combination_exercise_slots
+ WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""",
+ (exercise_id,),
+ )
+ slot_rows = [r2d(r) for r in cur.fetchall()]
+ slots_out: List[dict] = []
+ for sr in slot_rows:
+ slot_pk = sr["id"]
+ cur.execute(
+ """SELECT candidate_exercise_id FROM combination_slot_candidates
+ WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""",
+ (slot_pk,),
+ )
+ crows = cur.fetchall()
+ cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows]
+ cand_meta: Dict[int, Optional[str]] = {}
+ if cids:
+ ph = ",".join(["%s"] * len(cids))
+ cur.execute(
+ f"SELECT id, title FROM exercises WHERE id IN ({ph})",
+ tuple(cids),
+ )
+ cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()}
+ slots_out.append(
+ {
+ "slot_index": sr["slot_index"],
+ "title": sr.get("title"),
+ "candidate_exercise_ids": cids,
+ "candidates": [
+ {"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids
+ ],
+ }
+ )
+ exercise["combination_slots"] = slots_out
+
return exercise
@@ -1528,6 +1733,10 @@ def list_exercises(
default=False,
description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)",
),
+ exercise_kind_any: list[str] = Query(
+ default=[],
+ description="ODER: mind. einer dieser Übungsarten — simple oder combination",
+ ),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
@@ -1558,6 +1767,21 @@ def list_exercises(
where.append("e.created_by = %s")
params.append(profile_id)
+ ek_filtered: List[str] = []
+ if exercise_kind_any:
+ for raw in exercise_kind_any:
+ s = str(raw or "").strip().lower()
+ if not s:
+ continue
+ if s not in ("simple", "combination"):
+ raise HTTPException(status_code=400, detail="exercise_kind_any: nur simple oder combination")
+ if s not in ek_filtered:
+ ek_filtered.append(s)
+ if ek_filtered:
+ ph = ",".join(["%s"] * len(ek_filtered))
+ where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
+ params.extend(ek_filtered)
+
vis_list = _merge_str_any(visibility_any, visibility)
if vis_list:
ph = ",".join(["%s"] * len(vis_list))
@@ -1776,6 +2000,7 @@ def list_exercises(
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
query = f"""
SELECT e.id, e.title, e.summary, e.visibility, e.status,
+ e.exercise_kind, e.method_archetype,
e.created_by, p.name as creator_name,
e.club_id, c.name as club_name,
e.created_at, e.updated_at,
@@ -1829,6 +2054,7 @@ def list_exercises(
out = []
for r in rows:
d = r2d(r)
+ d["exercise_kind"] = str(d.get("exercise_kind") or "simple").strip().lower()
pfn = d.get("primary_focus_name")
d["focus_area"] = pfn
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
@@ -1913,16 +2139,39 @@ def create_exercise(
cur, profile_id, tenant.global_role, body.visibility, club_id
)
+ kind_clean = str(body.exercise_kind or "simple").strip().lower()
+ if kind_clean not in ("simple", "combination"):
+ raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
+
+ prof_dict = _normalize_method_profile_store(body.method_profile)
+ arch_raw = body.method_archetype
+ arch_val = (arch_raw.strip() if isinstance(arch_raw, str) and arch_raw.strip() else None)
+ _validate_archetype_for_kind(kind_clean, arch_val)
+
+ slots_norm: List[Tuple[int, Optional[str], List[int]]] = []
+ if kind_clean == "combination":
+ slots_norm = _validate_and_normalize_combination_slots_payload(
+ cur, tenant, body.combination_slots or []
+ )
+ if not slots_norm:
+ raise HTTPException(
+ status_code=400,
+ detail="Kombinationsübung: mindestens eine Station mit Übungen nötig",
+ )
+
+ mp_json = Json(prof_dict if kind_clean == "combination" else {})
+ arch_db = arch_val if kind_clean == "combination" else None
+
# Equipment als JSONB
equipment_json = json.dumps(body.equipment) if body.equipment else None
- # INSERT
cur.execute(
"""INSERT INTO exercises
(title, summary, goal, execution, preparation, trainer_notes,
duration_min, duration_max, group_size_min, group_size_max,
- equipment, visibility, status, created_by, club_id)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+ equipment, visibility, status, created_by, club_id,
+ exercise_kind, method_archetype, method_profile)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""",
(
body.title, body.summary, body.goal, body.execution,
@@ -1931,11 +2180,15 @@ def create_exercise(
body.group_size_min, body.group_size_max,
equipment_json,
body.visibility, body.status, profile_id, club_id,
- )
+ kind_clean, arch_db, mp_json,
+ ),
)
row = cur.fetchone()
exercise_id = row['id'] if isinstance(row, dict) else row[0]
+ if kind_clean == "combination":
+ replace_combination_slots(cur, tenant, exercise_id, slots_norm)
+
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
if (body.visibility or "").strip().lower() == "club":
@@ -1963,7 +2216,7 @@ def update_exercise(
cur = get_cursor(conn)
cur.execute(
- f"""SELECT created_by, visibility, club_id,
+ f"""SELECT created_by, visibility, club_id, exercise_kind, method_archetype, method_profile,
{", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
FROM exercises WHERE id = %s""",
(exercise_id,),
@@ -1993,6 +2246,84 @@ def update_exercise(
default_official_copy = data.pop("default_official_media_copyright", None)
default_club_copy = data.pop("default_club_media_copyright", None)
+ combo_slots_provided = "combination_slots" in data
+ combo_slots_payload = data.pop("combination_slots", None)
+
+ ec_kind_was = str(rd_full.get("exercise_kind") or "simple").strip().lower()
+ ek_provided = "exercise_kind" in data
+ next_kind = ec_kind_was
+ if ek_provided:
+ next_kind = str(data.pop("exercise_kind") or "simple").strip().lower()
+
+ arche_provided = "method_archetype" in data
+ meth_prof_provided = "method_profile" in data
+
+ next_ma_db = rd_full.get("method_archetype")
+ if isinstance(next_ma_db, str):
+ next_ma_db = next_ma_db.strip() or None
+ else:
+ next_ma_db = None
+
+ mp_row = rd_full.get("method_profile")
+ next_mp_db = mp_row if isinstance(mp_row, dict) else {}
+
+ if ek_provided and next_kind not in ("simple", "combination"):
+ raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
+
+ if arche_provided:
+ va = data.pop("method_archetype")
+ if va is None or (isinstance(va, str) and not va.strip()):
+ next_ma_db = None
+ elif isinstance(va, str):
+ next_ma_db = va.strip() or None
+ else:
+ next_ma_db = None
+
+ if meth_prof_provided:
+ next_mp_db = _normalize_method_profile_store(data.pop("method_profile"))
+
+ if next_kind == "simple":
+ next_ma_db = None
+ next_mp_db = {}
+
+ _validate_archetype_for_kind(next_kind, next_ma_db)
+
+ if ec_kind_was == "simple" and next_kind == "combination":
+ if not combo_slots_provided:
+ raise HTTPException(
+ status_code=400,
+ detail='Umschalten auf Kombinationsübung: Feld "combination_slots" ist erforderlich',
+ )
+
+ combo_slots_normalized: Optional[List[Tuple[int, Optional[str], List[int]]]] = None
+ if combo_slots_provided:
+ if next_kind != "combination":
+ raise HTTPException(
+ status_code=400,
+ detail="combination_slots nur bei exercise_kind=combination erlaubt",
+ )
+ slots_in_raw = combo_slots_payload if combo_slots_payload is not None else []
+ slots_in: List[CombinationSlotIn] = []
+ for s in slots_in_raw:
+ if isinstance(s, CombinationSlotIn):
+ slots_in.append(s)
+ elif isinstance(s, dict):
+ slots_in.append(CombinationSlotIn(**s))
+ else:
+ raise HTTPException(status_code=400, detail="Ungültige combination_slots Payload-Struktur")
+ combo_slots_normalized = _validate_and_normalize_combination_slots_payload(
+ cur, tenant, slots_in
+ )
+ if not combo_slots_normalized:
+ raise HTTPException(status_code=400, detail="Kombinationsübung: mindestens eine Station mit Übungen")
+
+ update_combo_cols = (
+ ek_provided
+ or arche_provided
+ or meth_prof_provided
+ or (next_kind == "simple" and ec_kind_was != "simple")
+ )
+
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
for fld in RICH_HTML_EXERCISE_FIELDS:
if fld not in data:
@@ -2064,11 +2395,27 @@ def update_exercise(
fields.append("equipment = %s")
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
+ if update_combo_cols:
+ if ek_provided:
+ fields.append("exercise_kind = %s")
+ params.append(next_kind)
+ fields.append("method_archetype = %s")
+ params.append(next_ma_db)
+ fields.append("method_profile = %s")
+ params.append(Json(next_mp_db))
+
if fields:
fields.append("updated_at = NOW()")
params.append(exercise_id)
query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s"
cur.execute(query, params)
+ elif combo_slots_normalized is not None:
+ cur.execute("UPDATE exercises SET updated_at = NOW() WHERE id = %s", (exercise_id,))
+
+ if combo_slots_normalized is not None:
+ replace_combination_slots(cur, tenant, exercise_id, combo_slots_normalized)
+ elif ec_kind_was == "combination" and next_kind == "simple":
+ wipe_combination_structure(cur, exercise_id)
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
try:
@@ -2144,6 +2491,7 @@ def reorder_exercise_variants(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
+ assert_exercise_not_combination(cur, exercise_id)
cur.execute(
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
@@ -2178,6 +2526,7 @@ def create_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
+ assert_exercise_not_combination(cur, exercise_id)
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
eq_json = _variant_equipment_json(body.equipment_changes)
@@ -2242,6 +2591,7 @@ def update_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
+ assert_exercise_not_combination(cur, exercise_id)
old = _fetch_variant_row(cur, exercise_id, variant_id)
if "variant_name" in data and data["variant_name"] is not None:
@@ -2323,6 +2673,7 @@ def delete_exercise_variant(
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
+ assert_exercise_not_combination(cur, exercise_id)
_fetch_variant_row(cur, exercise_id, variant_id)
cur.execute(
diff --git a/backend/version.py b/backend/version.py
index 03d94db..4012a16 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.98"
+APP_VERSION = "0.8.99"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260512055"
+DB_SCHEMA_VERSION = "20260512056"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451)
+ "exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1)
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.99",
+ "date": "2026-05-12",
+ "changes": [
+ "exercises Phase 2 (Kombinationsübungen): Migration 056 (`exercise_kind`, `method_archetype`, `method_profile`; Tabellen Slots/Kandidaten); CRUD über POST/PUT/GET Übung mit `combination_slots`; Liste-Filter `exercise_kind_any`; Varianten-Endpoints verbieten `exercise_kind=combination`.",
+ ],
+ },
{
"version": "0.8.98",
"date": "2026-05-12",
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index bcf9e15..f2fb54d 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -225,6 +225,7 @@ export default function ExercisePickerModal({
...queryBase,
include_archived: true,
include_variants: true,
+ exercise_kind_any: ['simple'],
limit: PAGE_SIZE,
offset: 0,
})
@@ -253,6 +254,7 @@ export default function ExercisePickerModal({
...queryBase,
include_archived: true,
include_variants: true,
+ exercise_kind_any: ['simple'],
limit: PAGE_SIZE,
offset,
})
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx
index c4781d4..a3403ea 100644
--- a/frontend/src/pages/ExerciseDetailPage.jsx
+++ b/frontend/src/pages/ExerciseDetailPage.jsx
@@ -136,6 +136,42 @@ function ExerciseDetailPage() {
{meta.length > 0 && {meta.join(' · ')}
}
+ {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
+ Array.isArray(exercise.combination_slots) &&
+ exercise.combination_slots.length > 0 && (
+
+ Stationen und Übungspools
+ {exercise.method_archetype ? (
+
+ Archetyp: {String(exercise.method_archetype)}
+
+ ) : null}
+
+ {exercise.combination_slots.map((s) => (
+
+
+ Station {s.slot_index != null ? s.slot_index : '?'}{s.title ? ` — ${s.title}` : ''}
+
+
+ {(s.candidates && s.candidates.length
+ ? s.candidates
+ : (s.candidate_exercise_ids || []).map((id) => ({
+ exercise_id: id,
+ title: null,
+ }))
+ ).map((c) => (
+
+ Übung #{c.exercise_id}
+ {c.title ? ` — ${c.title}` : ''}
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
{exercise.goal && (
Ziel
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index c54de8a..d49c38a 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -30,6 +30,29 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' },
]
+/** An API `method_archetype` (Backend `COMBINATION_ARCHETYPE_IDS`) */
+const COMBINATION_ARCHETYPE_OPTIONS = [
+ { id: 'sequence_linear', label: 'Lineare Sequenz' },
+ { id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' },
+ { id: 'circuit_all_parallel', label: 'Parallele Stationen' },
+ { id: 'station_parcour', label: 'Parcours' },
+ { id: 'pair_superset', label: 'Partner- / Paarwechsel' },
+ { id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' },
+ { id: 'free_method_block', label: 'Freier Methodenblock' },
+]
+
+function comboSlotsFromDetail(exercise) {
+ const raw = exercise?.combination_slots
+ if (!Array.isArray(raw) || raw.length === 0) {
+ return [{ slot_index: 0, title: '', idsText: '' }]
+ }
+ return raw.map((s, i) => ({
+ slot_index: s.slot_index != null ? Number(s.slot_index) : i,
+ title: s.title != null ? String(s.title) : '',
+ idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '',
+ }))
+}
+
function emptyVariantDraft() {
return {
variant_name: '',
@@ -249,6 +272,10 @@ function emptyForm() {
visibility: 'private',
status: 'draft',
skills: [],
+ exercise_kind: 'simple',
+ method_archetype: '',
+ method_profile_json: '{}',
+ combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
}
}
@@ -291,6 +318,18 @@ function detailToForm(exercise) {
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
})) || [],
+ exercise_kind:
+ String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
+ ? 'combination'
+ : 'simple',
+ method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
+ method_profile_json:
+ typeof exercise.method_profile === 'object' &&
+ exercise.method_profile != null &&
+ !Array.isArray(exercise.method_profile)
+ ? JSON.stringify(exercise.method_profile, null, 2)
+ : '{}',
+ combination_slots: comboSlotsFromDetail(exercise),
}
}
@@ -949,6 +988,180 @@ function ExerciseFormPage() {
/>
+
+
Übungstyp
+
+ Art
+ {
+ const nk = e.target.value
+ setFormDirty(true)
+ setFormData((prev) => ({
+ ...prev,
+ exercise_kind: nk,
+ ...(nk === 'simple'
+ ? {
+ method_archetype: '',
+ method_profile_json: '{}',
+ combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
+ }
+ : {}),
+ }))
+ }}
+ >
+ Einzelübung
+ Kombinationsübung (Stationen / Pool)
+
+
+ {formData.exercise_kind === 'combination' ? (
+ <>
+
+ Methoden-Archetyp (optional)
+ updateFormField('method_archetype', e.target.value)}
+ >
+ — später wählen —
+ {COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+ Ablaufprofil (JSON, optional)
+ updateFormField('method_profile_json', e.target.value)}
+ spellCheck={false}
+ placeholder='{"work_seconds":45,"rest_seconds":15,"rounds":3}'
+ />
+
+
+
Stationen
+
+ Index (Reihenfolge), optional Stationstitel und kommaseparierte IDs von{' '}
+ Einzelübungen im Pool (nur Übungen mit Art „Einzelübung“).
+
+ {(formData.combination_slots || []).map((row, idx) => (
+
+
+
+ Idx.
+
+ {
+ const next = [...(formData.combination_slots || [])]
+ const v = e.target.value
+ next[idx] = {
+ ...row,
+ slot_index: v === '' ? '' : parseInt(v, 10),
+ }
+ updateFormField('combination_slots', next)
+ }}
+ />
+
+
+
+ Titel
+
+ {
+ const next = [...(formData.combination_slots || [])]
+ next[idx] = { ...row, title: e.target.value }
+ updateFormField('combination_slots', next)
+ }}
+ />
+
+
+
+ Übungs-IDs
+
+ {
+ const next = [...(formData.combination_slots || [])]
+ next[idx] = { ...row, idsText: e.target.value }
+ updateFormField('combination_slots', next)
+ }}
+ placeholder="z. B. 12, 34, 56"
+ />
+
+
{
+ const prev = formData.combination_slots || []
+ const next = prev.filter((_, j) => j !== idx)
+ updateFormField(
+ 'combination_slots',
+ next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
+ )
+ }}
+ >
+ Entf.
+
+
+ ))}
+
{
+ const cur = formData.combination_slots || []
+ const ixList = cur
+ .map((r) =>
+ typeof r.slot_index === 'number' && !Number.isNaN(r.slot_index) ? r.slot_index : null,
+ )
+ .filter((n) => n != null)
+ const nextIx = ixList.length ? Math.max(...ixList) + 1 : 0
+ updateFormField('combination_slots', [
+ ...cur,
+ { slot_index: nextIx, title: '', idsText: '' },
+ ])
+ }}
+ >
+ + Station
+
+
+ >
+ ) : null}
+
+
Ziel *
- {isEdit && (
+ {isEdit && formData.exercise_kind !== 'combination' && (
Übungsvarianten
@@ -1380,7 +1593,7 @@ function ExerciseFormPage() {
)}
- {isEdit && (
+ {isEdit && formData.exercise_kind !== 'combination' && (
Progressionsgraph
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index aed1082..5f42b15 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -1408,6 +1408,11 @@ function ExercisesListPage() {
{typeNames.map((name) => (
{name}
))}
+ {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
+
+ Kombination
+
+ ) : null}
{exercise.summary && String(exercise.summary).trim() ? (
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index d7fa338..6536a76 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -451,7 +451,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
- return {
+ const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goalHtml.trim() ? goalHtml : null,
@@ -478,8 +478,65 @@ export function buildExerciseApiPayload(formData, extras = {}) {
visibility: formData.visibility || 'private',
status: formData.status || 'draft',
club_id: formData.club_id ?? null,
+ exercise_kind:
+ String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
+ ? 'combination'
+ : 'simple',
...extras,
}
+
+ const isCombo = payload.exercise_kind === 'combination'
+
+ if (isCombo) {
+ let mpObj = {}
+ const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
+ if (mpRaw) {
+ try {
+ const parsed = JSON.parse(mpRaw)
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
+ }
+ mpObj = parsed
+ } catch (e) {
+ if (e instanceof SyntaxError) {
+ throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
+ }
+ throw e
+ }
+ }
+
+ const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
+ const combination_slots = []
+ for (let i = 0; i < slotRows.length; i += 1) {
+ const row = slotRows[i] || {}
+ const ix =
+ row.slot_index === '' || row.slot_index == null ? i : parseInt(row.slot_index, 10)
+ if (Number.isNaN(ix) || ix < 0 || ix > 99) {
+ throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
+ }
+ const idsText = typeof row.idsText === 'string' ? row.idsText : ''
+ const candidate_exercise_ids = idsText
+ .split(/[\s,;]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((s) => parseInt(s, 10))
+ .filter((n) => Number.isFinite(n))
+ combination_slots.push({
+ slot_index: ix,
+ title: (typeof row.title === 'string' && row.title.trim()) || null,
+ candidate_exercise_ids,
+ })
+ }
+
+ payload.method_archetype = (formData.method_archetype || '').trim() || null
+ payload.method_profile = mpObj
+ payload.combination_slots = combination_slots
+ } else {
+ payload.method_archetype = null
+ payload.method_profile = {}
+ }
+
+ return payload
}
export async function uploadExerciseMedia(exerciseId, formData) {
--
2.43.0
From 3dc4c9c79ea1d090f724fddbaf1d7c1189d75ba9 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 06:30:53 +0200
Subject: [PATCH 11/27] feat(exercises): update to version 0.8.100 and enhance
combination exercise handling
- Bumped app version to 0.8.100, reflecting recent updates.
- Improved validation logic for combination exercises in the backend, ensuring proper handling of exercise variants.
- Enhanced frontend components, including the ExercisePickerModal, to support filtering and displaying combination exercises.
- Updated API payloads and utility functions to accommodate new exercise types and their properties.
Co-Authored-By: Claude Sonnet 4.6
---
backend/routers/training_planning.py | 25 +++-
backend/version.py | 11 +-
.../src/components/ExercisePickerModal.jsx | 25 +++-
.../ExerciseProgressionGraphPanel.jsx | 7 +-
.../components/TrainingUnitSectionsEditor.jsx | 18 ++-
frontend/src/utils/trainingPlanUtils.js | 4 +-
.../src/utils/trainingUnitSectionsForm.js | 126 ++++++++++++------
7 files changed, 162 insertions(+), 54 deletions(-)
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index 4294d5d..a6ace71 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -40,12 +40,28 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]:
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
+ if not exercise_id:
+ if variant_id:
+ raise HTTPException(
+ status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
+ )
+ return
+ cur.execute(
+ "SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s",
+ (int(exercise_id),),
+ )
+ ek_row = cur.fetchone()
+ if not ek_row:
+ raise HTTPException(status_code=400, detail="Übung nicht gefunden")
+ if str(r2d(ek_row).get("exercise_kind") or "simple").strip().lower() == "combination":
+ if variant_id:
+ raise HTTPException(
+ status_code=400,
+ detail="Kombinationsübungen haben keine Varianten — bitte exercise_variant_id weglassen",
+ )
+ return
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),
@@ -434,6 +450,7 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
"""
SELECT tusi.*,
e.title AS exercise_title,
+ e.exercise_kind AS exercise_kind,
e.summary AS exercise_summary,
(
SELECT fa.name FROM exercise_focus_areas efa
diff --git a/backend/version.py b/backend/version.py
index 4012a16..9f8d5b5 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.99"
+APP_VERSION = "0.8.100"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512056"
@@ -24,7 +24,7 @@ MODULE_VERSIONS = {
"exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056)
"training_units": "0.2.0",
"training_programs": "0.1.0",
- "planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1)
+ "planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.100",
+ "date": "2026-05-12",
+ "changes": [
+ "Planungs-API/UI: Kombinationsübungen in Trainingsseinheiten (exercise_kind in Sektions-Responses; PATCH verbietet exercise_variant_id für combination); ExercisePicker ohne simple-only Filter, Badge Kombination.",
+ ],
+ },
{
"version": "0.8.99",
"date": "2026-05-12",
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index f2fb54d..e93a896 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -32,6 +32,8 @@ export default function ExercisePickerModal({
multiSelect = false,
onSelectExercises = null,
enableQuickCreateDraft = false,
+ /** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
+ exerciseKindAny = undefined,
}) {
const { user } = useAuth()
const [catalogs, setCatalogs] = useState({
@@ -213,8 +215,14 @@ export default function ExercisePickerModal({
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi
+ if (
+ Array.isArray(exerciseKindAny) &&
+ exerciseKindAny.length > 0
+ ) {
+ q.exercise_kind_any = exerciseKindAny
+ }
return q
- }, [filters, debouncedSearch, debouncedAi])
+ }, [filters, debouncedSearch, debouncedAi, exerciseKindAny])
const reload = useCallback(async () => {
if (!open || !catalogsReady) return
@@ -225,7 +233,6 @@ export default function ExercisePickerModal({
...queryBase,
include_archived: true,
include_variants: true,
- exercise_kind_any: ['simple'],
limit: PAGE_SIZE,
offset: 0,
})
@@ -254,7 +261,6 @@ export default function ExercisePickerModal({
...queryBase,
include_archived: true,
include_variants: true,
- exercise_kind_any: ['simple'],
limit: PAGE_SIZE,
offset,
})
@@ -608,6 +614,19 @@ export default function ExercisePickerModal({
{ex.focus_area}
)}
+ {(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
+
+ Kombination
+
+ ) : null}
>
)
if (multiSelect) {
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index f9ee3fd..2100409 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -1055,7 +1055,12 @@ export default function ExerciseProgressionGraphPanel({
)}
- setPickContext(null)} onSelectExercise={applyPickedExercise} />
+ setPickContext(null)}
+ onSelectExercise={applyPickedExercise}
+ exerciseKindAny={['simple']}
+ />
)
}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index bd812c7..7db3da4 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -837,9 +837,11 @@ export default function TrainingUnitSectionsEditor({
const variantOpts = Array.isArray(it.variants) ? it.variants : []
const exTitle =
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
+ const isCombination =
+ String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const annotPrev = truncatePreview(it.notes || '', 220)
const annotHasText = Boolean((it.notes || '').trim())
- const hasVariants = variantOpts.length > 0 && it.exercise_id
+ const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id
const variantIdPeek =
it.exercise_variant_id === '' || it.exercise_variant_id == null
? undefined
@@ -893,6 +895,20 @@ export default function TrainingUnitSectionsEditor({
) : (
Keine Übung gewählt
)}
+ {isCombination ? (
+
+ Kombination
+
+ ) : null}
{planningCompactLegend && curMn ? (
) : null}
diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js
index 30e896e..a81e380 100644
--- a/frontend/src/utils/trainingPlanUtils.js
+++ b/frontend/src/utils/trainingPlanUtils.js
@@ -71,7 +71,9 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
if (eid === '' || eid == null || Number.isNaN(Number(eid))) {
return null
}
- const vid = it.exercise_variant_id
+ const isCombo =
+ String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
+ const vid = isCombo ? null : it.exercise_variant_id
let actual =
durationOverridesByItemId[String(it.id)]?.actual_duration_min ??
it.actual_duration_min
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index f99851e..e9d2773 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -9,6 +9,7 @@ export function exerciseRow() {
item_type: 'exercise',
exercise_id: '',
exercise_variant_id: '',
+ exercise_kind: 'simple',
exercise_title: '',
variants: [],
planned_duration_min: '',
@@ -23,22 +24,31 @@ export function exerciseRow() {
export async function hydrateExercisePlanningRow(exercise) {
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
let title = exercise?.title || ''
+ let exerciseKind = exercise?.exercise_kind
const id = exercise?.id
if (!id) return null
let meta = {}
- if (!variants.length) {
+
+ async function fetchFull() {
try {
- const full = await api.getExercise(id)
- variants = Array.isArray(full?.variants) ? full.variants : []
- title = full?.title || title
- meta = {
- exercise_visibility: full?.visibility || 'private',
- exercise_club_id: full?.club_id ?? null,
- exercise_created_by: full?.created_by ?? null,
- exercise_status: full?.status || 'draft',
- }
+ return await api.getExercise(id)
} catch {
- variants = []
+ return null
+ }
+ }
+
+ if (!variants.length) {
+ const full = await fetchFull()
+ if (full) {
+ variants = Array.isArray(full.variants) ? full.variants : []
+ title = full.title || title
+ if (exerciseKind == null) exerciseKind = full.exercise_kind
+ meta = {
+ exercise_visibility: full.visibility || 'private',
+ exercise_club_id: full.club_id ?? null,
+ exercise_created_by: full.created_by ?? null,
+ exercise_status: full.status || 'draft',
+ }
}
} else {
meta = {
@@ -47,25 +57,37 @@ export async function hydrateExercisePlanningRow(exercise) {
exercise_created_by: exercise?.created_by ?? null,
exercise_status: exercise?.status ?? null,
}
- if (meta.exercise_visibility == null || meta.exercise_created_by == null) {
- try {
- const full = await api.getExercise(id)
- if (meta.exercise_visibility == null) meta.exercise_visibility = full?.visibility || 'private'
- if (meta.exercise_club_id == null) meta.exercise_club_id = full?.club_id ?? null
- if (meta.exercise_created_by == null) meta.exercise_created_by = full?.created_by ?? null
- if (meta.exercise_status == null) meta.exercise_status = full?.status || 'draft'
- } catch {
- /* keep partial meta */
+ if (
+ meta.exercise_visibility == null ||
+ meta.exercise_created_by == null ||
+ exerciseKind == null
+ ) {
+ const full = await fetchFull()
+ if (full) {
+ if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
+ if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
+ if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null
+ if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft'
+ if (exerciseKind == null) exerciseKind = full.exercise_kind
+ if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : []
}
}
meta.exercise_visibility = meta.exercise_visibility || 'private'
meta.exercise_status = meta.exercise_status || 'draft'
}
+
const row = exerciseRow()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
- row.variants = variants
+ row.exercise_kind =
+ String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple'
+ if (row.exercise_kind === 'combination') {
+ row.variants = []
+ row.exercise_variant_id = ''
+ } else {
+ row.variants = variants
+ }
Object.assign(row, meta)
return row
}
@@ -106,10 +128,13 @@ export function normalizeUnitToForm(fullUnit) {
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
+ const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
+ const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
- exercise_variant_id: it.exercise_variant_id ?? '',
+ exercise_kind: isCombo ? 'combination' : 'simple',
+ exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
@@ -141,23 +166,28 @@ export function normalizeUnitToForm(fullUnit) {
{
title: 'Übungen',
guidance_notes: '',
- items: fullUnit.exercises.map((ex) => ({
- item_type: 'exercise',
- exercise_id: ex.exercise_id,
- exercise_variant_id: ex.exercise_variant_id ?? '',
- exercise_title: ex.exercise_title || '',
- variants: [],
- planned_duration_min:
- ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
- ? String(ex.planned_duration_min)
- : '',
- actual_duration_min:
- ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
- ? String(ex.actual_duration_min)
- : '',
- notes: ex.notes ?? '',
- modifications: ex.modifications ?? '',
- })),
+ items: fullUnit.exercises.map((ex) => {
+ const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
+ const isCombo = ek === 'combination'
+ return {
+ item_type: 'exercise',
+ exercise_kind: ek,
+ exercise_id: ex.exercise_id,
+ exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''),
+ exercise_title: ex.exercise_title || '',
+ variants: [],
+ planned_duration_min:
+ ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
+ ? String(ex.planned_duration_min)
+ : '',
+ actual_duration_min:
+ ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
+ ? String(ex.actual_duration_min)
+ : '',
+ notes: ex.notes ?? '',
+ modifications: ex.modifications ?? '',
+ }
+ }),
},
]
}
@@ -181,6 +211,7 @@ export async function enrichSectionsWithVariants(sections) {
const ex = await api.getExercise(id)
cache.set(id, {
title: ex.title || '',
+ exercise_kind: String(ex.exercise_kind || 'simple').toLowerCase().trim(),
variants: Array.isArray(ex.variants) ? ex.variants : [],
visibility: ex.visibility || 'private',
club_id: ex.club_id ?? null,
@@ -190,6 +221,7 @@ export async function enrichSectionsWithVariants(sections) {
} catch {
cache.set(id, {
title: '',
+ exercise_kind: 'simple',
variants: [],
visibility: 'private',
club_id: null,
@@ -206,11 +238,15 @@ export async function enrichSectionsWithVariants(sections) {
if (!it.exercise_id) return it
const c = cache.get(it.exercise_id)
if (!c) return it
+ const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
+ const isCombo = ek === 'combination'
return {
...it,
+ exercise_kind: isCombo ? 'combination' : 'simple',
exercise_title: it.exercise_title || c.title,
+ exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
variants:
- Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
+ isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
exercise_visibility: c.visibility,
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
@@ -246,7 +282,8 @@ export function buildSectionsPayload(sections) {
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
return null
}
- const vid = it.exercise_variant_id
+ const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
+ const vid = isCombo ? null : it.exercise_variant_id
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowEx = {
item_type: 'exercise',
@@ -320,7 +357,12 @@ export async function insertTrainingModuleIntoPlanningSections({
if (!hydrated) continue
hydrated.source_training_module_id = midNum
hydrated.source_module_title = modTitle
- if (mi.exercise_variant_id) hydrated.exercise_variant_id = String(mi.exercise_variant_id)
+ if (
+ hydrated.exercise_kind !== 'combination' &&
+ mi.exercise_variant_id
+ ) {
+ hydrated.exercise_variant_id = String(mi.exercise_variant_id)
+ }
hydrated.planned_duration_min =
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
? String(mi.planned_duration_min)
--
2.43.0
From 919910d52a0b3d292c8281ca1cdb28d7f2ee3d06 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 06:36:50 +0200
Subject: [PATCH 12/27] feat(version): bump to 0.8.101 and update exercise
module versions
- Updated app version to 0.8.101, reflecting recent enhancements.
- Incremented exercise module version to 2.24.1, improving handling of combination exercises.
- Added changelog entry for new features related to training-coach functionality in combination exercises.
Co-Authored-By: Claude Sonnet 4.6
---
backend/version.py | 11 +-
.../src/components/CombinationCoachSlots.jsx | 226 ++++++++++++++++++
.../src/components/ExerciseFullContent.jsx | 10 +
.../src/constants/combinationArchetypes.js | 61 +++++
frontend/src/pages/ExerciseFormPage.jsx | 12 +-
5 files changed, 307 insertions(+), 13 deletions(-)
create mode 100644 frontend/src/components/CombinationCoachSlots.jsx
create mode 100644 frontend/src/constants/combinationArchetypes.js
diff --git a/backend/version.py b/backend/version.py
index 9f8d5b5..c1144b6 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.100"
+APP_VERSION = "0.8.101"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512056"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056)
+ "exercises": "2.24.1", # Coach/Kombination: Stationen laden Einzelübungen + Archetyp-Hilfstext (Frontend ExerciseFullContent)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.101",
+ "date": "2026-05-12",
+ "changes": [
+ "Training-Coach bei Kombinationsübungen: Stationen/Kandidaten mit geladenem Katalog (Kurzbeschreibung, aufklappbar Ablauf/Trainerhinweise); Archetyp-spezifischer Coach-Hilfstext; Archetyp-Labels aus `combinationArchetypes.js`.",
+ ],
+ },
{
"version": "0.8.100",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
new file mode 100644
index 0000000..65f9f14
--- /dev/null
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -0,0 +1,226 @@
+/**
+ * Kombinationsübung im Coach: Archetyp-Hinweis + Katalog-Inhalt je Slot/Kandidat.
+ */
+import React, { useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+import ExerciseRichTextBlock from './ExerciseRichTextBlock'
+import {
+ archetypeCoachHint,
+ combinationArchetypeLabel,
+ sortCombinationSlotsForDisplay,
+} from '../constants/combinationArchetypes'
+
+export default function CombinationCoachSlots({ combinationSlots, methodArchetype }) {
+ const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
+
+ const candidateIds = useMemo(() => {
+ const set = new Set()
+ for (const s of slots) {
+ for (const id of s.candidate_exercise_ids || []) {
+ const n = typeof id === 'number' ? id : parseInt(String(id), 10)
+ if (Number.isFinite(n)) set.add(n)
+ }
+ }
+ return [...set]
+ }, [slots])
+
+ const [byId, setById] = useState({})
+ const [errById, setErrById] = useState({})
+ const [loadingIds, setLoadingIds] = useState(false)
+
+ const sig = candidateIds.slice().sort((a, b) => a - b).join(',')
+
+ useEffect(() => {
+ let cancelled = false
+ setById({})
+ setErrById({})
+
+ if (candidateIds.length === 0) {
+ setLoadingIds(false)
+ return () => {
+ cancelled = true
+ }
+ }
+
+ setLoadingIds(true)
+ Promise.all(
+ candidateIds.map((id) =>
+ api.getExercise(id).then(
+ (ex) => ({ id, ok: true, ex }),
+ (e) => ({
+ id,
+ ok: false,
+ err: e?.message || String(e),
+ }),
+ ),
+ ),
+ ).then((results) => {
+ if (cancelled) return
+ const map = {}
+ const emap = {}
+ for (const r of results) {
+ if (r.ok) map[r.id] = r.ex
+ else emap[r.id] = r.err
+ }
+ setById(map)
+ setErrById(emap)
+ setLoadingIds(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [sig])
+
+ const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
+ const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
+
+ return (
+
+
+ Kombination · Stationen & Einzelübungen
+
+ {archDisplay ? (
+
+ {archDisplay}
+ {archeKey && archDisplay !== archeKey ? (
+
+ ({archeKey})
+
+ ) : null}
+
+ ) : null}
+
+ {archetypeCoachHint(archeKey)}
+
+
+ {!slots.length ? (
+ Keine Stationen hinterlegt.
+ ) : (
+
+ {slots.map((slot, si) => {
+ const candIdsRaw = slot.candidate_exercise_ids || []
+ const candIds = candIdsRaw
+ .map((id) => (typeof id === 'number' ? id : parseInt(String(id), 10)))
+ .filter((n) => Number.isFinite(n))
+
+ const slotTitle =
+ (slot.title && String(slot.title).trim()) ||
+ (candIds.length <= 1 && slot.candidates?.[0]?.title) ||
+ `Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
+
+ return (
+
+ 1 ? '6px' : '8px' }}>
+ {slotTitle}
+
+ {candIds.length === 0 ? (
+ Keine Übung zugeordnet.
+ ) : (
+
+ {candIds.map((cid, ci) => {
+ const ex = byId[cid]
+ const err = errById[cid]
+ const candTitleFallback =
+ slot.candidates?.find((c) => Number(c.exercise_id) === cid)?.title ||
+ slot.candidates?.[ci]?.title
+
+ const isAlt = candIds.length > 1
+
+ return (
+
+ {isAlt ? (
+
+ {candIds.length > 2 ? `Alternative ${ci + 1}` : ci === 0 ? 'Alternative A' : 'Alternative B'}
+
+ ) : null}
+ {!ex && loadingIds ? (
+
+
+ Übung #{cid} laden…
+
+ ) : err ? (
+
+ Übung #{cid}: {err}
+
+ ) : ex ? (
+ <>
+
{ex.title}
+ {ex.summary ? (
+
+
+
+ ) : (
+
+ Keine Kurzbeschreibung im Katalog.
+
+ )}
+ {ex.execution ? (
+
+
+ Ablauf (Detail)
+
+
+
+
+
+ ) : null}
+ {ex.trainer_notes ? (
+
+
+ Hinweise Trainer
+
+
+
+
+
+ ) : null}
+
+
+ Volle Übungsseite
+
+
+ >
+ ) : (
+
+ {candTitleFallback || `Übung #${cid}`}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx
index 288f4d9..ee4833a 100644
--- a/frontend/src/components/ExerciseFullContent.jsx
+++ b/frontend/src/components/ExerciseFullContent.jsx
@@ -5,6 +5,7 @@
import React from 'react'
import { Link } from 'react-router-dom'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
+import CombinationCoachSlots from './CombinationCoachSlots'
function TagRow({ exercise }) {
const tags = []
@@ -76,6 +77,9 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
+ const isCombination =
+ String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
+
return (
{variant ? (
@@ -106,6 +110,12 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
) : null}
) : null}
+ {isCombination && Array.isArray(exercise.combination_slots) ? (
+
+ ) : null}
{exercise.title}
{meta.length > 0 && (
diff --git a/frontend/src/constants/combinationArchetypes.js b/frontend/src/constants/combinationArchetypes.js
new file mode 100644
index 0000000..928c39d
--- /dev/null
+++ b/frontend/src/constants/combinationArchetypes.js
@@ -0,0 +1,61 @@
+/** API `method_archetype`-Werte (Backend `COMBINATION_ARCHETYPE_IDS`). */
+
+export const COMBINATION_ARCHETYPE_OPTIONS = [
+ { id: 'sequence_linear', label: 'Lineare Sequenz' },
+ { id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' },
+ { id: 'circuit_all_parallel', label: 'Parallele Stationen' },
+ { id: 'station_parcour', label: 'Parcours' },
+ { id: 'pair_superset', label: 'Partner- / Paarwechsel' },
+ { id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' },
+ { id: 'free_method_block', label: 'Freier Methodenblock' },
+]
+
+const LABEL_BY_ID = Object.fromEntries(
+ COMBINATION_ARCHETYPE_OPTIONS.map((o) => [String(o.id), o.label]),
+)
+
+/** Coach-/Lesetexte: strukturieren die Erwartung, nicht die komplette Methodik */
+const COACH_HINT_BY_ID = {
+ sequence_linear:
+ 'Station für Station der Reihenfolge nach durchfahren. Pro Abschnitt zuerst klarziehen, dann erst zur nächsten Übung übergehen.',
+ circuit_rotate_time:
+ 'Zirkelsystem nach Zeitfenster drehen oder Gruppe weitergeben. Halte Rotation und Pausen an der Kombi-Beschreibung fest.',
+ circuit_all_parallel:
+ 'Alle Stationen parallel nutzen: Aufteilen, gleicher Zeitraum bzw. Rundenlogik wie in dieser Kombination beschrieben.',
+ station_parcour:
+ 'Parcours: Besuchsreihenfolge und Regeln (z.\u202fB. Stopp-/Wechselpunkte) aus der Kombi-Beschreibung und Stationsnamen.',
+ pair_superset:
+ 'Nach Paar-/Superset-Logik abstimmen: z.\u202fB. Übung A ↔ B oder Abwechseln — Rhythmus an der Kombi oder Stationen ausrichten.',
+ time_domain_interval:
+ 'Strikt an die Zeituhr bzw. Intervallarbeit halten (Arbeit, Pause, Etappen). Kombi beschreibt meist Arbeit–Pause–Schema.',
+ free_method_block:
+ 'Lockerer Stationenblock: Reihenfolge und Verweildauer können flexibel sein — Stationsübungen unten sind die angebotenen Bausteine.',
+}
+
+export function combinationArchetypeLabel(archetypeId) {
+ if (archetypeId == null || String(archetypeId).trim() === '') {
+ return null
+ }
+ const key = String(archetypeId).trim()
+ return LABEL_BY_ID[key] || key
+}
+
+export function archetypeCoachHint(archetypeId) {
+ if (archetypeId == null || String(archetypeId).trim() === '') {
+ return 'Nutze die Stationen wie in der Kombi-Beschreibung oben angelegt.'
+ }
+ const key = String(archetypeId).trim()
+ return COACH_HINT_BY_ID[key] || 'Nutze die Stationen entsprechend dem gewählten Archetyp und der Kombination-Beschreibung.'
+}
+
+export function sortCombinationSlotsForDisplay(slotsRaw) {
+ if (!Array.isArray(slotsRaw) || slotsRaw.length === 0) return []
+ return [...slotsRaw].sort((a, b) => {
+ const ia = Number(a.slot_index)
+ const ib = Number(b.slot_index)
+ const na = Number.isFinite(ia) ? ia : 0
+ const nb = Number.isFinite(ib) ? ib : 0
+ if (na !== nb) return na - nb
+ return String(a.title || '').localeCompare(String(b.title || ''), 'de')
+ })
+}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index d49c38a..ba7a42f 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -14,6 +14,7 @@ import {
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
+import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
@@ -30,17 +31,6 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' },
]
-/** An API `method_archetype` (Backend `COMBINATION_ARCHETYPE_IDS`) */
-const COMBINATION_ARCHETYPE_OPTIONS = [
- { id: 'sequence_linear', label: 'Lineare Sequenz' },
- { id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' },
- { id: 'circuit_all_parallel', label: 'Parallele Stationen' },
- { id: 'station_parcour', label: 'Parcours' },
- { id: 'pair_superset', label: 'Partner- / Paarwechsel' },
- { id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' },
- { id: 'free_method_block', label: 'Freier Methodenblock' },
-]
-
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
if (!Array.isArray(raw) || raw.length === 0) {
--
2.43.0
From 12fd3926b2b238edd33fcb952fc61fd3e3af293d Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 07:16:58 +0200
Subject: [PATCH 13/27] feat(combination-exercises): enhance method profile
integration and update specifications
- Updated app version to 0.8.102, reflecting recent enhancements in combination exercises.
- Introduced structured method profiles for combination exercises, allowing for detailed planning and coaching support.
- Enhanced frontend components to display method profiles in the Exercise and Combination Coach views.
- Updated documentation to include new specifications and implementation details for method archetypes and profiles.
Co-Authored-By: Claude Sonnet 4.6
---
.../docs/functional/SHINKAN_REQUIREMENTS.md | 3 +
...e Kombinationsuebungen Spezifikation V2.md | 134 +++++++++++++-
..._MODULES_AND_COMBINATION_EXERCISES_SPEC.md | 6 +
.../TRAINING_MODULES_IMPLEMENTATION_PLAN.md | 18 +-
backend/version.py | 11 +-
docs/FACHLICHE_NUTZERFUNKTIONEN.md | 8 +-
docs/HANDOVER.md | 14 +-
.../src/components/CombinationCoachSlots.jsx | 38 +++-
.../CombinationMethodProfileEditor.jsx | 175 ++++++++++++++++++
.../src/components/ExerciseFullContent.jsx | 1 +
frontend/src/pages/ExerciseFormPage.jsx | 18 +-
.../src/utils/combinationMethodProfileUi.js | 165 +++++++++++++++++
12 files changed, 564 insertions(+), 27 deletions(-)
create mode 100644 frontend/src/components/CombinationMethodProfileEditor.jsx
create mode 100644 frontend/src/utils/combinationMethodProfileUi.js
diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
index d36098b..84c44ba 100644
--- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
+++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
@@ -9,6 +9,9 @@ Ausführliche fachliche Inhalte:
| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2), **Medien-Archiv** (Abschnitt 2026-05) |
| [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) | Medien-Archiv, Lifecycle, **Inline-Medien** (Spec Abschnitt 11, umgesetzt) |
| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan |
+| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
+| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
+| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
index 5ac7708..88eeceb 100644
--- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
+++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
@@ -1,7 +1,7 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf
-**Stand:** 2026-05-12
+**Stand:** 2026-05-12 · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.5, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** (Abgleich Code vs. Spec)
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:**
@@ -335,6 +335,30 @@ Für jeden Archetyp muss fachlich beschrieben sein:
Die technische Validierung und konkrete Ablage dieser Angaben soll der Coding Agent planen.
+### 5.4 Einordnung: Trainingsformen wie HIIT, Dauer, plyometrisch ↔ Archetypen
+
+**Wichtige Trennung (bleibt fachlich zwingend):**
+
+* **Trainingsmethode im Methodenkatalog** (z. B. HIIT, extensive Intervallmethode, Dauermethode, plyometrisches Training) beschreibt primär den **didaktisch/belastungsmethodischen Kontext („was für eine Trainingsqualität ist das?“)**.
+* **`method_archetype`** beschreibt **das Ablaufmuster („wie soll der Trainer den Block strukturieren und im Coach geführt werden?“)** — insbesondere **Parallelität, Rotation, Sequenz, Zeitdomänen**.
+
+Dieselbe Methode kann in der Praxis mit **mehreren** Archetypen sinnvoll kombiniert sein; das ist **kein Widerspruch**.
+
+| Beispiel (Methoden-/Belastungsbegriff) | Typischer Archetyp (Orientierung), nicht Pflicht |
+| -------------------------------------- | ---------------------------------------------- |
+| **HIIT**, Tabata-ähnlich, Kurzintervalle oft mit hoher Intention | sehr oft **`time_domain_interval`** oder innerhalb eines Zirkels **`circuit_rotate_time`** mit kurzen Arbeitsphasen; Partnerformen zusätzlich **`pair_superset`**. |
+| **Klassisches Intervalltraining** (längere Arbeit, definierte Erholung, N Wiederholungen) | überwiegend **`time_domain_interval`** oder **`circuit_rotate_time`**, wenn die „Intervallschicht“ an Stationen gebunden ist. |
+| **Dauermethode** (überwiegend durchgehend ohne harte Arbeit-Erholung-Takte) | eher **`free_method_block`** oder **`sequence_linear`** mit optionalen Hinweiten; **weniger** `time_domain_interval`, sofern kein geregeltes Intervallschema gemeint ist. |
+| **Plyometrisch**, Explosivblöcke, Technik-Schichtung | häufig **`sequence_linear`** (Progression vor Ort) oder **`circuit_rotate_time`** / **`time_domain_interval`**, wenn klar Zeitfenster oder Wiederholungsblöcke vorgegeben sind. |
+| Rein **organisatorisches** Stationslaufen ohne gemeinsamen Intervalltakt | **`circuit_rotate_time`** oder **`station_parcours`**, **`circuit_all_parallel`**, je nach ob rotiert wird oder parallel aktiv ist. |
+
+**Shinkan-Zielrichtung bleibt trainerzentriert:** Es geht **nicht** um individuelle Pulsonomie eines Sportlers, sondern darum, dass der **Trainer Belastungs- und Erholungsphasen, Durchläufe und ggf. Umlauf-/Parallellogik** vorgibt und der **Coach** diese Vorgaben **sichtbar und später steuerbar** macht
+(siehe **§ 6.3** zu Phasen jenseits „nur Gesamtminuten auf dem Planungsitem“).
+
+### 5.5 Erweiterbarkeit von Archetypen (aktuell zurückgestellt)
+
+Die Idee einer **von Superadmins zur Laufzeit editierbare Archetyp-Registry**, die den Coaching-Modus **völlig frei parametrierbar** macht, wird **zurückgestellt**. Vorerst reicht die **festgelegte, versionierte Liste** kanonischer Archetyp-IDs (**§ 10.2.1**); **weitere Archetypen** können später **wie bisher durch Produkt-/Release entschieden** ergänzt werden (Code oder kuratierter Import), ohne freies „Beliebig-Neuanlegen“ ohne definierten Coach-Verhaltens-Anker.
+
---
## 6. Kombinationsübungen
@@ -370,10 +394,32 @@ Eine Kombinationsübung sollte fachlich enthalten:
* Archetyp,
* Slots / Stationen / Rollen / Schritte,
* mögliche Übungen je Slot,
-* optionale Standardwerte für Dauer, Runden oder Wechsel,
+* strukturierte **Zeitphasen und Belastungs-/Erholungsvorgaben** innerhalb der Kombination (**`method_profile`**, Überblick § 6.3; Details § 10.5) — **zusätzlich** zu allenfalls geplanten **Gesamtminuten am Planungseintrag**,
+* optionale klassische Hinweise zu Dauer, Runden oder Wechsel aus der Übung heraus,
* Hinweise für den Coaching-Modus.
-### 6.3 Slot- und Pool-Logik
+### 6.3 Zeitschicht: Phasen innerhalb der Kombination (Bibliothek) und Anpassungen in der Planung
+
+Ein **einzelnes** Feld „Geplante Minuten für diesen Eintrag in der Einheit“ kann die **innenliegende zeitliche Logik** einer Kombinationsübung **nicht** ersetzen. Für Trainersteuerung (und später für Coaching **Stufe C**) soll die Kombination in der Bibliotheksbeschreibung vorsehen können:
+
+**A) Kombinationseinheit („global“ über die Slots)**
+
+* **Arbeits-** und **Erholungszeiten** (Sekunden/Minuten) und **Anzahl der Durchläufe** oder **Intervalle**,
+* ggf. **gemeinsamer Takt** für alle Teilnehmenden (z. B. rotierender Zirkel oder eine gemeinsame Intervalluhr),
+* **Erklär- oder Aufbauzeit** vor dem eigentlichen Start,
+* dort, wo der Archetyp passt: **Runden-/Umlaufzahl** oder vergleichbare Strukturen.
+
+Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz**, **keine** individuelle Pulssonde oder ähnliche Personenmessung.
+
+**B) Optional pro Slot oder Schritt**
+
+* wenn fachlich sinnvoll: **von Station zu Station variierende Arbeitsphasen** oder Mini‑Sequenzen innerhalb eines Slots — technisch z. B. als strukturierte Liste in `method_profile` mit Bezug zum `slot_index` (Ausarbeitung Coding Agent).
+
+**Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits § 2.5 und § 8.3).
+
+**Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§ 10.4**).
+
+### 6.4 Slot- und Pool-Logik
Slots können fest oder variabel sein.
@@ -466,6 +512,7 @@ Produktregel:
Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können:
* Dauer ändern,
+* **bei Kombinationsübungen:** im Idealfall **`method_profile` (Arbeit, Erholung, Durchläufe)** und Stations-/Slot-Anpassungen des **konkreten Vorkommens**, nicht nur Gesamtzeit,
* Übung austauschen,
* Station ergänzen,
* Hinweise anpassen,
@@ -549,6 +596,22 @@ Grundsatz:
| Intervallblock | Globale Zeit, Intervallzähler, Aufgaben. |
| Freier Methodenblock | Kompakte Übersicht und manuelle Steuerung. |
+#### 10.2.1 Kanonische Archetyp-IDs (Abgleich Fachbegriff, UI und API)
+
+Damit Produktbeschreibung, Formularfelder (`method_archetype`), Trainingscoach und Backend‑Validierung **dieselben Werte** nutzen und es keinen dokumentationsbedingten Drift gibt, gelten diese **festen Schlüssel** (Maschinen‑IDs):
+
+| Archetyp (fachlicher Name in § 5.2) | Schlüssel `method_archetype` (`exercises.method_archetype`) |
+| ----------------------------------- | ----------------------------------------------------------- |
+| Lineare Sequenz | `sequence_linear` |
+| Rotierender Zirkel (Zeit) | `circuit_rotate_time` |
+| Parallele Stationen | `circuit_all_parallel` |
+| Parcours | `station_parcour` |
+| Partner- / Paarwechsel | `pair_superset` |
+| Intervallblock (Zeitdomäne) | `time_domain_interval` |
+| Freier Methodenblock | `free_method_block` |
+
+Änderungen an dieser Zuordnung nur **gemeinsam** (Produkt, Backend‑Enum und UI‑Konstanten); siehe Implementierungsanhang weiter unten.
+
### 10.3 Durchführungsdokumentation
Perspektivisch sollte dokumentierbar sein:
@@ -563,6 +626,43 @@ Perspektivisch sollte dokumentierbar sein:
Die konkrete technische Umsetzung wird nicht in dieser Spezifikation festgelegt.
+### 10.4 Coaching-Reifegrade (Normierung ohne technisches Pflichtenheft)
+
+Archetyp-spezifisches Coaching soll **nicht** als ein einziges UX-„Monolith“ gebaut werden, sondern in **nachvollziehbaren Stufen**, damit frühere Umsetzungen nicht überschrieben wirken und der Fortschritt in Doku/Umsetzungsplan nachverfolgt werden kann:
+
+| Stufe | Bezeichnung (Arbeitstitel) | Inhalt aus Trainersicht | Abgrenzung |
+| ----- | ---------------------------- | ------------------------ | ----------- |
+| **A** | **Informations-/Struktursicht** | Pro Kombinationsübung: Kopf‑Kontext aus Katalog **plus** strukturierte Darstellung der **Slots** und der **einzelnen Kandidatenübungen** (Titel, Kurztext, Detail aufklappbar); **ein zeitlicher Schritt im Coach** entspricht weiter **einem** Planungseintrag (ein Item in der Einheit). | Kein eigener Rundenzähler, kein eigener Stations‑Timer‑State pro Archetyp. |
+| **B** | **Archetyp-Steuerung in der bestehenden Zeitleiste** | Optionale Aufspaltung: z. B. bei **`sequence_linear`** pro Slot **ein Coach‑Schritt** (Weiter/Zurück pro Station), ohne die Datenbank-Semantik der Einheit zu zerstückeln (Virtuelle Schritte oder materialisierte Hilfs‑Einträge – technische Variante dokumentieren). | Bewusste Produkt-/Architekturentscheidung nötig, damit IST‑Zeiten und Abschluss‑PUT konsistent bleiben. |
+| **C** | **Interaktive Assistenz je Archetyp** | Gemeinschafts-/Stations‑Timer, Wechselimpulse (**`circuit_rotate_time`**), Vorab‑„Erklärphase“‑Flag (**`circuit_all_parallel`**), Abhaken (**`station_parcour`**), gekoppelte A/B‑Ansicht (**`pair_superset`**), globale Intervalluhr (**`time_domain_interval`**) — jeweils an Parameter aus **`method_profile`** angebunden, wo diese in Stufe A/B bereits sichtbar gepflegt werden. | Keine verpflichtende KI‑Steuerung; Trainer kann überspringen (Grundsatz § 10.1). |
+
+**Aktuelle Zielrichtung:** Stufe **A** soll für **alle** in § 10.2.1 genannten Archetypen **inhaltsgleich** die Slot‑ und Kandidateninformation liefern; **unterschiedliche Kopf-/Hilfstexte und UI-Mikrolayouts** nach Archetyp sind Teil von A und sollten gemeinsam mit Stufe B/C wachsen (kein „still“ abweichendes Verhalten ohne Doku‑Update).
+
+### 10.5 Fachliche Mindestinfos im **Ablaufprofil** (`method_profile`) pro Archetyp
+
+`method_profile` ist das **konkretisierende** JSON (o. ä.) zum gewählten Archetyp: Zeiten, Runden, Schalter. Technische Pflichtfelder und Validierung regelt die technische Umsetzung — **fachlich** gilt folgende Minimal-Erwartung, damit Stufe B/C sinnvoll nutzbar ist:
+
+| Archetyp-Schlüssel | Mindest-Parameter (fachlich sinnvoll; Benennung in der Umsetzung kanonisch festlegen). Typische Zuordnung methodischer Überbegriffe: **§ 5.4** |
+| ------------------ | ------------------------------------------------------------------------------------- |
+| `sequence_linear` | Orientierungs-Arbeits-/Pausenhinweise je Schritt oder global; Reihenfolge = Slotreihenfolge — u. a. für **Skillschichtungen**, Aufwärmserien ohne festen Rotationstakt oder **Ausdauer-/Technikketten ohne Intervalltakt**. |
+| `circuit_rotate_time` | **Arbeit** je Station oder Umlauf, **optional Wechsel/Transition**, **optional Erholung zwischen Runden**, **optional Rundenanzahl**; Kern für rotierenden Zirkel inkl. vieler HIIT-/Zirkelmischformen über Stationen hinweg (**§ 5.4**). |
+| `circuit_all_parallel` | „Erst gemeinsame Erklärung, dann gleichzeitiger Betrieb aller Stationen“; Zeitfenster Vorab‑Erklärung optional — z. B. wenn keine Rotation, aber gemeinsamer Startzeitpunkt gewünscht ist. |
+| `station_parcour` | Fokus Stationsbeschreibung; optional freie Besuchsreihenfolge (Profil/Archetyp); weniger zentral **feste Arbeit/Erholung-Takte**, mehr Navigation/Abhaken (später Stufe C). |
+| `pair_superset` | Arbeit und Wechsel bei **gekoppelten** Rollen; typisch wenn zwei Linien oder Partnerblöcke im Takt gewechselt werden. |
+| `time_domain_interval` | Klare Zeitdomäne: **Belastungs-, Erholungsblöcke** und **Anzahl Wiederholungen** der Domäne bzw. **Gesamtblockbegrenzung** — zentrale Schicht für viele Formen aus **„Intervall/HIIT/Zeitschachtelungs“‑**Methodenkatalog ohne individuelle Messung (**§ 5.4**). |
+| `free_method_block` | Keine zusätzlichen Pflichtparameter; **unterstützt** etwa **reibungsarmere Dauer- oder Spielformen**, wo der Trainer keine starke Taktuhr braucht, aber Stationsideen strukturiert bündeln will. |
+
+#### 10.5.1 Mehrschichtiges Planen (Überblick)
+
+| Ebene | Inhalt zeitlicher Art |
+| ----- | --------------------- |
+| **Einheit / Planungsitem** | z. B. geplante **Gesamtminuten** dieser Platzierung („der Block soll heute etwa 25 Min einnehmen“). |
+| **Kombinationsübung (Bibliothek)** | strukturierte **Phasen in `method_profile`** (arbeiten, pausieren, Runden…) — § 6.3. |
+| **Einheitliche Planungsinstanz** | optionale Abweiche vom Bibliotheksprofil **nur für dieses Training** (§ 8.3). |
+| **Coach** | liest wirksamen Stand (Bibliothek + Overrides) zur **Orientierung**, später automatisierte Taktassistenz (**§ 10.4**). |
+
+Solange diese Mindestinfos in der Datenpflege noch **nicht** validiert oder nicht geführt erfasst werden, bleibt Coaching bei **Informations-Schicht und manuellen Timern des bestehenden Coach-Dialogs** die fachlich ehrliche Darstellung (siehe Anhang A).
+
---
## 11. Rahmenprogramm-Integration
@@ -625,7 +725,7 @@ Für Methoden ist eine besondere Qualitätskontrolle sinnvoll, weil sie als fach
* freier Methodenblock,
* Planungsblöcke als fachliches Konzept,
* lokale Anpassbarkeit nach Einfügen,
-* einfache Coaching-Ansicht.
+* Coaching: mindestens **Stufe A** nach § 10.4 für alle Archetypen aus § 10.2.1 (strukturierte Slot-/Kombi-Darstellung; Archetyp-Hilfstexte); **zeitliche/mechanische Archetyp-Steuerung (Stufen B/C)** ausdrücklich als Ausbauschritte.
### 13.2 Sollte vorbereitet werden
@@ -677,8 +777,32 @@ Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlich
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.
+8. Ablaufprofile konkretisieren den Archetyp für Planung und Coaching (siehe § 10.5).
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.
+13. Archetyp-IDs und Coaching-Stufen (§ 10.2.1, § 10.4) sind die **Referenz gegen Code-Drift**; Änderungen nur mit Anhang A und technischer Doku.
+14. **Zeitliche Phasen** einer Kombination liegen vorrangig in **`method_profile`** und **Gesamtzeit am Planungseintrag**; **Übersteuerungen nur in der Planungsinstanz**, nicht still in der Bibliothek (§ 6.3, § 8.3, § 10.5.1).
+
+---
+
+## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.102**, grob)
+
+Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird.
+
+| Thema (fachliche Headline aus dieser Spez) | Kurz beschrieben | Stand Code / UX (Referenz nur) | Lücke / nächste sinnvolle Schritte |
+|--------------------------------------------|-----------------|---------------------------------|-------------------------------------|
+| **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus |
+| **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus § 4/§ 6 dort umsetzen, wo die Domäne es noch nicht abdeckt |
+| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` | Persistenz; Übungsformular: **geführte Felder** nach Archetyp (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) + eingeklapptes Roh‑JSON | Schema‑Validierung serverseitig noch offen; UI für Pflicht je Archetyp (§ 10.5) weiter schärfen |
+| **Einplanbarkeit (normale Planung)** | Kombi wie Übung in Sektionen; **Zeitprofil‑Overrides** nach § 8.3 / § 10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Overrides von `method_profile` am Platzierungseintrag fehlen** | Planungs-UI/API: kopiertes **`method_profile` pro Einheit/item** bearbeitbar; Planungsblöcke (Phase 3) |
+| **Zeitphasen (global / pro Slot)** | § 6.3 | Über `method_profile` teilweise (globale Schlüssel im Formular); **keine strukturierten slotgebundenen Zeitlisten** im UI | `slot_timing[]` oder äquivalent definieren und editieren |
+| **Coaching Stufe A** | Slots + Kandidaten sichtbar, Archetyp‑Hinweis, Profil lesbar | `CombinationCoachSlots` zeigt **Key/Value** aus `method_profile`, sonst wie zuvor | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) |
+| **Coaching Stufe B** | Zeitleiste archetypnah (z. B. Schritt pro Station) | **Nein** — ein Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item |
+| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Coach‑Timer pro Planungsitem | Pro Archetyp UI‑State + Anbindung an `method_profile` |
+| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | Slot‑Blueprint, `from-framework-slot` | Modul-/Kombi‑UX in Rahmen wie in Einheit konsolidieren (Phase 5) |
+| **Coaching-Vorschau im Editor** | § 9.3 Schritt 7 | **Nein** / nicht als eigener Modus | Optional: dieselbe `CombinationCoachSlots`‑Ansicht read‑only im Übungseditor |
+
+**Pflege:** Bei jeder relevanten Codeänderung diese Tabelle **in demselben PR / derselben Session** anpassen (kein stiller Drift).
+
diff --git a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
index 6035e09..5a69629 100644
--- a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
+++ b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
@@ -3,6 +3,12 @@
**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12
**Zweck:** Rahmen für Umsetzung, Integration in Planung/Rahmenprogramm und Durchführung im assistierten Training (Coaching-Modus). Dieses Dokument ist **nicht** implementierungsbindend, bis die markierten **offenen Entscheidungen** geschlossen und der Status angehoben wurde.
+**Abgleich mit Code (Stand ~0.8.101, Drift vermeiden):**
+
+- **Kanonische Archetyp-IDs:** fest in `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`); fachliche Tabelle und UI-Labels in `frontend/src/constants/combinationArchetypes.js` — die **fachliche Master-Zuordnung** Name↔ID steht in `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` § 10.2.1.
+- **Coaching:** Stufe **A** (informations-/strukturierte Slot- und Kandidatenansicht + Archetyp-Hilfstext) umgesetzt im Trainings-Coach (`ExerciseFullContent` / `CombinationCoachSlots`); Stufen **B/C** bewusst offen — siehe Fachspez § 10.4 und **Anhang A** dort.
+- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phasen 2/4 mit „teilweise“).
+
**Verwandte Dokumente:**
| Dokument | Bezug |
diff --git a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
index 2e8dfc2..72cc011 100644
--- a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
+++ b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
@@ -1,8 +1,8 @@
# Umsetzungsplan: Trainingsmodule & Kombinationsübungen
-**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, Stand 2026-05-12)
+**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§ 10.2.1**, **§ 10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz)
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
-**Stand dieses Dokuments:** 2026-05-12
+**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code ~App **0.8.102**)
## Ziele
@@ -13,11 +13,20 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
| 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 |
+| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — Migration 056, CRUD/API, Picker/Liste; Übungsformular: geführtes **`method_profile` nach Archetyp** (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) plus Roh‑JSON; **Backend:** keine strenge Validierung Profil ↔ Archetyp | Haupt-/Nebenmethoden an Kombi wo Spec es verlangt; serverseitige Validierung für Profil‑Schlüssel optional |
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
-| **4** | Coaching-Ansicht: Archetyp-spezifische Darstellung für MVP-Archetypen | geplant |
+| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** nach Fachspez § 10.4 (Slotliste, Kandidaten aus Katalog geladen, Archetyp-Hilfstexte in `CombinationCoachSlots`/`combinationArchetypes.js`); **Stufe B/C** (Zeitleisten-Splitting, Stations-/Intervall-Timing) — **offen**, siehe Anhang A der Fachspez |
| **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant |
+## Coaching — verbindliche Arbeitspakete (gegen Spec-Drift)
+
+| Paket | Spec-Referenz | Kurzinhalt |
+|-------|----------------|-----------|
+| **4a (Ist/Ziel)** | § 10.2.1 | Archetyp-Schlüssel bleiben identisch zu `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`) und `frontend/src/constants/combinationArchetypes.js`. |
+| **4b** | § 10.4 Stufe A | Slots + Kandidaten; Archetyp-Hilfstext; `method_profile` **lesend** unter der Kopf-Zeile (Key/Wert‑Liste wenn gepflegt); Feintuning Labels optional. |
+| **4c** | § 10.4 Stufe B | Entscheidung: virtuelle Substeps vs. persistierte Items; Konsistenz `sectionsToPutPayload`/Ist-Zeit. |
+| **4d** | § 10.4 Stufe C | Archetyp-spezifische Timer/Wechsel/Abhaken an `method_profile` — nach 4b/4c. |
+
## Phase 1 (technische Notizen)
- **Governance:** `visibility`/`club_id`/`created_by` analog `training_plan_templates`; Listenfilter `library_content_visibility_sql`.
@@ -28,3 +37,4 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
- `DATABASE_SCHEMA.md` bei größeren Schema-Erweiterungen ergänzen.
- `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei neuen mandantenbezogenen Endpunkten fortpflegen.
+- **Nach jeder Kombi-/Coach-Änderung:** `functional/… Spezifikation V2.md` **Anhang A** und diese Tabelle Phasen 2/4 abstimmen.
diff --git a/backend/version.py b/backend/version.py
index c1144b6..74e6431 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.101"
+APP_VERSION = "0.8.102"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512056"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.24.1", # Coach/Kombination: Stationen laden Einzelübungen + Archetyp-Hilfstext (Frontend ExerciseFullContent)
+ "exercises": "2.24.2", # Kombi: geführtes method_profile im Übungsformular nach Archetyp + Coach zeigt Profil als Key/Wert
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.102",
+ "date": "2026-05-12",
+ "changes": [
+ "Kombinationsübung beim Anlegen/Bearbeiten: archetypbezogenes Ablaufprofil (geführt) + eingeklappt Roh-JSON (`CombinationMethodProfileEditor`); Schlüsselmanifest `combinationMethodProfileUi.js`. Coach: angelegtes method_profile unter Stationenliste lesbar.",
+ ],
+ },
{
"version": "0.8.101",
"date": "2026-05-12",
diff --git a/docs/FACHLICHE_NUTZERFUNKTIONEN.md b/docs/FACHLICHE_NUTZERFUNKTIONEN.md
index 4f9ddc3..d98dc6a 100644
--- a/docs/FACHLICHE_NUTZERFUNKTIONEN.md
+++ b/docs/FACHLICHE_NUTZERFUNKTIONEN.md
@@ -2,7 +2,7 @@
**Zweck:** Überblick über die **wesentlichen, produktiv nutzbaren Funktionen** aus Nutzer- und Fachperspektive – zur Weitergabe an Design, Product Discovery oder externe Fachplanung.
-**Technischer Detailstand:** App-Version und Schema siehe `backend/version.py` (Stand Code: **0.8.96**, **DB_SCHEMA_VERSION** siehe dort).
+**Technischer Detailstand:** App-Version und Schema siehe `backend/version.py` (Stand Code: **0.8.101**, **DB_SCHEMA_VERSION** siehe dort).
**Vertiefung:** Domänenmodell `.claude/docs/functional/DOMAIN_MODEL.md`, Lieferdetal `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md`, Projektstatus `.claude/docs/PROJECT_STATUS.md`, Entwickler-Handover `docs/HANDOVER.md`.
@@ -59,6 +59,7 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
- **Medien an der Übung:** Upload, Einbettung, Verknüpfung aus dem **Archiv**; Darstellung in Detail- und Bearbeitungsansicht.
- **Rich-Text-Felder** (Ablauf, Ziele, Hinweise): **Inline-Verweise auf verknüpfte Medien** über eine einheitliche Platzhalter-/Renderlogik (konsistent mit Archiv-Governance).
- **Exercise Blocks** („Bausteine“) und gespeicherte Suchpräferenzen, wo implementiert.
+- **Kombinationsübungen** („combination“, Migration 056): Sonderform im Übungskatalog mit **Stationen/Slots**, **Trainingsmethode-/Archetyp** (`method_archetype`), optionalem strukturierten **Ablaufprofil** (`method_profile`). In der Planung wie eine Übung ohne Variante einsetzbar; im **Coach** werden Stations-Kandidaten und Archetyp-Hilfstexte angezeigt (Ausbauschritte B/C nach Fachspez § 10.4 dokumentiert unter `.claude/docs/functional/… Kombinationsübungen Spezifikation V2.md` Anhang A).
### 4.2 Fähigkeiten, Methoden, Kataloge
@@ -77,7 +78,7 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden.
- **Trainingsrahmenprogramm (Bibliothek):** übergeordnete Programme mit **Zielen** und **Slots**; Slot-Inhalt technisch als **Blueprint-Trainingsunit** abgebildet.
- **Materialisierung:** aus einem Rahmen-Slot kann eine **konkrete Kalender-Einheit** für eine Gruppe erzeugt werden (API vorhanden; UI-Anbindung kann erweitert werden).
-- **Durchführung:** Ansicht zum Abarbeiten einer Einheit; **Coaching-Modus** als separater Erlebnispfad.
+- **Durchführung:** Ansicht zum Abarbeiten einer Einheit; **Coaching-Modus** als separater Erlebnispfad (generischer Zeit-Block pro Platzierung); bei **Kombinationsübungen** zusätzliche **Stations-/Kandidaten-Schicht und Archetyp-Hinweise** siehe Kombination-Fachspez **Anhang A** (implementierter Umfang vs. nächste Stufen).
### 4.5 Medienbibliothek und Archiv
@@ -117,7 +118,7 @@ Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen**
- Kalender-UX: **„Aus Rahmen übernehmen“** flächendeckend und ggf. bulkfähig anbinden.
- **Policies** für geteilte Rahmen (Wer darf Bibliotheks-Rahmen sehen/kopieren?).
- **Skill-Kategorie-Admin-UI**, **Dark Mode/Responsive/PWA-Ausbau**, **KI-Suche** über Volltext hinaus – je nach Backlog.
-- **Moderations-Fläche**, Uploader-Benachrichtigung bei Sperre, **Beschwerdeverfahren** – laut Handover bewusst noch nicht umgesetzt (Nachfolge von P-13).
+- **Coach / Kombination:** nächste Stufen **Zeitleisten-Splitting** und **Archetyp-Timer** (Fachspez § 10.4 Stufe B/C; Umsetzungsplan Phase 4b–d); **geführtes Erfassen** von `method_profile` im Übungseditor.
---
@@ -126,3 +127,4 @@ Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen**
| Datum | Änderung |
|-------|----------|
| 2026-05-12 | Erstfassung für Übergabe an fachliches Design; Abgleich mit Code-Navigation, `version.py`, `HANDOVER.md`, `FEATURES_DELIVERED`, `DOMAIN_MODEL`. |
+| 2026-05-12 | Kombinationsübungen + Coaching Stufe A; Verweise auf Fachspezifikation (`…Kombinationsuebungen…` V3 Anhang A) und `TRAINING_MODULES_IMPLEMENTATION_PLAN.md`. |
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index d6b1303..b9c3e97 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-12
-**App-Version / DB-Schema:** App **0.8.96**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
+**App-Version / DB-Schema:** App **0.8.101**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@@ -29,6 +29,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
| Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md` |
+| **Trainingsmodule & Kombinationsübungen (Fachspez, Drift-Schutz)** | `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (§ 10.2.1 Archetyp-IDs, § 10.4 Coaching-Stufen, **Anhang A** Code-Abgleich) |
+| **Umsetzungsplan** (Module/Kombination/Coach) | `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` |
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
@@ -72,6 +74,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
+### Trainingsmodule, Kombinationsübungen und Coach (Stand ~0.8.101)
+
+- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **Anhang A** Abgleich).
+- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–d**).
+- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**) + Einplanung + Coach **Stufe A** (`CombinationCoachSlots`, `combinationArchetypes.js`). Coach **Stufe B/C** und geführtes **`method_profile`** offen — siehe Fachspez Anhang A.
+
---
## 4. Stand: Medien-Management (Ist, 2026-05-07)
@@ -152,6 +160,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
+8. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
---
@@ -159,7 +168,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Bereich | Einstieg |
|---------|----------|
-| Backend API | `backend/main.py`; u. a. **`media_assets.py`**, **`exercises.py`**, **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` |
+| Backend API | `backend/main.py`; u. a. **`media_assets.py`**, **`exercises.py`** (`COMBINATION_ARCHETYPE_IDS`, `enrich_exercise_detail`), **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` |
+| Coach-Kombination (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `constants/combinationArchetypes.js` |
| Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
| Frontend API | `frontend/src/utils/api.js` |
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index 65f9f14..3645836 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -11,7 +11,7 @@ import {
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
-export default function CombinationCoachSlots({ combinationSlots, methodArchetype }) {
+export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
@@ -111,6 +111,42 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
{archetypeCoachHint(archeKey)}
+ {methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length ? (
+
+
+ Geplantes Ablaufprofil (Katalog)
+
+
+ {Object.entries(methodProfile)
+ .sort(([a], [b]) => a.localeCompare(b, 'de'))
+ .map(([k, val]) => (
+
+
{k}
+
+ {typeof val === 'boolean'
+ ? val
+ ? 'ja'
+ : 'nein'
+ : typeof val === 'number'
+ ? String(val)
+ : typeof val === 'string'
+ ? val
+ : JSON.stringify(val)}
+
+
+ ))}
+
+
+ ) : null}
+
{!slots.length ? (
Keine Stationen hinterlegt.
) : (
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
new file mode 100644
index 0000000..f71eadf
--- /dev/null
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -0,0 +1,175 @@
+import React, { useMemo, useState } from 'react'
+import { archetypeCoachHint, combinationArchetypeLabel } from '../constants/combinationArchetypes'
+import {
+ METHOD_PROFILE_GUI_FIELDS,
+ parseProfileJson,
+ setFullProfileRawJson,
+ updateProfileGuided,
+} from '../utils/combinationMethodProfileUi'
+
+function clampInt(n, min, max) {
+ if (!Number.isFinite(n)) return null
+ let x = n
+ if (typeof min === 'number' && x < min) x = min
+ if (typeof max === 'number' && x > max) x = max
+ return Math.round(x)
+}
+
+/**
+ * Kombination: geführtes Ablaufprofil + optionales Roh-JSON.
+ */
+export default function CombinationMethodProfileEditor({
+ methodArchetype,
+ methodProfileJson,
+ onChangeMethodProfileJson,
+}) {
+ const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
+ const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
+ const fields = Array.isArray(fieldsGui) ? fieldsGui : null
+ const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
+ const [rawOpenError, setRawOpenError] = useState(null)
+ const [rawDraft, setRawDraft] = useState(null)
+
+ const profileObj = parseState.ok ? parseState.obj : {}
+
+ const applyGuided = (key, value, kind) => {
+ if (kind === 'bool') {
+ const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
+ if (!res.ok) return
+ onChangeMethodProfileJson(res.json)
+ return
+ }
+ if (value === '' || value === undefined || value === null) {
+ const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
+ if (!res.ok) return
+ onChangeMethodProfileJson(res.json)
+ return
+ }
+ const num = typeof value === 'number' ? value : parseInt(String(value), 10)
+ if (!Number.isFinite(num)) return
+ const def = METHOD_PROFILE_GUI_FIELDS[arch]?.find((f) => f.key === key && f.kind === 'int')
+ const c = clampInt(num, def?.min, def?.max)
+ if (c == null) return
+ const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int')
+ if (!res.ok) return
+ onChangeMethodProfileJson(res.json)
+ }
+
+ const archeLabel = arch ? combinationArchetypeLabel(arch) : null
+
+ const openAdvanced = () => {
+ setRawOpenError(null)
+ const p = parseProfileJson(methodProfileJson || '{}')
+ setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
+ }
+
+ return (
+
+ {arch ? (
+
+
+ Coach & Planung:{' '}
+ {archeLabel && archeLabel !== arch ? `${archeLabel} · ` : ''}
+
+ {archetypeCoachHint(arch)}
+
+ ) : (
+
+ Wähle einen Archetyp, um das Ablaufprofil strukturiert zu erfassen — oder nur das JSON weiter unten.
+
+ )}
+
+ {!parseState.ok ? (
+
{parseState.error}
+ ) : null}
+
+ {fields && fields.length > 0 ? (
+
+ ) : arch && fields && fields.length === 0 ? (
+
+ Für diesen Archetyp gibt es keine vorgegebenen Profilfelder — nutze die Freitexte der Kombination oder Roh‑JSON bei Bedarf.
+
+ ) : null}
+
+
{
+ if (ev.target.open) openAdvanced()
+ }}
+ >
+ Erweitert: JSON direkt bearbeiten
+
+ Zusätzliche Schlüssel (Piloten). Geführte Felder können dieselben Schlüssel beim nächsten Speichern überlagern.
+
+ {
+ setRawDraft(e.target.value)
+ setRawOpenError(null)
+ }}
+ spellCheck={false}
+ onBlur={() => {
+ const src = rawDraft != null ? rawDraft : methodProfileJson
+ const res = setFullProfileRawJson(src || '{}')
+ if (!res.ok) {
+ setRawOpenError(res.error)
+ return
+ }
+ setRawOpenError(null)
+ setRawDraft(null)
+ onChangeMethodProfileJson(res.json)
+ }}
+ />
+ {rawOpenError ? {rawOpenError}
: null}
+
+
+ )
+}
diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx
index ee4833a..cc5e3a5 100644
--- a/frontend/src/components/ExerciseFullContent.jsx
+++ b/frontend/src/components/ExerciseFullContent.jsx
@@ -114,6 +114,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
) : null}
{exercise.title}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index ba7a42f..d8f9e6e 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -7,6 +7,7 @@ import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGrap
import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import MediaPreviewModal from '../components/MediaPreviewModal'
import ReportContentModal from '../components/ReportContentModal'
+import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
@@ -1016,13 +1017,13 @@ function ExerciseFormPage() {
{formData.exercise_kind === 'combination' ? (
<>
- Methoden-Archetyp (optional)
+ Methoden-Archetyp (für Coach & Planung empfohlen)
updateFormField('method_archetype', e.target.value)}
>
- — später wählen —
+ — noch nicht festgelegt —
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
{o.label}
@@ -1031,14 +1032,11 @@ function ExerciseFormPage() {
- Ablaufprofil (JSON, optional)
- updateFormField('method_profile_json', e.target.value)}
- spellCheck={false}
- placeholder='{"work_seconds":45,"rest_seconds":15,"rounds":3}'
+ Ablaufprofil (über Archetyp)
+ updateFormField('method_profile_json', s)}
/>
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
new file mode 100644
index 0000000..646b541
--- /dev/null
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -0,0 +1,165 @@
+/**
+ * Geführtes method_profile für Kombinationsübungen — Felder nach method_archetype.
+ * Unbekannte JSON-Schlüssel bleiben beim Zusammenführen erhalten (Erweiterbarkeit).
+ */
+
+const INT_MAX = 86400
+
+function parseProfileJson(raw) {
+ if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
+ try {
+ const p = JSON.parse(raw)
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
+ return { ok: false, error: 'Ablaufprofil muss ein JSON-Objekt sein.' }
+ }
+ return { ok: true, obj: { ...p } }
+ } catch {
+ return { ok: false, error: 'Ablaufprofil (JSON): Syntax ungültig.' }
+ }
+}
+
+/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
+export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
+ sequence_linear: [
+ {
+ key: 'hint_step_duration_sec',
+ kind: 'int',
+ label: 'Orientierung: Sekunden je Station/Schritt (optional)',
+ min: 5,
+ max: INT_MAX,
+ },
+ {
+ key: 'block_intro_sec',
+ kind: 'int',
+ label: 'Einführung / Demon am Block Gesamt (Sek., optional)',
+ min: 0,
+ max: INT_MAX,
+ },
+ ],
+ circuit_rotate_time: [
+ {
+ key: 'work_seconds',
+ kind: 'int',
+ label: 'Arbeitszeit pro Station (Sek.)',
+ min: 5,
+ max: INT_MAX,
+ },
+ {
+ key: 'transition_seconds',
+ kind: 'int',
+ label: 'Wechsel / Rotation (Sek., optional)',
+ min: 0,
+ max: INT_MAX,
+ },
+ {
+ key: 'rest_seconds',
+ kind: 'int',
+ label: 'Pause zwischen Runden oder Stationen-Folgen (Sek., optional)',
+ min: 0,
+ max: INT_MAX,
+ },
+ {
+ key: 'rounds',
+ kind: 'int',
+ label: 'Runden (optional, wenn alle Station je Runde angefahren werden)',
+ min: 1,
+ max: 999,
+ },
+ ],
+ circuit_all_parallel: [
+ {
+ key: 'explain_before_seconds',
+ kind: 'int',
+ label: 'Zeitfenster Vorab‑Erklärung aller Stationen (Sek., optional)',
+ min: 0,
+ max: INT_MAX,
+ },
+ {
+ key: 'simultaneous_start',
+ kind: 'bool',
+ label: 'Alle Stationen starten zusammen nach Erklärung',
+ },
+ ],
+ station_parcour: [
+ {
+ key: 'allow_free_visit_order',
+ kind: 'bool',
+ label: 'Reihenfolge der Besuche frei (Parcours / Abhaken-Logik später im Coach)',
+ },
+ ],
+ pair_superset: [
+ {
+ key: 'switch_seconds',
+ kind: 'int',
+ label: 'Orientierung: Wechselpause A↔B (Sek., optional)',
+ min: 0,
+ max: INT_MAX,
+ },
+ {
+ key: 'work_seconds_per_side',
+ kind: 'int',
+ label: 'Arbeit pro Rolle oder Seite (Sek., optional)',
+ min: 5,
+ max: INT_MAX,
+ },
+ ],
+ time_domain_interval: [
+ {
+ key: 'work_seconds',
+ kind: 'int',
+ label: 'Intervall: Belastungszeit (Sek.)',
+ min: 5,
+ max: INT_MAX,
+ },
+ {
+ key: 'rest_seconds',
+ kind: 'int',
+ label: 'Intervall: Erholungszeit (Sek., optional)',
+ min: 0,
+ max: INT_MAX,
+ },
+ {
+ key: 'interval_rounds',
+ kind: 'int',
+ label: 'Anzahl Wiederholungen / Runden der Domäne (optional)',
+ min: 1,
+ max: 999,
+ },
+ ],
+ free_method_block: [],
+})
+
+/**
+ * Aktualisiert method_profile unter Beibehaltung nicht-GUI Schlüssel.
+ */
+export function updateProfileGuided(archetype, rawJson, key, parsedValue, kind) {
+ const arch = typeof archetype === 'string' ? archetype.trim() : ''
+ const parsed = parseProfileJson(rawJson)
+ if (!parsed.ok) return parsed
+ const next = { ...parsed.obj }
+
+ if (kind === 'bool') {
+ if (parsedValue) next[key] = true
+ else delete next[key]
+ } else if (kind === 'int') {
+ if (parsedValue === null || parsedValue === undefined || parsedValue === '') {
+ delete next[key]
+ } else {
+ const n = typeof parsedValue === 'number' ? parsedValue : parseInt(String(parsedValue), 10)
+ if (!Number.isFinite(n)) delete next[key]
+ else next[key] = n
+ }
+ }
+
+ const outJson = JSON.stringify(next)
+ return { ok: true, obj: next, json: outJson === '{}' ? '{}' : outJson }
+}
+
+export function setFullProfileRawJson(rawEditable) {
+ const parsed = parseProfileJson(rawEditable)
+ if (!parsed.ok) return parsed
+ const j = JSON.stringify(parsed.obj)
+ return { ok: true, obj: parsed.obj, json: j === '{}' ? '{}' : j }
+}
+
+export { parseProfileJson, INT_MAX }
--
2.43.0
From 4e654e50c0a73c989b71872b337a2ddab1ffca30 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 07:21:33 +0200
Subject: [PATCH 14/27] feat(version): bump to 0.8.103 and enhance planning
method profile integration
- Updated app version to 0.8.103, reflecting recent enhancements in training planning.
- Incremented database schema version to 20260512057, ensuring compatibility with new features.
- Introduced optional `planning_method_profile` for combination exercises, allowing for detailed planning and coaching support.
- Enhanced frontend components to manage and display planning method profiles effectively in the Training Unit Sections Editor and ExerciseFullContent.
Co-Authored-By: Claude Sonnet 4.6
---
..._planning_method_profile_section_items.sql | 8 +++
backend/routers/training_planning.py | 44 ++++++++++--
backend/version.py | 13 +++-
.../src/components/ExerciseFullContent.jsx | 11 ++-
.../components/TrainingUnitSectionsEditor.jsx | 68 +++++++++++++++++++
frontend/src/pages/TrainingCoachPage.jsx | 5 ++
.../src/utils/comboPlanningMethodProfile.js | 26 +++++++
frontend/src/utils/trainingPlanUtils.js | 9 ++-
.../src/utils/trainingUnitSectionsForm.js | 66 ++++++++++++++++--
9 files changed, 233 insertions(+), 17 deletions(-)
create mode 100644 backend/migrations/057_planning_method_profile_section_items.sql
create mode 100644 frontend/src/utils/comboPlanningMethodProfile.js
diff --git a/backend/migrations/057_planning_method_profile_section_items.sql b/backend/migrations/057_planning_method_profile_section_items.sql
new file mode 100644
index 0000000..865df43
--- /dev/null
+++ b/backend/migrations/057_planning_method_profile_section_items.sql
@@ -0,0 +1,8 @@
+-- 057: Terminspezifisches Ablaufprofil fuer Kombinationsuebungen in der Planung
+-- NULL = method_profile vom Katalog (exercises) verwenden; sonst dieser JSONB-Stand gilt fuer diese Platzierung.
+
+ALTER TABLE training_unit_section_items
+ ADD COLUMN IF NOT EXISTS planning_method_profile JSONB NULL;
+
+COMMENT ON COLUMN training_unit_section_items.planning_method_profile IS
+ 'Snapshots des Ablaufprofils fuer diese Einheit/Zeile; NULL = exercises.method_profile.';
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index a6ace71..ff0172e 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -407,6 +407,18 @@ def _normalize_assistant_trainer_profile_ids(
)
return uniq
+def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]:
+ """None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
+ if raw is None:
+ return None
+ if isinstance(raw, dict):
+ return dict(raw)
+ raise HTTPException(
+ status_code=400,
+ detail="planning_method_profile muss ein JSON-Objekt oder null sein",
+ )
+
+
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """
LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
@@ -452,6 +464,8 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
e.title AS exercise_title,
e.exercise_kind AS exercise_kind,
e.summary AS exercise_summary,
+ e.method_archetype AS catalog_method_archetype,
+ e.method_profile AS catalog_method_profile,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
@@ -471,6 +485,14 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
(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)
secs.append(sec)
return secs
@@ -506,6 +528,7 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
"actual_duration_min": it.get("actual_duration_min"),
"notes": it.get("notes"),
"modifications": it.get("modifications"),
+ "planning_method_profile": it.get("planning_method_profile"),
}
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
@@ -676,9 +699,10 @@ def _append_copied_module_items_to_section(
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
+ notes, modifications, note_body,
+ source_training_module_id, planning_method_profile
) VALUES (%s, %s, 'exercise',
- %s, %s, %s, NULL, %s, NULL, NULL, %s)
+ %s, %s, %s, NULL, %s, NULL, NULL, %s, NULL)
""",
(
section_id,
@@ -728,6 +752,15 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
eid = int(eid)
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
_validate_variant_for_exercise(cur, eid, vid)
+ cur.execute(
+ """SELECT COALESCE(exercise_kind, 'simple') AS k FROM exercises WHERE id = %s""",
+ (eid,),
+ )
+ er = cur.fetchone()
+ ek = str(er["k"] if er and er.get("k") is not None else "simple").strip().lower()
+ planning_mp = _normalize_planning_method_profile_payload(raw.get("planning_method_profile"))
+ if ek != "combination":
+ planning_mp = None
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
cur.execute(
"""
@@ -735,10 +768,10 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
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
+ notes, modifications, note_body,
+ source_training_module_id, planning_method_profile
) VALUES (%s, %s, 'exercise',
- %s, %s, %s, %s, %s, %s, NULL, %s
- )
+ %s, %s, %s, %s, %s, %s, NULL, %s, %s)
""",
(
section_id,
@@ -750,6 +783,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
raw.get("notes"),
raw.get("modifications"),
src_mod,
+ planning_mp,
),
)
diff --git a/backend/version.py b/backend/version.py
index 74e6431..ae392af 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.102"
+APP_VERSION = "0.8.103"
BUILD_DATE = "2026-05-12"
-DB_SCHEMA_VERSION = "20260512056"
+DB_SCHEMA_VERSION = "20260512057"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -24,7 +24,7 @@ MODULE_VERSIONS = {
"exercises": "2.24.2", # Kombi: geführtes method_profile im Übungsformular nach Archetyp + Coach zeigt Profil als Key/Wert
"training_units": "0.2.0",
"training_programs": "0.1.0",
- "planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
+ "planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.103",
+ "date": "2026-05-12",
+ "changes": [
+ "Trainingsplanung: bei Kombinationszeilen optionales `planning_method_profile` (Migration 057); Planungs-Editor mit Ablaufprofil-Details, „wie Katalog“ / „aus Katalog kopieren“; Payload/Coach-PUT übernehmen Snapshot.",
+ ],
+ },
{
"version": "0.8.102",
"date": "2026-05-12",
diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx
index cc5e3a5..2d4ef6e 100644
--- a/frontend/src/components/ExerciseFullContent.jsx
+++ b/frontend/src/components/ExerciseFullContent.jsx
@@ -6,6 +6,7 @@ import React from 'react'
import { Link } from 'react-router-dom'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import CombinationCoachSlots from './CombinationCoachSlots'
+import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function TagRow({ exercise }) {
const tags = []
@@ -53,9 +54,9 @@ function metaParts(exercise) {
}
/**
- * @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null }} props
+ * @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null }} props
*/
-export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId }) {
+export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile }) {
if (loading) {
return (
@@ -80,6 +81,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
const isCombination =
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
+ const coachComboProfile = isCombination
+ ? effectiveComboMethodProfile(exercise.method_profile, planningComboMethodProfile)
+ : null
+
return (
{variant ? (
@@ -114,7 +119,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
) : null}
{exercise.title}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 7db3da4..6ea4963 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1,5 +1,7 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
+import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
+import { comboPlanningProfileJsonForEditor } from '../utils/comboPlanningMethodProfile'
import {
defaultSection,
exerciseRow,
@@ -1019,6 +1021,72 @@ export default function TrainingUnitSectionsEditor({
+ {isCombination && it.exercise_id ? (
+
+
+
+ Ablaufprofil für diese Planung (Kombination)
+
+ {it.planning_method_profile != null &&
+ typeof it.planning_method_profile === 'object' &&
+ !Array.isArray(it.planning_method_profile)
+ ? '— Anpassung aktiv'
+ : '— wie im Katalog'}
+
+
+
+
+ updateItem(sIdx, iIdx, 'planning_method_profile', null)}
+ >
+ Planung wie Katalog
+
+
+ updateItem(sIdx, iIdx, 'planning_method_profile', {
+ ...(it.catalog_method_profile || {}),
+ })
+ }
+ >
+ Aus Katalog kopieren …
+
+
+
{
+ try {
+ const obj = JSON.parse(json || '{}')
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
+ updateItem(sIdx, iIdx, 'planning_method_profile', obj)
+ }
+ } catch {
+ /* Ungültiges JSON — Hinweis im Editor */
+ }
+ }}
+ />
+
+
+
+ ) : null}
+
{showExecutionExtras ? (
diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx
index 56a75c4..ad4b8b5 100644
--- a/frontend/src/pages/TrainingCoachPage.jsx
+++ b/frontend/src/pages/TrainingCoachPage.jsx
@@ -740,6 +740,11 @@ export default function TrainingCoachPage() {
exercise={catalogExercise}
exerciseId={currentEntry?.item?.exercise_id ?? null}
variantId={currentEntry?.item?.exercise_variant_id ?? null}
+ planningComboMethodProfile={
+ String(currentEntry?.item?.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
+ ? currentEntry?.item?.planning_method_profile ?? null
+ : null
+ }
/>
>
diff --git a/frontend/src/utils/comboPlanningMethodProfile.js b/frontend/src/utils/comboPlanningMethodProfile.js
new file mode 100644
index 0000000..0de9814
--- /dev/null
+++ b/frontend/src/utils/comboPlanningMethodProfile.js
@@ -0,0 +1,26 @@
+/** Effektives Ablaufprofil für Kombination im Coach/in der Planung */
+
+export function effectiveComboMethodProfile(catalogDict, planningSnapshot) {
+ const cat =
+ catalogDict && typeof catalogDict === 'object' && !Array.isArray(catalogDict)
+ ? catalogDict
+ : {}
+ if (
+ planningSnapshot !== null &&
+ planningSnapshot !== undefined &&
+ typeof planningSnapshot === 'object' &&
+ !Array.isArray(planningSnapshot)
+ ) {
+ return { ...planningSnapshot }
+ }
+ return { ...cat }
+}
+
+export function comboPlanningProfileJsonForEditor(catalogDict, planningSnapshot) {
+ const o = effectiveComboMethodProfile(catalogDict, planningSnapshot)
+ try {
+ return JSON.stringify(Object.keys(o).length ? o : {})
+ } catch {
+ return '{}'
+ }
+}
diff --git a/frontend/src/utils/trainingPlanUtils.js b/frontend/src/utils/trainingPlanUtils.js
index a81e380..3e88e59 100644
--- a/frontend/src/utils/trainingPlanUtils.js
+++ b/frontend/src/utils/trainingPlanUtils.js
@@ -81,7 +81,7 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
else actual = typeof actual === 'number' ? actual : parseInt(String(actual), 10)
if (actual !== null && !Number.isFinite(actual)) actual = null
- return {
+ const row = {
item_type: 'exercise',
order_index: it.order_index ?? ii,
exercise_id: parseInt(String(eid), 10),
@@ -92,6 +92,13 @@ export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
notes: trimOrNull(it.notes),
modifications: trimOrNull(it.modifications),
}
+ if (isCombo) {
+ const pmp = it.planning_method_profile
+ if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
+ row.planning_method_profile = { ...pmp }
+ }
+ }
+ return row
})
.filter(Boolean),
}))
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index e9d2773..f95bd8c 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -4,6 +4,18 @@ export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
+function normalizeCatalogMethodProfile(cp) {
+ if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
+ return {}
+}
+
+/** NULL = Planung folgt Katalogprofil der Übung */
+function normalizePlanningMethodProfile(pm) {
+ if (pm == null) return null
+ if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
+ return null
+}
+
export function exerciseRow() {
return {
item_type: 'exercise',
@@ -18,6 +30,9 @@ export function exerciseRow() {
modifications: '',
source_training_module_id: '',
source_module_title: '',
+ catalog_method_archetype: '',
+ catalog_method_profile: {},
+ planning_method_profile: null,
}
}
@@ -29,16 +44,20 @@ export async function hydrateExercisePlanningRow(exercise) {
if (!id) return null
let meta = {}
- async function fetchFull() {
+ let full
+
+ async function ensureFull() {
+ if (full !== undefined) return full
try {
- return await api.getExercise(id)
+ full = await api.getExercise(id)
} catch {
- return null
+ full = null
}
+ return full
}
if (!variants.length) {
- const full = await fetchFull()
+ await ensureFull()
if (full) {
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
@@ -48,6 +67,8 @@ export async function hydrateExercisePlanningRow(exercise) {
exercise_club_id: full.club_id ?? null,
exercise_created_by: full.created_by ?? null,
exercise_status: full.status || 'draft',
+ catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '',
+ catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile),
}
}
} else {
@@ -62,7 +83,7 @@ export async function hydrateExercisePlanningRow(exercise) {
meta.exercise_created_by == null ||
exerciseKind == null
) {
- const full = await fetchFull()
+ await ensureFull()
if (full) {
if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
@@ -89,6 +110,15 @@ export async function hydrateExercisePlanningRow(exercise) {
row.variants = variants
}
Object.assign(row, meta)
+ if (row.exercise_kind === 'combination') {
+ if (full === undefined) await ensureFull()
+ if (full) {
+ row.catalog_method_archetype =
+ typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
+ row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
+ }
+ }
+ row.planning_method_profile = null
return row
}
@@ -147,6 +177,9 @@ export function normalizeUnitToForm(fullUnit) {
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
+ catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
+ catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
+ planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
...(smEx != null
? {
source_training_module_id: smEx,
@@ -186,6 +219,9 @@ export function normalizeUnitToForm(fullUnit) {
: '',
notes: ex.notes ?? '',
modifications: ex.modifications ?? '',
+ catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(),
+ catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile),
+ planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile),
}
}),
},
@@ -217,6 +253,8 @@ export async function enrichSectionsWithVariants(sections) {
club_id: ex.club_id ?? null,
created_by: ex.created_by ?? null,
status: ex.status || 'draft',
+ method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
+ method_profile: normalizeCatalogMethodProfile(ex.method_profile),
})
} catch {
cache.set(id, {
@@ -227,6 +265,8 @@ export async function enrichSectionsWithVariants(sections) {
club_id: null,
created_by: null,
status: 'draft',
+ method_archetype: '',
+ method_profile: {},
})
}
})
@@ -240,8 +280,18 @@ export async function enrichSectionsWithVariants(sections) {
if (!c) return it
const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
+ const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile)
+ const catalog_method_profile =
+ Object.keys(itemCatalog).length > 0
+ ? itemCatalog
+ : normalizeCatalogMethodProfile(c.method_profile)
+ const rowArche = String(it.catalog_method_archetype ?? '').trim()
+ const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim()
return {
...it,
+ catalog_method_archetype,
+ catalog_method_profile,
+ planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_title: it.exercise_title || c.title,
exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
@@ -296,6 +346,12 @@ export function buildSectionsPayload(sections) {
notes: it.notes?.trim() ? it.notes.trim() : null,
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
}
+ if (isCombo) {
+ const pmp = it.planning_method_profile
+ if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
+ rowEx.planning_method_profile = { ...pmp }
+ }
+ }
if (smEx != null) rowEx.source_training_module_id = smEx
return rowEx
})
--
2.43.0
From 435da7f17a68bf2beeeb949d1699d378bb49a320 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 07:38:44 +0200
Subject: [PATCH 15/27] feat(version): bump to 0.8.104 and enhance combination
exercise features
- Updated app version to 0.8.104, reflecting recent improvements in combination exercise handling.
- Enhanced the CombinationMethodProfileEditor to support structured slot timing profiles without requiring JSON input from trainers.
- Introduced quick ratio presets for circuit and interval training methods, improving user experience in setting up training profiles.
- Updated documentation and changelog to reflect new features and integration details.
Co-Authored-By: Claude Sonnet 4.6
---
...e Kombinationsuebungen Spezifikation V2.md | 18 +-
.../COMBINATION_TIMING_PROFILE_PLAN.md | 100 ++++++
backend/version.py | 12 +-
.../CombinationMethodProfileEditor.jsx | 325 +++++++++++++++---
.../components/TrainingUnitSectionsEditor.jsx | 1 +
frontend/src/pages/ExerciseFormPage.jsx | 24 +-
.../src/utils/combinationMethodProfileUi.js | 145 ++++++++
7 files changed, 560 insertions(+), 65 deletions(-)
create mode 100644 .claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
index 88eeceb..dd18836 100644
--- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
+++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
@@ -1,7 +1,7 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf
-**Stand:** 2026-05-12 · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.5, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** (Abgleich Code vs. Spec)
+**Stand:** 2026-05-12 (Anhang A **grob** App **0.8.104**; Zeit‑Pfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.5, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:**
@@ -417,8 +417,12 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz**
**Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits § 2.5 und § 8.3).
+**Umsetzung in der App (Stand 0.8.103):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB). **`null`** bedeutet: es wirkt das Ablaufprofil aus dem **Katalog** (`method_profile` der Übung). Ist ein Snapshot gesetzt, ersetzt er den Katalog **vollständig** für diese Platzierung (kein serverseitiges Zusammenführen). Bearbeitung in der Planungs-UI: aufklappbarer Block **„Ablaufprofil für diese Planung (Kombination)“** mit denselben geführten Feldern wie im Übungsformular.
+
**Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§ 10.4**).
+**Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z. B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z. B. Zirkel: Relation Belastungszeit = Übergangszeit oder Erholungsanteil ≈ 2/3 der Belastung); der Archetyp **Freier Methodenblock** bildet den **Maximal‑Pfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden.
+
### 6.4 Slot- und Pool-Logik
Slots können fest oder variabel sein.
@@ -512,7 +516,7 @@ Produktregel:
Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können:
* Dauer ändern,
-* **bei Kombinationsübungen:** im Idealfall **`method_profile` (Arbeit, Erholung, Durchläufe)** und Stations-/Slot-Anpassungen des **konkreten Vorkommens**, nicht nur Gesamtzeit,
+* **bei Kombinationsübungen:** Ablaufprofil **optional nur für diese Platzierung** überschreiben (aktuell: Snapshot parallel zum Katalog-`method_profile`, z. B. Arbeit-, Erholungs- und Runden-/Intervallangaben über die gleichen strukturierten Felder wie im Übungskatalog) — zusätzlich zu den **Gepl.-Min.** am Eintrag; **Stations-/Slot-Austausch** am konkreten Vorkommen weiter über die bestehende Übungs-/Planungslogik, nicht gesondert als „Kombi-Programmierung“ je Zeile,
* Übung austauschen,
* Station ergänzen,
* Hinweise anpassen,
@@ -787,7 +791,7 @@ Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlich
---
-## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.102**, grob)
+## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.104**, grob)
Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird.
@@ -795,10 +799,10 @@ Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schr
|--------------------------------------------|-----------------|---------------------------------|-------------------------------------|
| **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus |
| **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus § 4/§ 6 dort umsetzen, wo die Domäne es noch nicht abdeckt |
-| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` | Persistenz; Übungsformular: **geführte Felder** nach Archetyp (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) + eingeklapptes Roh‑JSON | Schema‑Validierung serverseitig noch offen; UI für Pflicht je Archetyp (§ 10.5) weiter schärfen |
-| **Einplanbarkeit (normale Planung)** | Kombi wie Übung in Sektionen; **Zeitprofil‑Overrides** nach § 8.3 / § 10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Overrides von `method_profile` am Platzierungseintrag fehlen** | Planungs-UI/API: kopiertes **`method_profile` pro Einheit/item** bearbeitbar; Planungsblöcke (Phase 3) |
-| **Zeitphasen (global / pro Slot)** | § 6.3 | Über `method_profile` teilweise (globale Schlüssel im Formular); **keine strukturierten slotgebundenen Zeitlisten** im UI | `slot_timing[]` oder äquivalent definieren und editieren |
-| **Coaching Stufe A** | Slots + Kandidaten sichtbar, Archetyp‑Hinweis, Profil lesbar | `CombinationCoachSlots` zeigt **Key/Value** aus `method_profile`, sonst wie zuvor | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) |
+| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` (+ Pilot **`slot_profiles_v1`** je Station in derselben JSON‑Struktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne Nutzer‑JSON‑Pflicht; Schnellwahl typische Arbeit/Pause‑Relationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON‑„Experte“ weiter abschaltbar; Schema‑Pflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) |
+| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **Zeitprofil‑Overrides** nach § 8.3 / § 10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Override:** DB **`planning_method_profile`** je Sektions-Item (Migration **057**), Planungseditor: Details „Ablaufprofil für diese Planung“, **„Planung wie Katalog“** / **„Aus Katalog kopieren“** | Planungsblöcke als Produktkonzept · Phase 3; serverseitige Validierung Snapshot↔Archetyp optional |
+| **Zeitphasen (global / pro Slot)** | § 6.3 | Über `method_profile` / Planungs‑Snapshot (**gleiche JSON-Struktur** wie Katalogprofil): globale Schlüssel im Übungs- und Planungseditor; weiterhin **keine** eigenständigen slotgebundenen Zeitlisten im UI | `slot_timing[]` oder äquivalent definieren und editieren |
+| **Coaching Stufe A** | Slots + Kandidaten sichtbar, Archetyp‑Hinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **Planungs‑Snapshot wenn gesetzt, sonst Katalog**; Anzeige **Key/Value** | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) |
| **Coaching Stufe B** | Zeitleiste archetypnah (z. B. Schritt pro Station) | **Nein** — ein Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item |
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Coach‑Timer pro Planungsitem | Pro Archetyp UI‑State + Anbindung an `method_profile` |
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | Slot‑Blueprint, `from-framework-slot` | Modul-/Kombi‑UX in Rahmen wie in Einheit konsolidieren (Phase 5) |
diff --git a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
new file mode 100644
index 0000000..dae1862
--- /dev/null
+++ b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
@@ -0,0 +1,100 @@
+# Kombinations‑Ablaufprofil — Zeitmodell, Archetyp‑Vorgaben, Umsetzung
+
+**Zweck:** Fach-/Technik-Brücke zwischen Wunschbild („kein Nutzer‑JSON“, globale und slotbezogene Eckwerte, Archetyp‑Strukturen) und bestehendem Speicher **`method_profile` (JSON)** + **`planning_method_profile`** auf Planungszeilen.
+
+**Bezüge:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (§ 6.3 / § 8.3); Frontend `CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`; Archetyp‑IDs siehe Backend `COMBINATION_ARCHETYPE_IDS` / Frontend `COMBINATION_ARCHETYPE_OPTIONS`.
+
+---
+
+## 1. Grundprinzipien
+
+| Prinzip | Beschreibung |
+|--------|--------------|
+| **Kein Pflicht‑JSON für Trainer** | Alle trainertypischen Pflegepfade nur über geführte Felder + Archetyp‑Vorschlagsknöpfe. |
+| **JSON bleibt Transport** | Persistenz geschieht weiter in `method_profile` / Kopie in `planning_method_profile`; **kanonische Schlüssel** werden hier und in Codekommentaren festgehalten. |
+| **Archetyp = Struktur + Defaults** | Wechsel des Archetyps soll (optional/togglebar) Grundwerte oder typische Relationen vorbelegen können — keine stillen Überschreibungen ohne Hinweis. |
+| **`free_method_block` = Maximale Freiheit** | Entspricht „maximaler Konfiguration“: alle relevanten Timing‑Dimensionen über UI, insbesondere **pro Slot**; keine impliziten stationären Constraints. |
+
+---
+
+## 2. Kanonisches Zeit‑Schema (`timing_schema`)
+
+**Empfohlene Versionierung im Objekt:**
+
+- **`timing_schema: 1`** — sobald neue globale/strukturierte Felder aktiv genutzt werden (Pilot; UI kann ohne Migration starten durch parallele Schlüssel).
+
+### 2.1 Globalebene (`method_profile`)
+
+| Feld (Pilot) | Semantik |
+|----------------|----------|
+| `timing_schema` | `1` wenn Block unten aktiv |
+| `intro_sec` oder bestehend `block_intro_sec` | einmalige Einführung/Demo am Block |
+| `rounds` (bzw. bei Intervallen `interval_rounds` — Angleich später) | komplette Durchläufe des Musters |
+| *Planned totals* nur **berechnete Anzeige** in UI, optional persistiert z. B. `planned_total_duration_min_hint` später |
+
+Relationen **Zwischen Arbeit und Pause** können als Schnellwahl gesetzt werden (kein eigener Persist‑Erzwing‑Typ nötig), indem konkrete Sekunden geschrieben werden.
+
+### 2.2 Slots (`slot_profiles_v1`)
+
+Array synchron zu `slot_index`; fehlende Einträge = „nicht gefüllt / aus globalen Eckdaten ableiten wo sinnvoll“.
+
+Objekt‑Shape (Sekunden, ganze Zahlen ≥ 0):
+
+```json
+{
+ "slot_index": 0,
+ "load_sec": 40,
+ "consecutive_reps": 1,
+ "intra_rep_rest_sec": 10,
+ "transition_after_sec": 15
+}
+```
+
+| Feld | Bedeutung |
+|------|------------|
+| `load_sec` | Belastungsdauer „an der Station“. |
+| `consecutive_reps` | Übungen hintereinander ohne Wechsel zu **neuem** Stationsinhalt („oft 1“). |
+| `intra_rep_rest_sec` | Pause zwischen diesen Folge‑Wiederholungen. |
+| `transition_after_sec` | Pause / Wechsel **zur nächsten** Station oder zum nächsten logischen Block. |
+
+**Hinweis:** Bestehende Archetyp‑„flachen“ Schlüssel (`work_seconds`, `transition_seconds`, …) werden schrittweise **nicht zerstört**, sondern Slots ergänzen; Konvergenz (eine Darstellung zu v1) kann Phase 4 sein.
+
+---
+
+## 3. Archetyp → typische Schnellwahl (Überblicks‑Matrix)
+
+| Archetyp | Globale Schnellwahl (Beispiele) | Slots |
+|----------|---------------------------------|-------|
+| `circuit_rotate_time` | Arbeit; Rotation „≈ Arbeit“ oder „Pause 2/3 Arbeit“ bezogen auf Rund‑Pausen/Rotation wo im UI dokumentiert | sinnvoll ab **timing_schema** geführt |
+| `time_domain_interval` | Pause = Arbeit; Pause = 2/3 Arbeit (auf `rest_seconds`↔`work_seconds`) | optional |
+| `sequence_linear` | Einführung + grobe Sek./Station | **slot_profiles_v1** priorisiert |
+| `circuit_all_parallel` | Erklärzeit, gemeinsamer Start | Slots optional |
+| `pair_superset` | Wechsel A↔B, Arbeit je Seite (+ später erweiterbar) | 2‑Slot‑Fokus |
+| `free_method_block` | alle globalen Slots optional | **Pfad für maximale Flex** |
+| `station_parcour` | Reihenfolge frei‑Flag bestehend | pro Station Verweilen sinnvoll |
+
+**Pyramidal (später):** neue Archetyp‑ID **`pyramid_interval`** o. ä. oder Flag `pyramid_recovery_rule` mit Regelwerk „Pause abhängig von letzter Belastung“ — **explizit out of scope** bis Regeln feststehen.
+
+---
+
+## 4. UX‑Normen
+
+- **Trainingsplanung** (`plannerMode`): **keine** Roh‑JSON‑Oberfläche.
+- **Übungsformular**: Roh‑JSON nur wenn `allowExpertJson === true` (Default false; später z. B. Superadmin/Dev).
+- **Coaching‑Ansicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln.
+
+---
+
+## 5. Phasen (Implementierung)
+
+| Phase | Inhalt |
+|-------|--------|
+| **1 (jetzt)** | Slot‑Zeilen‑UI über `slot_profiles_v1`; Schnellwahl‑Ratios für `circuit_rotate_time` + `time_domain_interval`; `plannerMode` ohne JSON; `allowExpertJson` default false |
+| **2** | Beim Archetypwechsel **optionales** Modal „Archetyp‑Vorlage anwenden?“ mit nicht‑destruktivem Merge |
+| **3** | Geplante **Gesamtzeit** konsistent rechnerisch (Summe Slots × Runden + Global) mit Transparenz in UI |
+| **4** | Konsolidierung flacher Schlüssel → **`timing_schema`** v1‑only im Editor |
+| **5** | Pyramide / adaptive Recovery |
+
+---
+
+**Pflege:** Änderungen an Schlüsseln oder Phasen hier und in Anhang A der Fachspez mitziehen.
diff --git a/backend/version.py b/backend/version.py
index ae392af..2988c92 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.103"
+APP_VERSION = "0.8.104"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.24.2", # Kombi: geführtes method_profile im Übungsformular nach Archetyp + Coach zeigt Profil als Key/Wert
+ "exercises": "2.25.0", # Kombi: slot_profiles_v1 + Schnellwahl Belastung/Erholung; keine Nutzer‑JSON‑Pflicht; Übungsform Stationen vor Ablaufprofil
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
@@ -35,6 +35,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.104",
+ "date": "2026-05-12",
+ "changes": [
+ "Kombinations‑Ablaufprofil UX: Stationszeilen (slot_profiles_v1); Schnellwahlen Arbeit↔Pause (Zirkel + Intervall); Planungs‑Override ohne JSON; Übungsformular: Reihenfolge Stationen dann Ablaufprofil.",
+ "Arbeitspapier: `.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`.",
+ ],
+ },
{
"version": "0.8.103",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index f71eadf..f205fa6 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -1,10 +1,15 @@
import React, { useMemo, useState } from 'react'
-import { archetypeCoachHint, combinationArchetypeLabel } from '../constants/combinationArchetypes'
+import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
setFullProfileRawJson,
updateProfileGuided,
+ patchMethodProfile,
+ readSlotProfilesV1,
+ patchSlotTimingField,
+ applyCircuitRotateQuickRatio,
+ applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
function clampInt(n, min, max) {
@@ -15,13 +20,31 @@ function clampInt(n, min, max) {
return Math.round(x)
}
+/** Archetypen mit klar bezifferbarer Stationslogik · alle mit Slot-Liste sinnvoll */
+const ARCHETYPES_WITH_SLOT_TIMING = new Set([
+ 'circuit_rotate_time',
+ 'sequence_linear',
+ 'station_parcour',
+ 'time_domain_interval',
+ 'circuit_all_parallel',
+ 'pair_superset',
+ 'free_method_block',
+])
+
/**
- * Kombination: geführtes Ablaufprofil + optionales Roh-JSON.
+ * Kombination: geführtes method_profile (+ optional Stationszeilen, ohne JSON für Trainer).
+ *
+ * @param {boolean} [props.plannerMode] — z. B. Planungs‑Override: keine Roh‑JSON‑Sektion.
+ * @param {boolean} [props.allowExpertJson] — wenn true und nicht plannerMode: Roh‑JSON (Support).
+ * @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] — für Slot‑Felder aus der Übung
*/
export default function CombinationMethodProfileEditor({
methodArchetype,
methodProfileJson,
onChangeMethodProfileJson,
+ plannerMode = false,
+ allowExpertJson = false,
+ comboSlotsOutline = null,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
@@ -29,20 +52,36 @@ export default function CombinationMethodProfileEditor({
const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
const [rawOpenError, setRawOpenError] = useState(null)
const [rawDraft, setRawDraft] = useState(null)
+ const [presetHint, setPresetHint] = useState(null)
const profileObj = parseState.ok ? parseState.obj : {}
+ const outlineSorted = useMemo(() => {
+ if (!comboSlotsOutline || !Array.isArray(comboSlotsOutline) || comboSlotsOutline.length === 0) return []
+ return sortCombinationSlotsForDisplay(comboSlotsOutline)
+ }, [comboSlotsOutline])
+
+ const showSlotTiming =
+ ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
+
+ const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
+
+ const lookupSlotTiming = (slotIndex) =>
+ slotRowsModel.find((r) => Number(r.slot_index) === Number(slotIndex)) || {}
+
const applyGuided = (key, value, kind) => {
if (kind === 'bool') {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
+ setPresetHint(null)
return
}
if (value === '' || value === undefined || value === null) {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
+ setPresetHint(null)
return
}
const num = typeof value === 'number' ? value : parseInt(String(value), 10)
@@ -53,6 +92,36 @@ export default function CombinationMethodProfileEditor({
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
+ setPresetHint(null)
+ }
+
+ const onSlotField = (slotIx, field, rawStr) => {
+ const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
+ patchSlotTimingField(d, slotIx, field, rawStr)
+ )
+ if (!patched.ok) return
+ onChangeMethodProfileJson(patched.json)
+ setPresetHint(null)
+ }
+
+ const runCircuitPreset = (presetId) => {
+ const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
+ const pr = applyCircuitRotateQuickRatio(draft, presetId)
+ if (!pr.ok) setPresetHint(pr.error || '')
+ else setPresetHint(null)
+ })
+ if (!r.ok) return
+ onChangeMethodProfileJson(r.json)
+ }
+
+ const runIntervalPreset = (presetId) => {
+ const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
+ const pr = applyIntervalDomainQuickRatio(draft, presetId)
+ if (!pr.ok) setPresetHint(pr.error || '')
+ else setPresetHint(null)
+ })
+ if (!r.ok) return
+ onChangeMethodProfileJson(r.json)
}
const archeLabel = arch ? combinationArchetypeLabel(arch) : null
@@ -63,8 +132,14 @@ export default function CombinationMethodProfileEditor({
setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
}
+ const showExpertSection = allowExpertJson && !plannerMode
+
return (
+ {presetHint ? (
+
{presetHint}
+ ) : null}
+
{arch ? (
@@ -75,7 +150,8 @@ export default function CombinationMethodProfileEditor({
) : (
- Wähle einen Archetyp, um das Ablaufprofil strukturiert zu erfassen — oder nur das JSON weiter unten.
+ Wähle einen Methoden‑Archetyp — besonders beim freien Methodenblock stehen alle
+ typischen Stations‑Zeiten zur Verfügung. Ohne Archetyp keine geführten Eingaben.
)}
@@ -85,6 +161,48 @@ export default function CombinationMethodProfileEditor({
{fields && fields.length > 0 ? (
+ {arch === 'circuit_rotate_time' ? (
+
+ Schnellwahl:
+ runCircuitPreset('transition_equals_work')}>
+ Wechsel ≈ Arbeit
+
+ runCircuitPreset('round_rest_equals_work')}>
+ Runden‑Pause ≈ Arbeit
+
+ runCircuitPreset('round_rest_two_thirds_work')}>
+ Runden‑Pause ≈ ⅔ Arbeit
+
+
+ ) : null}
+ {arch === 'time_domain_interval' ? (
+
+ Schnellwahl:
+ runIntervalPreset('rest_equals_work')}>
+ Erholung = Belastung
+
+ runIntervalPreset('rest_two_thirds_work')}>
+ Erholung ≈ ⅔ Belastung
+
+
+ ) : null}
+
{fields.map((def) => {
if (def.kind === 'bool') {
const ck = !!profileObj[def.key]
@@ -125,51 +243,170 @@ export default function CombinationMethodProfileEditor({
})}
) : arch && fields && fields.length === 0 ? (
-
- Für diesen Archetyp gibt es keine vorgegebenen Profilfelder — nutze die Freitexte der Kombination oder Roh‑JSON bei Bedarf.
-
+
+
+ Dieser Archetyp ist für maximal flexible Stationsblöcke gedacht — die Zeit‑Eckdaten sind
+ unten je Station möglich. Freitexte der Kombination beschreiben alles Organisatorische, was nicht in
+ Sekunden gefasst wird.
+
+
) : null}
-
{
- if (ev.target.open) openAdvanced()
- }}
- >
- Erweitert: JSON direkt bearbeiten
-
- Zusätzliche Schlüssel (Piloten). Geführte Felder können dieselben Schlüssel beim nächsten Speichern überlagern.
-
- {
- setRawDraft(e.target.value)
- setRawOpenError(null)
+ {showSlotTiming ? (
+ {
- const src = rawDraft != null ? rawDraft : methodProfileJson
- const res = setFullProfileRawJson(src || '{}')
- if (!res.ok) {
- setRawOpenError(res.error)
- return
- }
- setRawOpenError(null)
- setRawDraft(null)
- onChangeMethodProfileJson(res.json)
+ >
+
+ Pro Station / Slot (Zeiten in Sekunden)
+
+
+ Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
+ zur nächsten Station. Felder können leer bleiben — z. B. nutzt der Zirkel oben erst die globalen Arbeit‑/
+ Rotations‑Sekunden.
+
+
+ {outlineSorted.map((slot) => {
+ const siRaw = slot.slot_index
+ const si =
+ siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
+ if (!Number.isFinite(si)) return null
+ const row = lookupSlotTiming(si)
+ const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
+ return (
+
+
+ Station {si}
+ {ttl}
+
+
+
+ )
+ })}
+
+
+ ) : null}
+
+ {showExpertSection ? (
+
- {rawOpenError ? {rawOpenError}
: null}
-
+ onToggle={(ev) => {
+ if (ev.target.open) openAdvanced()
+ }}
+ >
+
+ Support / Entwicklung: Rohdaten (JSON)
+
+
+ Für Migrationen und Sonderfälle. Geführte Felder setzen weiterhin gültige Standardschlüssel.
+
+
{
+ setRawDraft(e.target.value)
+ setRawOpenError(null)
+ }}
+ spellCheck={false}
+ onBlur={() => {
+ const src = rawDraft != null ? rawDraft : methodProfileJson
+ const res = setFullProfileRawJson(src || '{}')
+ if (!res.ok) {
+ setRawOpenError(res.error)
+ return
+ }
+ setRawOpenError(null)
+ setRawDraft(null)
+ onChangeMethodProfileJson(res.json)
+ }}
+ />
+ {rawOpenError ? (
+ {rawOpenError}
+ ) : null}
+
+ ) : null}
)
}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 6ea4963..c824305 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1066,6 +1066,7 @@ export default function TrainingUnitSectionsEditor({
-
- Ablaufprofil (über Archetyp)
- updateFormField('method_profile_json', s)}
- />
-
-
+
Stationen
-
- Index (Reihenfolge), optional Stationstitel und kommaseparierte IDs von{' '}
- Einzelübungen im Pool (nur Übungen mit Art „Einzelübung“).
+
+ Zuerst die Stationen anlegen — dann erscheinen die Zeiten darunter auch pro Slot im Ablaufprofil.
{(formData.combination_slots || []).map((row, idx) => (
+
+ Ablaufprofil (Zeiten & Runden)
+ updateFormField('method_profile_json', s)}
+ comboSlotsOutline={formData.combination_slots || []}
+ />
+
>
) : null}
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index 646b541..1beb348 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -162,4 +162,149 @@ export function setFullProfileRawJson(rawEditable) {
return { ok: true, obj: parsed.obj, json: j === '{}' ? '{}' : j }
}
+/**
+ * Pfad für slot_profiles_v1 und ähnliche strukturierte Erweiterungen.
+ * Ungültiges JSON gibt { ok:false } zurück; mutator erhält geklontes Profil‑Objekt.
+ */
+export function patchMethodProfile(rawJson, mutator) {
+ const parsed = parseProfileJson(rawJson || '{}')
+ if (!parsed.ok) return parsed
+ const draft = { ...parsed.obj }
+ mutator(draft)
+ try {
+ const j = JSON.stringify(draft)
+ return { ok: true, obj: draft, json: j === '{}' ? '{}' : j }
+ } catch {
+ return { ok: false, error: 'Ablaufprofil konnte nicht gespeichert werden.' }
+ }
+}
+
+/** Normalisiert slot_profiles_v1 aus dem gespeicherten Profil */
+export function readSlotProfilesV1(profileObj) {
+ if (!profileObj || typeof profileObj !== 'object') return []
+ const raw = profileObj.slot_profiles_v1
+ if (!Array.isArray(raw)) return []
+ return raw.map((row) => {
+ if (!row || typeof row !== 'object') return null
+ const si = Number(row.slot_index)
+ return {
+ slot_index: Number.isFinite(si) ? si : 0,
+ load_sec: normalizeOptionalNonNegInt(row.load_sec),
+ consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
+ intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
+ transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
+ }
+ }).filter(Boolean)
+}
+
+function normalizeOptionalNonNegInt(v) {
+ if (v === '' || v === undefined || v === null) return undefined
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10)
+ if (!Number.isFinite(n) || n < 0) return undefined
+ return Math.round(n)
+}
+
+function normalizeOptionalPositiveInt(v) {
+ const n = normalizeOptionalNonNegInt(v)
+ if (n === undefined) return undefined
+ if (n < 1) return undefined
+ return n
+}
+
+const SLOT_TIMING_FIELDS = /** @type {const} */ ([
+ 'load_sec',
+ 'consecutive_reps',
+ 'intra_rep_rest_sec',
+ 'transition_after_sec',
+])
+
+/** '', null = Feld entfernen; sonst gültige Zahl setzen */
+export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
+ if (!SLOT_TIMING_FIELDS.includes(field)) return
+ const ix =
+ typeof slotIndex === 'number' && Number.isFinite(slotIndex)
+ ? slotIndex
+ : parseInt(String(slotIndex), 10)
+ if (!Number.isFinite(ix)) return
+
+ let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
+ let found = arr.findIndex((r) => r && typeof r === 'object' && Number(r.slot_index) === ix)
+
+ const nextRow = {}
+ if (found >= 0 && arr[found] && typeof arr[found] === 'object') {
+ Object.assign(nextRow, arr[found])
+ }
+ nextRow.slot_index = ix
+
+ if (rawInput === null || rawInput === undefined || String(rawInput).trim() === '') {
+ delete nextRow[field]
+ } else if (field === 'consecutive_reps') {
+ const n = normalizeOptionalPositiveInt(rawInput)
+ if (n === undefined) delete nextRow[field]
+ else nextRow[field] = n
+ } else {
+ const n = normalizeOptionalNonNegInt(rawInput)
+ if (n === undefined) delete nextRow[field]
+ else nextRow[field] = n
+ }
+
+ const hasTiming = SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
+
+ if (found >= 0) {
+ if (!hasTiming) {
+ arr = arr.filter((_, i) => i !== found)
+ } else {
+ arr[found] = nextRow
+ }
+ } else if (hasTiming) {
+ arr.push(nextRow)
+ }
+
+ arr.sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
+
+ if (arr.length === 0) delete profileDraft.slot_profiles_v1
+ else profileDraft.slot_profiles_v1 = arr
+}
+
+/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
+export function applyCircuitRotateQuickRatio(profileDraft, preset) {
+ const wRaw = profileDraft.work_seconds
+ const work =
+ typeof wRaw === 'number' && Number.isFinite(wRaw) ? Math.round(wRaw) : parseInt(String(wRaw), 10)
+ if (!Number.isFinite(work) || work <= 0)
+ return { ok: false, error: 'Zuerst Arbeitszeit pro Station (Sek.) setzen.' }
+ profileDraft.timing_schema = profileDraft.timing_schema ?? 1
+ if (preset === 'transition_equals_work') {
+ profileDraft.transition_seconds = work
+ return { ok: true }
+ }
+ if (preset === 'round_rest_equals_work') {
+ profileDraft.rest_seconds = work
+ return { ok: true }
+ }
+ if (preset === 'round_rest_two_thirds_work') {
+ profileDraft.rest_seconds = Math.round((work * 2) / 3)
+ return { ok: true }
+ }
+ return { ok: false, error: 'Unbekannte Schnellwahl.' }
+}
+
+export function applyIntervalDomainQuickRatio(profileDraft, preset) {
+ const wRaw = profileDraft.work_seconds
+ const work =
+ typeof wRaw === 'number' && Number.isFinite(wRaw) ? Math.round(wRaw) : parseInt(String(wRaw), 10)
+ if (!Number.isFinite(work) || work <= 0)
+ return { ok: false, error: 'Zuerst Belastungszeit Intervall (Sek.) setzen.' }
+ profileDraft.timing_schema = profileDraft.timing_schema ?? 1
+ if (preset === 'rest_equals_work') {
+ profileDraft.rest_seconds = work
+ return { ok: true }
+ }
+ if (preset === 'rest_two_thirds_work') {
+ profileDraft.rest_seconds = Math.round((work * 2) / 3)
+ return { ok: true }
+ }
+ return { ok: false, error: 'Unbekannte Schnellwahl.' }
+}
+
export { parseProfileJson, INT_MAX }
--
2.43.0
From ce63d46cf427e113f8d80a457f8a25a669f58147 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 07:53:00 +0200
Subject: [PATCH 16/27] feat(version): bump to 0.8.105 and enhance combination
exercise features
- Updated app version to 0.8.105, reflecting recent improvements in combination exercise handling.
- Added support for per-slot timing options in the CombinationMethodProfileEditor, allowing for more flexible exercise configurations.
- Enhanced the ExerciseFormPage to manage combination slots more effectively, including new functions for reordering and merging exercises.
- Updated changelog to document the latest changes and improvements.
Co-Authored-By: Claude Sonnet 4.6
---
backend/version.py | 9 +-
.../CombinationMethodProfileEditor.jsx | 3 +-
frontend/src/pages/ExerciseFormPage.jsx | 497 ++++++++++++++----
frontend/src/utils/api.js | 52 +-
.../src/utils/combinationMethodProfileUi.js | 30 +-
5 files changed, 466 insertions(+), 125 deletions(-)
diff --git a/backend/version.py b/backend/version.py
index 2988c92..f937795 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.104"
+APP_VERSION = "0.8.105"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.105",
+ "date": "2026-05-12",
+ "changes": [
+ "Übungsbearbeitung Kombi: Stationen mit Pool per Modal (nur Einzelübungen), Zeiten pro Station in derselben Karte, Drag&Drop + Pfeile statt Index; API schreibt slot_index aus Reihenfolge; Gesamtdurchläufe bei Zirkel/Sequenz/Parcours/Parallel klar beschriftet.",
+ ],
+ },
{
"version": "0.8.104",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index f205fa6..cb6f5f4 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -45,6 +45,7 @@ export default function CombinationMethodProfileEditor({
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
+ omitPerSlotTiming = false,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
@@ -62,7 +63,7 @@ export default function CombinationMethodProfileEditor({
}, [comboSlotsOutline])
const showSlotTiming =
- ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
+ !omitPerSlotTiming && ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index ad92334..f384086 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -8,6 +8,7 @@ import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import MediaPreviewModal from '../components/MediaPreviewModal'
import ReportContentModal from '../components/ReportContentModal'
import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
+import ExercisePickerModal from '../components/ExercisePickerModal'
import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
@@ -16,6 +17,8 @@ import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
+import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
+import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
@@ -32,16 +35,52 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' },
]
+/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
+const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
+
+function emptyComboSlotRow() {
+ return {
+ title: '',
+ candidate_exercise_ids: [],
+ exercise_title_by_id: {},
+ load_sec: '',
+ consecutive_reps: '',
+ intra_rep_rest_sec: '',
+ transition_after_sec: '',
+ }
+}
+
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
+ const mp =
+ exercise?.method_profile &&
+ typeof exercise.method_profile === 'object' &&
+ !Array.isArray(exercise.method_profile)
+ ? exercise.method_profile
+ : {}
+ const spvList = readSlotProfilesV1(mp)
+ const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
+
if (!Array.isArray(raw) || raw.length === 0) {
- return [{ slot_index: 0, title: '', idsText: '' }]
+ return [emptyComboSlotRow()]
}
- return raw.map((s, i) => ({
- slot_index: s.slot_index != null ? Number(s.slot_index) : i,
- title: s.title != null ? String(s.title) : '',
- idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '',
- }))
+ const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
+ return sorted.map((s) => {
+ const si = Number(s.slot_index)
+ const st = byIx.get(si) || {}
+ const cands = Array.isArray(s.candidate_exercise_ids)
+ ? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
+ : []
+ return {
+ title: s.title != null ? String(s.title) : '',
+ candidate_exercise_ids: cands,
+ exercise_title_by_id: {},
+ load_sec: st.load_sec != null ? String(st.load_sec) : '',
+ consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
+ intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
+ transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
+ }
+ })
}
function emptyVariantDraft() {
@@ -266,7 +305,7 @@ function emptyForm() {
exercise_kind: 'simple',
method_archetype: '',
method_profile_json: '{}',
- combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
+ combination_slots: [emptyComboSlotRow()],
}
}
@@ -569,6 +608,69 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
+ const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
+ const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
+
+ const reorderCombinationSlots = (fromI, toBeforeIx) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ if (fromI < 0 || fromI >= rows.length) return prev
+ const [moved] = rows.splice(fromI, 1)
+ let insertAt = toBeforeIx
+ if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
+ insertAt = Math.max(0, Math.min(insertAt, rows.length))
+ rows.splice(insertAt, 0, moved)
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const patchComboSlotRow = (idx, patch) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ if (!rows[idx]) return prev
+ rows[idx] = { ...rows[idx], ...patch }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const removeCandidateFromSlot = (slotIdx, exerciseId) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ const row = rows[slotIdx]
+ if (!row) return prev
+ const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
+ const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
+ delete labels[Number(exerciseId)]
+ rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
+ if (!Array.isArray(pickedList) || !pickedList.length) return
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ const row = rows[slotIdx] || emptyComboSlotRow()
+ const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n)))
+ const labels =
+ row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
+ pickedList.forEach((ex) => {
+ if (ex && ex.id != null) {
+ const id = Number(ex.id)
+ nextSet.add(id)
+ const t = (ex.title || '').trim()
+ if (t) labels[id] = t
+ }
+ })
+ rows[slotIdx] = { ...row, candidate_exercise_ids: [...nextSet], exercise_title_by_id: labels }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
const addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) {
@@ -1004,7 +1106,7 @@ function ExerciseFormPage() {
? {
method_archetype: '',
method_profile_json: '{}',
- combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
+ combination_slots: [emptyComboSlotRow()],
}
: {}),
}))
@@ -1031,119 +1133,290 @@ function ExerciseFormPage() {
))}
-
-
Stationen
-
- Zuerst die Stationen anlegen — dann erscheinen die Zeiten darunter auch pro Slot im Ablaufprofil.
+ {String(formData.method_archetype || '').trim() === 'station_parcour' ? (
+
+ Parcours / Bahnsystem: typischerweise starten alle an Station 1 und durchlaufen der
+ Reihe nach alle Punkte (Geschwindigkeit variabel). Die Stationsreihenfolge unten ist der Ablaufweg;
+ Zeitangaben pro Station und Gesamtdurchläufe im Ablaufprofil strukturieren das
+ spätere Coaching.
- {(formData.combination_slots || []).map((row, idx) => (
-
-
-
- Idx.
-
- {
- const next = [...(formData.combination_slots || [])]
- const v = e.target.value
- next[idx] = {
- ...row,
- slot_index: v === '' ? '' : parseInt(v, 10),
- }
- updateFormField('combination_slots', next)
- }}
- />
-
-
-
- Titel
-
- {
- const next = [...(formData.combination_slots || [])]
- next[idx] = { ...row, title: e.target.value }
- updateFormField('combination_slots', next)
- }}
- />
-
-
-
- Übungs-IDs
-
- {
- const next = [...(formData.combination_slots || [])]
- next[idx] = { ...row, idsText: e.target.value }
- updateFormField('combination_slots', next)
- }}
- placeholder="z. B. 12, 34, 56"
- />
-
-
{
- const prev = formData.combination_slots || []
- const next = prev.filter((_, j) => j !== idx)
- updateFormField(
- 'combination_slots',
- next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
- )
+ ) : null}
+
+
+ Stationen und Übungs‑Pool
+
+ Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
+
+
+
+ Jede Station: Titel (optional), am Ort wählbare Einzelübungen sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen).
+
+ {(formData.combination_slots || []).map((row, idx) => {
+ const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
+ const lbl =
+ row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
+ ? row.exercise_title_by_id
+ : {}
+ const isDropHere = comboDropTargetIx === idx
+ return (
+
{
+ if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ setComboDropTargetIx(idx)
+ }}
+ onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
+ onDrop={(e) => {
+ const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
+ const fromI = parseInt(rawFrom, 10)
+ e.preventDefault()
+ setComboDropTargetIx(null)
+ if (!Number.isFinite(fromI)) return
+ reorderCombinationSlots(fromI, idx)
+ }}
+ style={{
+ marginBottom: '12px',
+ padding: '12px 14px',
+ borderRadius: '12px',
+ border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
+ background: 'var(--surface)',
+ boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
}}
>
- Entf.
-
-
- ))}
+
+
{
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData(DND_EXERCISE_COMBO_STATION, String(idx))
+ }}
+ onDragEnd={() => setComboDropTargetIx(null)}
+ aria-label={`Station ${idx + 1} ziehen`}
+ title="Ziehen zum Sortieren"
+ className="btn btn-secondary framework-ctrl framework-ctrl--xs"
+ style={{ cursor: 'grab', padding: '6px 8px', touchAction: 'none' }}
+ >
+
+
+
+ reorderCombinationSlots(idx, idx - 1)}
+ >
+ ▲
+
+ reorderCombinationSlots(idx, idx + 2)}
+ >
+ ▼
+
+
+
+
+ Station {idx + 1} — Titel
+
+ patchComboSlotRow(idx, { title: e.target.value })}
+ />
+
+
+ setComboStationPickerIx(idx)}
+ >
+ Einzelübungen wählen…
+
+ {
+ const prev = formData.combination_slots || []
+ const next = prev.filter((_, j) => j !== idx)
+ updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
+ }}
+ >
+ Station entfernen
+
+
+
+
+
+ Gewählte Einzelübungen (Pool für diese Station)
+
+ {candIds.length === 0 ? (
+
Noch keine Übung gewählt — mindestens eine erforderlich zum Speichern.
+ ) : (
+
+ {candIds.map((id) => (
+
+
+ {(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
+
+ removeCandidateFromSlot(idx, id)}
+ >
+ ✗
+
+
+ ))}
+
+ )}
+
+
+
+ )
+ })}
+ {
+ if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ }}
+ onDrop={(e) => {
+ const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
+ const fromI = parseInt(rawFrom, 10)
+ e.preventDefault()
+ setComboDropTargetIx(null)
+ if (!Number.isFinite(fromI)) return
+ const len = (formData.combination_slots || []).length
+ reorderCombinationSlots(fromI, len)
+ }}
+ style={{
+ padding: '10px',
+ textAlign: 'center',
+ fontSize: '11px',
+ color: 'var(--text3)',
+ border: '1px dashed var(--border)',
+ borderRadius: '10px',
+ marginBottom: '8px',
+ }}
+ >
+ Hier ablegen zum Anhängen am Ende der Reihenfolge
+
{
- const cur = formData.combination_slots || []
- const ixList = cur
- .map((r) =>
- typeof r.slot_index === 'number' && !Number.isNaN(r.slot_index) ? r.slot_index : null,
- )
- .filter((n) => n != null)
- const nextIx = ixList.length ? Math.max(...ixList) + 1 : 0
- updateFormField('combination_slots', [
- ...cur,
- { slot_index: nextIx, title: '', idsText: '' },
- ])
- }}
+ onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
>
- + Station
+ + Station hinzufügen
- Ablaufprofil (Zeiten & Runden)
+ Ablaufprofil (Globale Zeiten & Gesamtdurchläufe)
updateFormField('method_profile_json', s)}
- comboSlotsOutline={formData.combination_slots || []}
+ comboSlotsOutline={(formData.combination_slots || []).map((r, i) => ({
+ slot_index: i,
+ title: r.title || '',
+ }))}
+ omitPerSlotTiming
/>
>
@@ -1871,6 +2144,18 @@ function ExerciseFormPage() {
}
/>
)}
+
setComboStationPickerIx(null)}
+ exerciseKindAny={['simple']}
+ multiSelect
+ enableQuickCreateDraft
+ onSelectExercises={(picked) => {
+ if (comboStationPickerIx === null) return
+ mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
+ setComboStationPickerIx(null)
+ }}
+ />
{reportTarget && (
99) {
- throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
+ let ids = Array.isArray(row.candidate_exercise_ids)
+ ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
+ : []
+
+ /** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
+ if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
+ ids = row.idsText
+ .split(/[\s,;]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((s) => parseInt(s, 10))
+ .filter((n) => Number.isFinite(n))
}
- const idsText = typeof row.idsText === 'string' ? row.idsText : ''
- const candidate_exercise_ids = idsText
- .split(/[\s,;]+/)
- .map((s) => s.trim())
- .filter(Boolean)
- .map((s) => parseInt(s, 10))
- .filter((n) => Number.isFinite(n))
+
combination_slots.push({
- slot_index: ix,
+ slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null,
- candidate_exercise_ids,
+ candidate_exercise_ids: ids,
})
}
+ const slot_profiles_v1_next = []
+ for (let i = 0; i < slotRows.length; i += 1) {
+ const row = slotRows[i] || {}
+ const o = { slot_index: i }
+ const load = parseTimingField(row.load_sec)
+ const crs = parseTimingField(row.consecutive_reps)
+ const intra = parseTimingField(row.intra_rep_rest_sec)
+ const tran = parseTimingField(row.transition_after_sec)
+ if (load !== undefined && load >= 0) o.load_sec = Math.round(load)
+ if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
+ if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
+ if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
+ if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
+ }
+
payload.method_archetype = (formData.method_archetype || '').trim() || null
+ if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
+ else delete mpObj.slot_profiles_v1
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index 1beb348..f1643ef 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -21,6 +21,13 @@ function parseProfileJson(raw) {
/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
sequence_linear: [
+ {
+ key: 'rounds',
+ kind: 'int',
+ label: 'Anzahl Gesamtdurchläufe (komplette Sequenz, alle Stationen nacheinander)',
+ min: 1,
+ max: 999,
+ },
{
key: 'hint_step_duration_sec',
kind: 'int',
@@ -37,6 +44,14 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
},
],
circuit_rotate_time: [
+ {
+ key: 'rounds',
+ kind: 'int',
+ label:
+ 'Anzahl Gesamtdurchläufe (jede Station pro Sportler mehrfach beim Umlauf, z. B. 4 Stationen × 2 = zwei komplette Runden)',
+ min: 1,
+ max: 999,
+ },
{
key: 'work_seconds',
kind: 'int',
@@ -58,15 +73,15 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
min: 0,
max: INT_MAX,
},
+ ],
+ circuit_all_parallel: [
{
key: 'rounds',
kind: 'int',
- label: 'Runden (optional, wenn alle Station je Runde angefahren werden)',
+ label: 'Anzahl Durchläufe (wenn alle parallel dieselbe Rundenlogik haben, optional)',
min: 1,
max: 999,
},
- ],
- circuit_all_parallel: [
{
key: 'explain_before_seconds',
kind: 'int',
@@ -81,6 +96,13 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
},
],
station_parcour: [
+ {
+ key: 'rounds',
+ kind: 'int',
+ label: 'Anzahl Durchläufe des Parcours (Start Station 1, alle Bahnpunkte, Wiederholung bei Bedarf)',
+ min: 1,
+ max: 999,
+ },
{
key: 'allow_free_visit_order',
kind: 'bool',
@@ -121,7 +143,7 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
{
key: 'interval_rounds',
kind: 'int',
- label: 'Anzahl Wiederholungen / Runden der Domäne (optional)',
+ label: 'Anzahl Wiederholungen der Intervalldomäne (komplette Zyklen Arbeit/Pause)',
min: 1,
max: 999,
},
--
2.43.0
From 38d84ecdf6684cb0a813c53b203dde1c02b54cf3 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 08:19:46 +0200
Subject: [PATCH 17/27] feat(version): bump to 0.8.106 and enhance combination
exercise features
- Updated app version to 0.8.106, reflecting recent improvements in combination exercise handling.
- Introduced `advance_mode` for slot profiles, allowing for flexible timing options (timed, repetitions, manual) in the CombinationMethodProfileEditor.
- Enhanced the CombinationCoachSlots component to display timing summaries based on the selected advance mode.
- Updated ExerciseFormPage to manage combination slots with new validation and user feedback for exercise selection.
- Documented changes in the changelog for better tracking of feature enhancements.
Co-Authored-By: Claude Sonnet 4.6
---
...e Kombinationsuebungen Spezifikation V2.md | 2 +
backend/version.py | 11 +-
.../src/components/CombinationCoachSlots.jsx | 62 ++++++-
.../CombinationMethodProfileEditor.jsx | 73 +++++---
frontend/src/pages/ExerciseFormPage.jsx | 158 +++++++++++++-----
frontend/src/utils/api.js | 5 +-
.../src/utils/combinationMethodProfileUi.js | 75 ++++++++-
7 files changed, 306 insertions(+), 80 deletions(-)
diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
index dd18836..b42be9b 100644
--- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
+++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
@@ -423,6 +423,8 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz**
**Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z. B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z. B. Zirkel: Relation Belastungszeit = Übergangszeit oder Erholungsanteil ≈ 2/3 der Belastung); der Archetyp **Freier Methodenblock** bildet den **Maximal‑Pfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden.
+**Fortschritt pro Slot (Stand 0.8.106):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). **`intra_rep_rest_sec`** und **`transition_after_sec`** bleiben unabhängig vom Modus nutzbar. **`load_sec`** wird nur im Modus `timed` persistiert.
+
### 6.4 Slot- und Pool-Logik
Slots können fest oder variabel sein.
diff --git a/backend/version.py b/backend/version.py
index f937795..02c0f10 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.105"
+APP_VERSION = "0.8.106"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.25.0", # Kombi: slot_profiles_v1 + Schnellwahl Belastung/Erholung; keine Nutzer‑JSON‑Pflicht; Übungsform Stationen vor Ablaufprofil
+ "exercises": "2.26.0", # Kombi: advance_mode je Station (slot_profiles_v1: timed|rep|manual); Payload/Coach-Lesetext
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.106",
+ "date": "2026-05-12",
+ "changes": [
+ "Kombinationsübung: Stationssteuerung `advance_mode` in `slot_profiles_v1` (zeitlich / Ziel‑Wiederholungen / Coach ohne Arbeitsuhr); Übungsformular + Planungs‑Profil‑Editor; API‑Payload verwirft Arbeit‑Sekunden außer bei Zeitmodus; Coach zeigt verkürzte Planzeile je Station.",
+ ],
+ },
{
"version": "0.8.105",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index 3645836..1dfee64 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -10,9 +10,28 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
+import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
+
+function summarizeSlotProfilesRow(r) {
+ if (!r) return null
+ const adv = r.advance_mode || 'timed'
+ const bits = []
+ if (adv === 'timed') {
+ bits.push('Zeit')
+ if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
+ } else if (adv === 'rep') {
+ bits.push('Ziel‑Wdh.')
+ if (r.consecutive_reps != null) bits.push(`${r.consecutive_reps}×`)
+ } else {
+ bits.push('Coach')
+ if (r.consecutive_reps != null) bits.push(`Richtwert ${r.consecutive_reps}×`)
+ }
+ if (r.intra_rep_rest_sec != null) bits.push(`Pause ${r.intra_rep_rest_sec}s`)
+ if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
+ return bits.join(' · ')
+}
export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
- const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
@@ -76,6 +95,23 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
+ const slotTimingByIx = useMemo(() => {
+ if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return new Map()
+ const rows = readSlotProfilesV1(methodProfile)
+ const m = new Map()
+ for (const r of rows) {
+ m.set(Number(r.slot_index), r)
+ }
+ return m
+ }, [methodProfile])
+
+ const methodProfileKvSansSlots = useMemo(() => {
+ if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
+ return Object.entries(methodProfile)
+ .filter(([k]) => k !== 'slot_profiles_v1')
+ .sort(([a], [b]) => a.localeCompare(b, 'de'))
+ }, [methodProfile])
+
return (
Geplantes Ablaufprofil (Katalog)
-
- {Object.entries(methodProfile)
- .sort(([a], [b]) => a.localeCompare(b, 'de'))
- .map(([k, val]) => (
+ {methodProfileKvSansSlots.length === 0 ? (
+
+ Nur stationsbezogene Daten (Zeiten/Zähl‑Steuerung) — siehe je Station unter der Überschrift „Plan:“.
+
+ ) : (
+
+ {methodProfileKvSansSlots.map(([k, val]) => (
{k}
@@ -143,7 +182,8 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
))}
-
+
+ )}
) : null}
@@ -162,11 +202,19 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
`Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
+ const ix = slot.slot_index != null ? Number(slot.slot_index) : si
+ const timingSummary = summarizeSlotProfilesRow(slotTimingByIx.get(ix))
+
return (
- 1 ? '6px' : '8px' }}>
+
1 ? '6px' : '8px' }}>
{slotTitle}
+ {timingSummary ? (
+
+ Plan: {timingSummary}
+
+ ) : null}
{candIds.length === 0 ? (
Keine Übung zugeordnet.
) : (
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index cb6f5f4..bdb0243 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -8,6 +8,8 @@ import {
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
+ patchSlotAdvanceMode,
+ normalizeAdvanceMode,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
@@ -105,6 +107,15 @@ export default function CombinationMethodProfileEditor({
setPresetHint(null)
}
+ const onSlotAdvanceChange = (slotIx, rawMode) => {
+ const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
+ patchSlotAdvanceMode(d, slotIx, rawMode)
+ )
+ if (!patched.ok) return
+ onChangeMethodProfileJson(patched.json)
+ setPresetHint(null)
+ }
+
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
@@ -265,12 +276,12 @@ export default function CombinationMethodProfileEditor({
}}
>
- Pro Station / Slot (Zeiten in Sekunden)
+ Pro Station / Slot — Steuerung & Sekunden
- Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
- zur nächsten Station. Felder können leer bleiben — z. B. nutzt der Zirkel oben erst die globalen Arbeit‑/
- Rotations‑Sekunden.
+ Steuerung: zeitlich (Arbeits‑Countdown), Zielzahl Wiederholungen oder Coach‑geführt ohne
+ Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar. Felder können leer bleiben — z. B. nutzt der
+ Zirkel erst die globalen Arbeit‑Sekunden.
{outlineSorted.map((slot) => {
@@ -280,6 +291,9 @@ export default function CombinationMethodProfileEditor({
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
+ const slotAdv = normalizeAdvanceMode(row.advance_mode)
+ const serieLabel =
+ slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert'
return (
{ttl}
+
+
+ Steuerung
+
+ onSlotAdvanceChange(si, e.target.value)}
+ >
+ Zeit (Arbeit in Sekunden)
+ Wiederholungen (Ziel)
+ Coach (ohne Arbeitsuhr)
+
+
+ {slotAdv === 'timed' ? (
+
+
+ Belastung (s)
+
+ onSlotField(si, 'load_sec', e.target.value)}
+ />
+
+ ) : null}
- Belastung (s)
+ {serieLabel}
onSlotField(si, 'load_sec', e.target.value)}
- />
-
-
-
- Wdh. ohne Wechsel
-
-
onSlotField(si, 'consecutive_reps', e.target.value)}
/>
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index f384086..9fdc60c 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -17,7 +17,7 @@ import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
-import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
+import { readSlotProfilesV1, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
@@ -38,11 +38,23 @@ const VARIANT_DIFFICULTY = [
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
+/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
+const MAX_COMBO_CANDIDATES_PER_STATION = 3
+
+const comboTinyNumberInputSx = {
+ width: '3.5rem',
+ maxWidth: '100%',
+ padding: '4px 6px',
+ fontSize: '0.8125rem',
+ textAlign: 'center',
+}
+
function emptyComboSlotRow() {
return {
title: '',
candidate_exercise_ids: [],
exercise_title_by_id: {},
+ advance_mode: 'timed',
load_sec: '',
consecutive_reps: '',
intra_rep_rest_sec: '',
@@ -75,6 +87,7 @@ function comboSlotsFromDetail(exercise) {
title: s.title != null ? String(s.title) : '',
candidate_exercise_ids: cands,
exercise_title_by_id: {},
+ advance_mode: normalizeAdvanceMode(st.advance_mode),
load_sec: st.load_sec != null ? String(st.load_sec) : '',
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
@@ -651,22 +664,38 @@ function ExerciseFormPage() {
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
if (!Array.isArray(pickedList) || !pickedList.length) return
+ const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
+ const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
+ ? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
+ : []
+ const ordered = [...existingIds]
+ pickedList.forEach((ex) => {
+ if (ex?.id == null) return
+ const id = Number(ex.id)
+ if (!Number.isFinite(id)) return
+ if (!ordered.includes(id)) ordered.push(id)
+ })
+ let nextIds = ordered
+ if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
+ window.alert(
+ `Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`,
+ )
+ nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
+ }
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
const row = rows[slotIdx] || emptyComboSlotRow()
- const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n)))
const labels =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
pickedList.forEach((ex) => {
if (ex && ex.id != null) {
const id = Number(ex.id)
- nextSet.add(id)
const t = (ex.title || '').trim()
if (t) labels[id] = t
}
})
- rows[slotIdx] = { ...row, candidate_exercise_ids: [...nextSet], exercise_title_by_id: labels }
+ rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
return { ...prev, combination_slots: rows }
})
}
@@ -1154,16 +1183,22 @@ function ExerciseFormPage() {
) : null}
- Stationen und Übungs‑Pool
+ Stationen
- Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
+ Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station
- Jede Station: Titel (optional), am Ort wählbare Einzelübungen sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen).
+ Pro Station oft eine feste Übung; höchstens drei als kleiner Auswahl‑Pool.
+ Unter Steuerung wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).
{(formData.combination_slots || []).map((row, idx) => {
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
+ const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
+ const slotAdv = normalizeAdvanceMode(row.advance_mode)
+ const serieLabel =
+ slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert'
+ const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '–' : '1'
const lbl =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
? row.exercise_title_by_id
@@ -1234,13 +1269,13 @@ function ExerciseFormPage() {
- Station {idx + 1} — Titel
+ Name (St. {idx + 1})
patchComboSlotRow(idx, { title: e.target.value })}
/>
@@ -1248,31 +1283,39 @@ function ExerciseFormPage() {
setComboStationPickerIx(idx)}
>
- Einzelübungen wählen…
+ + Übung
{
const prev = formData.combination_slots || []
const next = prev.filter((_, j) => j !== idx)
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
}}
>
- Station entfernen
+ Entfernen
- Gewählte Einzelübungen (Pool für diese Station)
+ Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_STATION})
{candIds.length === 0 ? (
-
Noch keine Übung gewählt — mindestens eine erforderlich zum Speichern.
+
+ Mindestens eine Übung — mit „+ Übung“ wählen.
+
) : (
{candIds.map((id) => (
@@ -1306,62 +1349,95 @@ function ExerciseFormPage() {
)}
+
+
+ Steuerung
+
+ {
+ const m = normalizeAdvanceMode(e.target.value)
+ const patch = { advance_mode: m }
+ if (m !== 'timed') patch.load_sec = ''
+ patchComboSlotRow(idx, patch)
+ }}
+ >
+ Zeit (Arbeit in Sekunden)
+ Wiederholungen (Ziel)
+ Coach (Weiter nach Freigabe)
+
+
+
+ {slotAdv === 'timed'
+ ? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
+ : slotAdv === 'rep'
+ ? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen; Pause/Wechsel können weiter automatisch unterstützt werden.'
+ : 'Ohne feste Arbeitsuhr auf dieser Station — Fortschritt im Coach später per Tippschritt; Pause/Wechsel optional weiter mit Sekunden.'}
+
+ {slotAdv === 'timed' ? (
+
+
+ Arbeit (s)
+
+ patchComboSlotRow(idx, { load_sec: e.target.value })}
+ />
+
+ ) : null}
-
- Belastung an Station (s)
+
+ {serieLabel}
patchComboSlotRow(idx, { load_sec: e.target.value })}
- />
-
-
-
- Wdh. ohne Wechsel
-
- patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
/>
-
- Pause zwischen Wdh. (s)
+
+ Pause (s)
patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
/>
-
- Pause / nächste Station (s)
+
+ Wechsel (s)
patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
/>
@@ -1403,11 +1479,11 @@ function ExerciseFormPage() {
style={{ fontSize: '12px', marginTop: '4px' }}
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
>
- + Station hinzufügen
+ + Station
-
Ablaufprofil (Globale Zeiten & Gesamtdurchläufe)
+
Ablaufprofil (Runden & global)
= 0) o.load_sec = Math.round(load)
+ if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index f1643ef..a52122c 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -5,6 +5,16 @@
const INT_MAX = 86400
+/** Pro Station: zeitlich (Standard), mengenorientiert oder coachgeführt (ohne Arbeits-Countdown). */
+export const SLOT_ADVANCE_MODES = Object.freeze(['timed', 'rep', 'manual'])
+
+export function normalizeAdvanceMode(v) {
+ const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
+ if (s === 'rep' || s === 'reps' || s === 'count') return 'rep'
+ if (s === 'manual' || s === 'coach' || s === 'coach_led') return 'manual'
+ return 'timed'
+}
+
function parseProfileJson(raw) {
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
try {
@@ -209,13 +219,16 @@ export function readSlotProfilesV1(profileObj) {
return raw.map((row) => {
if (!row || typeof row !== 'object') return null
const si = Number(row.slot_index)
- return {
+ const mode = normalizeAdvanceMode(row.advance_mode)
+ const out = {
slot_index: Number.isFinite(si) ? si : 0,
+ advance_mode: mode,
load_sec: normalizeOptionalNonNegInt(row.load_sec),
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
}
+ return out
}).filter(Boolean)
}
@@ -240,6 +253,55 @@ const SLOT_TIMING_FIELDS = /** @type {const} */ ([
'transition_after_sec',
])
+function slotProfileRowShouldKeep(nextRow) {
+ if (!nextRow || typeof nextRow !== 'object') return false
+ const mode = normalizeAdvanceMode(nextRow.advance_mode)
+ if (mode !== 'timed') return true
+ return SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
+}
+
+function writeSlotProfilesV1Arr(profileDraft, arr) {
+ const sorted = [...arr].sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
+ if (sorted.length === 0) delete profileDraft.slot_profiles_v1
+ else profileDraft.slot_profiles_v1 = sorted
+}
+
+/** Steuert Ende der Arbeitsphase: Zeit, Wiederholungsziel oder nur manuell weiter. */
+export function patchSlotAdvanceMode(profileDraft, slotIndex, modeRaw) {
+ const ix =
+ typeof slotIndex === 'number' && Number.isFinite(slotIndex)
+ ? slotIndex
+ : parseInt(String(slotIndex), 10)
+ if (!Number.isFinite(ix)) return
+
+ const mode = normalizeAdvanceMode(modeRaw)
+ let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
+ const found = arr.findIndex((r) => r && typeof r === 'object' && Number(r.slot_index) === ix)
+
+ const nextRow = {}
+ if (found >= 0 && arr[found] && typeof arr[found] === 'object') {
+ Object.assign(nextRow, arr[found])
+ }
+ nextRow.slot_index = ix
+ if (mode === 'timed') delete nextRow.advance_mode
+ else {
+ nextRow.advance_mode = mode
+ delete nextRow.load_sec
+ }
+
+ let nextArr
+ if (!slotProfileRowShouldKeep(nextRow)) {
+ nextArr = found >= 0 ? arr.filter((_, i) => i !== found) : arr
+ } else if (found >= 0) {
+ nextArr = [...arr]
+ nextArr[found] = nextRow
+ } else {
+ nextArr = [...arr, nextRow]
+ }
+
+ writeSlotProfilesV1Arr(profileDraft, nextArr)
+}
+
/** '', null = Feld entfernen; sonst gültige Zahl setzen */
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
if (!SLOT_TIMING_FIELDS.includes(field)) return
@@ -270,22 +332,19 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
else nextRow[field] = n
}
- const hasTiming = SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
+ const keep = slotProfileRowShouldKeep(nextRow)
if (found >= 0) {
- if (!hasTiming) {
+ if (!keep) {
arr = arr.filter((_, i) => i !== found)
} else {
arr[found] = nextRow
}
- } else if (hasTiming) {
+ } else if (keep) {
arr.push(nextRow)
}
- arr.sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
-
- if (arr.length === 0) delete profileDraft.slot_profiles_v1
- else profileDraft.slot_profiles_v1 = arr
+ writeSlotProfilesV1Arr(profileDraft, arr)
}
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
--
2.43.0
From cf9932990ed37546731ad241d91b256aeb6ce0f2 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 08:58:41 +0200
Subject: [PATCH 18/27] feat(version): bump to 0.8.109 and enhance combination
exercise features
- Updated app version to 0.8.109, reflecting recent improvements in combination exercise handling.
- Introduced `rep_series_count` for slot profiles, allowing for multiple series in `rep` and `manual` modes, enhancing flexibility in exercise configurations.
- Updated the CombinationMethodProfileEditor and CombinationCoachSlots components to support and display the new series count feature.
- Enhanced ExerciseFormPage to manage series count and intra-series pauses effectively, improving user experience.
- Documented changes in the changelog for better tracking of feature enhancements.
Co-Authored-By: Claude Sonnet 4.6
---
...e Kombinationsuebungen Spezifikation V2.md | 2 +-
.../COMBINATION_TIMING_PROFILE_PLAN.md | 5 +-
backend/version.py | 25 +++-
.../src/components/CombinationCoachSlots.jsx | 28 ++++-
.../CombinationMethodProfileEditor.jsx | 82 ++++++++++---
.../src/constants/combinationArchetypes.js | 14 +++
frontend/src/pages/ExerciseFormPage.jsx | 113 ++++++++++++++----
frontend/src/utils/api.js | 17 ++-
.../src/utils/combinationMethodProfileUi.js | 13 +-
9 files changed, 251 insertions(+), 48 deletions(-)
diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
index b42be9b..55d3643 100644
--- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
+++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
@@ -423,7 +423,7 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz**
**Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z. B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z. B. Zirkel: Relation Belastungszeit = Übergangszeit oder Erholungsanteil ≈ 2/3 der Belastung); der Archetyp **Freier Methodenblock** bildet den **Maximal‑Pfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden.
-**Fortschritt pro Slot (Stand 0.8.106):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). **`intra_rep_rest_sec`** und **`transition_after_sec`** bleiben unabhängig vom Modus nutzbar. **`load_sec`** wird nur im Modus `timed` persistiert.
+**Fortschritt pro Slot (Stand 0.8.109):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). Optional **`rep_series_count`**: Standard **1** (wird im Formular/API explizit geführt); Ausnahmen nur, wenn der **Methoden‑Archetyp** in `ARCHETYPE_DEFAULT_REP_SERIES_COUNT` eine andere Vorgabe definiert oder der Nutzer eine andere Zahl setzt. ≥ 2 ermöglicht Pause **zwischen Serien** (`intra_rep_rest_sec`). Bei nur **einer** Serie: kein **`intra_rep_rest_sec`** in UI und Payload; **`transition_after_sec`** = Wechsel zur nächsten Station.
### 6.4 Slot- und Pool-Logik
diff --git a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
index dae1862..6a31017 100644
--- a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
+++ b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
@@ -53,8 +53,9 @@ Objekt‑Shape (Sekunden, ganze Zahlen ≥ 0):
| Feld | Bedeutung |
|------|------------|
| `load_sec` | Belastungsdauer „an der Station“. |
-| `consecutive_reps` | Übungen hintereinander ohne Wechsel zu **neuem** Stationsinhalt („oft 1“). |
-| `intra_rep_rest_sec` | Pause zwischen diesen Folge‑Wiederholungen. |
+| `consecutive_reps` | Wiederholungen pro „Serie“ bzw. ohne Wechsel zu **neuem** Stationsinhalt (oft 1). |
+| `rep_series_count` | Anzahl Serien à `consecutive_reps` bei rep/manual; Standard **1**, Archetyp‑Vorgabe möglich (**`ARCHETYPE_DEFAULT_REP_SERIES_COUNT`**). Persistiert für rep/manual ab 1. |
+| `intra_rep_rest_sec` | Pause zwischen den Folge‑Wiederholungen bzw. **zwischen Serien** (nur sinnvoll, wenn `rep_series_count` ≥ 2 im Modus `rep`/`manual`; sonst Wechselzeit `transition_after_sec` nutzen). |
| `transition_after_sec` | Pause / Wechsel **zur nächsten** Station oder zum nächsten logischen Block. |
**Hinweis:** Bestehende Archetyp‑„flachen“ Schlüssel (`work_seconds`, `transition_seconds`, …) werden schrittweise **nicht zerstört**, sondern Slots ergänzen; Konvergenz (eine Darstellung zu v1) kann Phase 4 sein.
diff --git a/backend/version.py b/backend/version.py
index 02c0f10..98c30eb 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.106"
+APP_VERSION = "0.8.109"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.26.0", # Kombi: advance_mode je Station (slot_profiles_v1: timed|rep|manual); Payload/Coach-Lesetext
+ "exercises": "2.27.2", # Kombi: Serien‑Standard 1 + Archetyp‑Map ARCHETYPE_DEFAULT_REP_SERIES_COUNT; Payload rep_series_count ab 1
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
@@ -35,6 +35,27 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.109",
+ "date": "2026-05-12",
+ "changes": [
+ "Kombination: „Serien“ standardmäßig 1 (Formular/API); Archetyp kann via `ARCHETYPE_DEFAULT_REP_SERIES_COUNT` andere Vorgaben setzen; Profil‑Editor zeigt Fallback.",
+ ],
+ },
+ {
+ "version": "0.8.108",
+ "date": "2026-05-12",
+ "changes": [
+ "Kombination rep/manual: Feld „Pause zw. Serien“ nur ab 2 Serien sichtbar und speicherbar; Hinweis unterscheidet Wechsel zur nächsten Station; API verwirft intra_rep_rest_sec bei nur einer Serie.",
+ ],
+ },
+ {
+ "version": "0.8.107",
+ "date": "2026-05-12",
+ "changes": [
+ "Kombination Wiederholungsziel: `rep_series_count` in `slot_profiles_v1` (mehrere Serien à Ziel‑Wdh.); Formular‑ und Profil‑Editor‑Felder; Pause als „zwischen Serien“ beschriftet; Coach‑Zusammenfassung angepasst.",
+ ],
+ },
{
"version": "0.8.106",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index 1dfee64..c8ca19d 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -21,17 +21,39 @@ function summarizeSlotProfilesRow(r) {
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
} else if (adv === 'rep') {
bits.push('Ziel‑Wdh.')
- if (r.consecutive_reps != null) bits.push(`${r.consecutive_reps}×`)
+ const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
+ if (r.consecutive_reps != null) {
+ if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
+ else bits.push(`${r.consecutive_reps}×`)
+ }
} else {
bits.push('Coach')
- if (r.consecutive_reps != null) bits.push(`Richtwert ${r.consecutive_reps}×`)
+ const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
+ if (r.consecutive_reps != null) {
+ if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
+ else bits.push(`Richtwert ${r.consecutive_reps}×`)
+ } else if (r.rep_series_count != null && r.rep_series_count >= 2) {
+ bits.push(`${r.rep_series_count} Serien`)
+ }
+ }
+ if (r.intra_rep_rest_sec != null) {
+ if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
+ else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
+ bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
+ else if (
+ adv === 'manual' &&
+ r.rep_series_count != null &&
+ r.rep_series_count >= 2
+ ) {
+ bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
+ }
}
- if (r.intra_rep_rest_sec != null) bits.push(`Pause ${r.intra_rep_rest_sec}s`)
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
return bits.join(' · ')
}
export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
+ const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index bdb0243..a8fae3f 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'
-import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
+import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
@@ -10,6 +10,7 @@ import {
patchSlotTimingField,
patchSlotAdvanceMode,
normalizeAdvanceMode,
+ parseComboRepSeriesCountUi,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
@@ -116,6 +117,20 @@ export default function CombinationMethodProfileEditor({
setPresetHint(null)
}
+ const onSlotRepSeriesCount = (slotIx, rawStr) => {
+ const trimmed = String(rawStr ?? '').trim()
+ const effective = trimmed === '' ? '1' : trimmed
+ const pn = parseInt(effective, 10)
+ const clearIntra = !Number.isFinite(pn) || pn < 2
+ const patched = patchMethodProfile(methodProfileJson || '{}', (d) => {
+ patchSlotTimingField(d, slotIx, 'rep_series_count', effective)
+ if (clearIntra) patchSlotTimingField(d, slotIx, 'intra_rep_rest_sec', '')
+ })
+ if (!patched.ok) return
+ onChangeMethodProfileJson(patched.json)
+ setPresetHint(null)
+ }
+
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
@@ -293,7 +308,11 @@ export default function CombinationMethodProfileEditor({
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
- slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert'
+ slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
+ const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
+ const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
+ const showInterSeriesPause = showMultiSeries && serienUi >= 2
+ const intraLabel = slotAdv === 'timed' ? 'Pause zwischen Wdh.' : 'Pause zw. Serien'
return (
onSlotField(si, 'consecutive_reps', e.target.value)}
/>
+ {showMultiSeries ? (
+
+
+ Serien
+
+ onSlotRepSeriesCount(si, e.target.value)}
+ />
+
+ ) : null}
+ {slotAdv === 'timed' || showInterSeriesPause ? (
+
+
+ {intraLabel} (s)
+
+ onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
+ />
+
+ ) : null}
- Pause zwischen Wdh. (s)
-
- onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
- />
-
-
-
- Pause / Wechsel (s)
+ Wechsel (s)
+ {showMultiSeries && serienUi < 2 ? (
+
+ Wechsel (s) zur nächsten Station . „Pause zw. Serien“ nur ab 2
+ Serien.
+
+ ) : null}
)
})}
diff --git a/frontend/src/constants/combinationArchetypes.js b/frontend/src/constants/combinationArchetypes.js
index 928c39d..93a4eb2 100644
--- a/frontend/src/constants/combinationArchetypes.js
+++ b/frontend/src/constants/combinationArchetypes.js
@@ -59,3 +59,17 @@ export function sortCombinationSlotsForDisplay(slotsRaw) {
return String(a.title || '').localeCompare(String(b.title || ''), 'de')
})
}
+
+/**
+ * Vorgabe „Serien“ pro Station bei Steuerung rep/manual, wenn kein Wert in `slot_profiles_v1` steht.
+ * Nur Archetypen eintragen, die fachlich ≠ 1 verlangen; sonst Standard 1.
+ */
+export const ARCHETYPE_DEFAULT_REP_SERIES_COUNT = Object.freeze({})
+
+export function defaultRepSeriesCountForArchetype(archetypeId) {
+ const key = archetypeId != null ? String(archetypeId).trim() : ''
+ const raw = key ? ARCHETYPE_DEFAULT_REP_SERIES_COUNT[key] : undefined
+ const n = typeof raw === 'number' ? raw : raw != null ? parseInt(String(raw), 10) : NaN
+ if (!Number.isFinite(n) || n < 1) return 1
+ return Math.round(n)
+}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index 9fdc60c..5e29bbb 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -16,8 +16,8 @@ import {
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
-import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
-import { readSlotProfilesV1, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi'
+import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
+import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
@@ -57,6 +57,7 @@ function emptyComboSlotRow() {
advance_mode: 'timed',
load_sec: '',
consecutive_reps: '',
+ rep_series_count: '1',
intra_rep_rest_sec: '',
transition_after_sec: '',
}
@@ -64,6 +65,8 @@ function emptyComboSlotRow() {
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
+ const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : ''
+ const serienFallback = defaultRepSeriesCountForArchetype(arch)
const mp =
exercise?.method_profile &&
typeof exercise.method_profile === 'object' &&
@@ -83,13 +86,19 @@ function comboSlotsFromDetail(exercise) {
const cands = Array.isArray(s.candidate_exercise_ids)
? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
+ const mode = normalizeAdvanceMode(st.advance_mode)
+ let repSer = ''
+ if (st.rep_series_count != null) repSer = String(st.rep_series_count)
+ else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback)
+ else repSer = '1'
return {
title: s.title != null ? String(s.title) : '',
candidate_exercise_ids: cands,
exercise_title_by_id: {},
- advance_mode: normalizeAdvanceMode(st.advance_mode),
+ advance_mode: mode,
load_sec: st.load_sec != null ? String(st.load_sec) : '',
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
+ rep_series_count: repSer,
intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
}
@@ -1152,7 +1161,26 @@ function ExerciseFormPage() {
updateFormField('method_archetype', e.target.value)}
+ onChange={(e) => {
+ const arch = (e.target.value || '').trim()
+ const forced = ARCHETYPE_DEFAULT_REP_SERIES_COUNT[arch]
+ setFormDirty(true)
+ setFormData((prev) => {
+ const slots = prev.combination_slots || []
+ const nextSlots =
+ forced !== undefined && forced !== null
+ ? slots.map((row) =>
+ normalizeAdvanceMode(row.advance_mode) !== 'timed'
+ ? {
+ ...row,
+ rep_series_count: String(Math.max(1, Math.round(Number(forced)))),
+ }
+ : row,
+ )
+ : slots
+ return { ...prev, method_archetype: arch, combination_slots: nextSlots }
+ })
+ }}
>
— noch nicht festgelegt —
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
@@ -1197,8 +1225,12 @@ function ExerciseFormPage() {
const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
- slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert'
+ slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '–' : '1'
+ const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
+ const serienCountUi = parseComboRepSeriesCountUi(row.rep_series_count)
+ const showInterSeriesPause = showMultiSeries && serienCountUi >= 2
+ const intraLabel = slotAdv === 'timed' ? 'Pause (s)' : 'Pause zw. Serien'
const lbl =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
? row.exercise_title_by_id
@@ -1361,6 +1393,14 @@ function ExerciseFormPage() {
const m = normalizeAdvanceMode(e.target.value)
const patch = { advance_mode: m }
if (m !== 'timed') patch.load_sec = ''
+ if (m === 'rep' || m === 'manual') {
+ const curSer = String(row.rep_series_count ?? '').trim()
+ if (!curSer) {
+ patch.rep_series_count = String(
+ defaultRepSeriesCountForArchetype(formData.method_archetype || ''),
+ )
+ }
+ }
patchComboSlotRow(idx, patch)
}}
>
@@ -1373,8 +1413,8 @@ function ExerciseFormPage() {
{slotAdv === 'timed'
? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
: slotAdv === 'rep'
- ? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen; Pause/Wechsel können weiter automatisch unterstützt werden.'
- : 'Ohne feste Arbeitsuhr auf dieser Station — Fortschritt im Coach später per Tippschritt; Pause/Wechsel optional weiter mit Sekunden.'}
+ ? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen. Ab zwei Serien: Pause zwischen diesen Serien; sonst nur Wechsel zur nächsten Station.'
+ : 'Coach: keine feste Arbeitsuhr — Fortschritt später per Tipp. Ab 2 Serien: Pause zwischen Serien; sonst nur Wechsel zur nächsten Station zeitlich planen.'}
patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
/>
-
-
- Pause (s)
-
- patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
- />
-
+ {showMultiSeries ? (
+
+
+ Serien
+
+ {
+ let rawSer = e.target.value.trim()
+ if (rawSer === '') rawSer = '1'
+ const pn = parseInt(String(rawSer).trim(), 10)
+ const patch = { rep_series_count: rawSer }
+ if (!Number.isFinite(pn) || pn < 2) patch.intra_rep_rest_sec = ''
+ patchComboSlotRow(idx, patch)
+ }}
+ />
+
+ ) : null}
+ {slotAdv === 'timed' || showInterSeriesPause ? (
+
+
+ {intraLabel}
+
+ patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
+ />
+
+ ) : null}
Wechsel (s)
@@ -1443,6 +1508,12 @@ function ExerciseFormPage() {
/>
+ {showMultiSeries && serienCountUi < 2 ? (
+
+ Wechsel (s) = Pause bis zur nächsten Station . Feld „Pause zw.
+ Serien“ erscheint erst ab 2 Serien (sonst keine Pause zwischen zwei Blöcken nötig).
+
+ ) : null}
)
})}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 00e887b..8cc9fee 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -5,7 +5,7 @@
*/
import { stripHtmlToText } from './htmlUtils'
-import { normalizeAdvanceMode } from './combinationMethodProfileUi'
+import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
const API_URL = import.meta.env.VITE_API_URL || ''
@@ -546,11 +546,24 @@ export function buildExerciseApiPayload(formData, extras = {}) {
if (advanceMode !== 'timed') o.advance_mode = advanceMode
const load = parseTimingField(row.load_sec)
const crs = parseTimingField(row.consecutive_reps)
+ const rsc = parseTimingField(row.rep_series_count)
const intra = parseTimingField(row.intra_rep_rest_sec)
const tran = parseTimingField(row.transition_after_sec)
+ const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
+ const allowInterSeriesPause =
+ advanceMode === 'timed' ||
+ ((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
+
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
- if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
+ if (
+ rsc !== undefined &&
+ rsc >= 1 &&
+ (advanceMode === 'rep' || advanceMode === 'manual')
+ ) {
+ o.rep_series_count = Math.round(rsc)
+ }
+ if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
}
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index a52122c..719d435 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -15,6 +15,15 @@ export function normalizeAdvanceMode(v) {
return 'timed'
}
+/** UI: Serien-Anzahl aus Formularfeld; leer/ungültig ⇒ 1 (eine Serie). */
+export function parseComboRepSeriesCountUi(raw) {
+ if (raw === '' || raw === undefined || raw === null) return 1
+ const n =
+ typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : parseInt(String(raw).trim(), 10)
+ if (!Number.isFinite(n) || n < 1) return 1
+ return n
+}
+
function parseProfileJson(raw) {
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
try {
@@ -225,6 +234,7 @@ export function readSlotProfilesV1(profileObj) {
advance_mode: mode,
load_sec: normalizeOptionalNonNegInt(row.load_sec),
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
+ rep_series_count: normalizeOptionalPositiveInt(row.rep_series_count),
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
}
@@ -249,6 +259,7 @@ function normalizeOptionalPositiveInt(v) {
const SLOT_TIMING_FIELDS = /** @type {const} */ ([
'load_sec',
'consecutive_reps',
+ 'rep_series_count',
'intra_rep_rest_sec',
'transition_after_sec',
])
@@ -322,7 +333,7 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
if (rawInput === null || rawInput === undefined || String(rawInput).trim() === '') {
delete nextRow[field]
- } else if (field === 'consecutive_reps') {
+ } else if (field === 'consecutive_reps' || field === 'rep_series_count') {
const n = normalizeOptionalPositiveInt(rawInput)
if (n === undefined) delete nextRow[field]
else nextRow[field] = n
--
2.43.0
From 13efce6e3614a6023caef8130c393668731231aa Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 09:06:10 +0200
Subject: [PATCH 19/27] feat(training-unit-editor): enhance combo planning
features and UI updates
- Added a new function `compactComboPlanningCaption` to display planning status with archetype labels.
- Introduced state management for a modal to edit combination planning profiles, improving user interaction.
- Updated UI components to enhance the display and interaction of combination exercises, including styling adjustments for better usability.
- Implemented keyboard event handling for modal closure, enhancing user experience.
Co-Authored-By: Claude Sonnet 4.6
---
.../components/TrainingUnitSectionsEditor.jsx | 235 +++++++++++++-----
1 file changed, 178 insertions(+), 57 deletions(-)
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index c824305..201fc13 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -2,6 +2,7 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import { comboPlanningProfileJsonForEditor } from '../utils/comboPlanningMethodProfile'
+import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
import {
defaultSection,
exerciseRow,
@@ -59,6 +60,20 @@ function gatherPlanningModuleOutline(items, startIdx, moduleId) {
const MODULE_OUTLINE_PREVIEW_MAX = 8
+/** Statuszeile: Planungs‑Override vs. Katalog, inkl. Archetyp‑Label wenn bekannt. */
+function compactComboPlanningCaption(it) {
+ const overridden =
+ it.planning_method_profile != null &&
+ typeof it.planning_method_profile === 'object' &&
+ !Array.isArray(it.planning_method_profile)
+ const archRaw = String(it.catalog_method_archetype || '').trim()
+ const archLbl = archRaw ? combinationArchetypeLabel(archRaw) : null
+ if (overridden) {
+ return archLbl ? `${archLbl} · Planung angepasst` : 'Planung angepasst'
+ }
+ return archLbl ? `${archLbl} · wie Katalog` : 'wie im Katalog'
+}
+
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@@ -290,6 +305,8 @@ export default function TrainingUnitSectionsEditor({
}
const [textEdit, setTextEdit] = useState(null)
+ /** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
+ const [comboPlanningModal, setComboPlanningModal] = useState(null)
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
const [insertChooser, setInsertChooser] = useState(null)
const [draggingPos, setDraggingPos] = useState(null)
@@ -316,6 +333,27 @@ export default function TrainingUnitSectionsEditor({
return () => window.removeEventListener('keydown', onKey)
}, [insertChooser])
+ useEffect(() => {
+ if (!comboPlanningModal) return
+ const onKey = (e) => {
+ if (e.key === 'Escape') setComboPlanningModal(null)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [comboPlanningModal])
+
+ useEffect(() => {
+ if (!comboPlanningModal) return
+ const L = ensure(sections)
+ const { sIdx, iIdx } = comboPlanningModal
+ const row = L[sIdx]?.items?.[iIdx]
+ const ok =
+ row &&
+ String(row.exercise_kind || '').toLowerCase().trim() === 'combination' &&
+ row.exercise_id
+ if (!ok) setComboPlanningModal(null)
+ }, [sections, comboPlanningModal])
+
const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
const insertSlotKeyPrefix =
@@ -533,6 +571,23 @@ export default function TrainingUnitSectionsEditor({
const list = ensure(sections)
+ let comboPlanningModalItem = null
+ let comboPlanningModalSX = null
+ let comboPlanningModalIX = null
+ if (comboPlanningModal) {
+ const { sIdx, iIdx } = comboPlanningModal
+ const cand = list[sIdx]?.items?.[iIdx]
+ if (
+ cand &&
+ String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
+ cand.exercise_id
+ ) {
+ comboPlanningModalItem = cand
+ comboPlanningModalSX = sIdx
+ comboPlanningModalIX = iIdx
+ }
+ }
+
return (
-
-
- Ablaufprofil für diese Planung (Kombination)
-
- {it.planning_method_profile != null &&
- typeof it.planning_method_profile === 'object' &&
- !Array.isArray(it.planning_method_profile)
- ? '— Anpassung aktiv'
- : '— wie im Katalog'}
-
-
-
-
- updateItem(sIdx, iIdx, 'planning_method_profile', null)}
- >
- Planung wie Katalog
-
-
- updateItem(sIdx, iIdx, 'planning_method_profile', {
- ...(it.catalog_method_profile || {}),
- })
- }
- >
- Aus Katalog kopieren …
-
-
-
{
- try {
- const obj = JSON.parse(json || '{}')
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
- updateItem(sIdx, iIdx, 'planning_method_profile', obj)
- }
- } catch {
- /* Ungültiges JSON — Hinweis im Editor */
- }
- }}
- />
-
-
+
+ Ablauf:
+ {compactComboPlanningCaption(it)}
+
+
setComboPlanningModal({ sIdx, iIdx })}
+ >
+ Ablauf bearbeiten…
+
) : null}
@@ -1322,6 +1348,101 @@ export default function TrainingUnitSectionsEditor({
) : null}
+ {comboPlanningModalItem != null &&
+ comboPlanningModalSX != null &&
+ comboPlanningModalIX != null ? (
+
{
+ if (e.target === e.currentTarget) setComboPlanningModal(null)
+ }}
+ >
+
e.stopPropagation()}
+ style={{
+ maxWidth: 'min(920px, 96vw)',
+ maxHeight: 'min(800px, 88vh)',
+ overflow: 'auto',
+ }}
+ >
+
+ Ablaufprofil dieser Kombination für diese Planung
+
+
+
+ {(comboPlanningModalItem.exercise_title || '').trim() ||
+ `Kombination #${comboPlanningModalItem.exercise_id}`}
+
+
+ ({compactComboPlanningCaption(comboPlanningModalItem)})
+
+
+
+
+ updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
+ }
+ >
+ Planung wie Katalog
+
+
+ updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
+ ...(comboPlanningModalItem.catalog_method_profile || {}),
+ })
+ }
+ >
+ Aus Katalog kopieren …
+
+
+
{
+ try {
+ const obj = JSON.parse(json || '{}')
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
+ updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', obj)
+ }
+ } catch {
+ /* Ungültiges JSON — Hinweis im Editor */
+ }
+ }}
+ />
+
+ setComboPlanningModal(null)}
+ >
+ Schließen
+
+
+
+
+ ) : null}
+
{textEdit ? (
Date: Wed, 13 May 2026 09:58:59 +0200
Subject: [PATCH 20/27] feat(training-unit-editor): enhance combination slots
handling and UI improvements
- Added support for `compactPlanningView` and `omitGlobalKeyValueBlock` in the CombinationCoachSlots component to improve display options.
- Updated the TrainingUnitSectionsEditor to fetch and manage combination slots more effectively, including new state management for modal interactions.
- Introduced a new utility function `comboSlotsOutlineForProfileEditor` to streamline the display of combination slots in the editor.
- Enhanced UI elements for better user experience when managing combination exercises and their associated slots.
Co-Authored-By: Claude Sonnet 4.6
---
.../src/components/CombinationCoachSlots.jsx | 25 +++-
.../components/TrainingUnitSectionsEditor.jsx | 118 ++++++++++++++++--
.../src/utils/trainingUnitSectionsForm.js | 25 +++-
3 files changed, 154 insertions(+), 14 deletions(-)
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index c8ca19d..99d5260 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -52,7 +52,13 @@ function summarizeSlotProfilesRow(r) {
return bits.join(' · ')
}
-export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
+export default function CombinationCoachSlots({
+ combinationSlots,
+ methodArchetype,
+ methodProfile,
+ compactPlanningView = false,
+ omitGlobalKeyValueBlock = false,
+}) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
@@ -153,7 +159,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
letterSpacing: '0.04em',
}}
>
- Kombination · Stationen & Einzelübungen
+ {compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
{archDisplay ? (
@@ -165,11 +171,13 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
) : null}
) : null}
+ {compactPlanningView ? null : (
{archetypeCoachHint(archeKey)}
+ )}
- {methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length ? (
+ {methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length && !omitGlobalKeyValueBlock ? (
) : ex ? (
+ compactPlanningView ? (
+ <>
+
{ex.title}
+
+
+ Im Katalog öffnen
+
+
+ >
+ ) : (
<>
{ex.title}
{ex.summary ? (
@@ -312,6 +330,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
>
+ )
) : (
{candTitleFallback || `Übung #${cid}`}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 201fc13..4c5f759 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1,14 +1,17 @@
-import React, { Fragment, useCallback, useEffect, useState } from 'react'
+import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
-import { comboPlanningProfileJsonForEditor } from '../utils/comboPlanningMethodProfile'
+import CombinationCoachSlots from './CombinationCoachSlots'
+import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
import {
+ comboSlotsOutlineForProfileEditor,
defaultSection,
exerciseRow,
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
+import api from '../utils/api'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
@@ -307,6 +310,8 @@ export default function TrainingUnitSectionsEditor({
const [textEdit, setTextEdit] = useState(null)
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
const [comboPlanningModal, setComboPlanningModal] = useState(null)
+ /** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */
+ const [modalComboSlotsFetched, setModalComboSlotsFetched] = useState(null)
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
const [insertChooser, setInsertChooser] = useState(null)
const [draggingPos, setDraggingPos] = useState(null)
@@ -354,6 +359,38 @@ export default function TrainingUnitSectionsEditor({
if (!ok) setComboPlanningModal(null)
}, [sections, comboPlanningModal])
+ useEffect(() => {
+ if (!comboPlanningModal) {
+ setModalComboSlotsFetched(null)
+ return
+ }
+ const L = ensure(sections)
+ const { sIdx, iIdx } = comboPlanningModal
+ const cand = L[sIdx]?.items?.[iIdx]
+ if (
+ !cand ||
+ String(cand.exercise_kind || '').toLowerCase().trim() !== 'combination' ||
+ !cand.exercise_id
+ ) {
+ setModalComboSlotsFetched(null)
+ return
+ }
+ const cached = cand.combination_slots
+ if (Array.isArray(cached) && cached.length > 0) {
+ setModalComboSlotsFetched(cached)
+ return
+ }
+ let cancelled = false
+ setModalComboSlotsFetched([])
+ api.getExercise(cand.exercise_id).then((ex) => {
+ if (cancelled) return
+ setModalComboSlotsFetched(Array.isArray(ex?.combination_slots) ? ex.combination_slots : [])
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [sections, comboPlanningModal])
+
const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
const insertSlotKeyPrefix =
@@ -571,10 +608,10 @@ export default function TrainingUnitSectionsEditor({
const list = ensure(sections)
- let comboPlanningModalItem = null
- let comboPlanningModalSX = null
- let comboPlanningModalIX = null
- if (comboPlanningModal) {
+ const comboPlanningModalDerived = useMemo(() => {
+ if (!comboPlanningModal) {
+ return { item: null, sIdx: null, iIdx: null }
+ }
const { sIdx, iIdx } = comboPlanningModal
const cand = list[sIdx]?.items?.[iIdx]
if (
@@ -582,11 +619,34 @@ export default function TrainingUnitSectionsEditor({
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
cand.exercise_id
) {
- comboPlanningModalItem = cand
- comboPlanningModalSX = sIdx
- comboPlanningModalIX = iIdx
+ return { item: cand, sIdx, iIdx }
}
- }
+ return { item: null, sIdx: null, iIdx: null }
+ }, [list, comboPlanningModal])
+
+ const comboPlanningModalItem = comboPlanningModalDerived.item
+ const comboPlanningModalSX = comboPlanningModalDerived.sIdx
+ const comboPlanningModalIX = comboPlanningModalDerived.iIdx
+
+ const comboPlanningResolvedSlots = useMemo(() => {
+ if (!comboPlanningModalItem) return []
+ const c = comboPlanningModalItem.combination_slots
+ if (Array.isArray(c) && c.length > 0) return c
+ return Array.isArray(modalComboSlotsFetched) ? modalComboSlotsFetched : []
+ }, [comboPlanningModalItem, modalComboSlotsFetched])
+
+ const comboPlanningSlotsOutline = useMemo(
+ () => comboSlotsOutlineForProfileEditor(comboPlanningResolvedSlots),
+ [comboPlanningResolvedSlots]
+ )
+
+ const comboPlanningEffectiveProfile = useMemo(() => {
+ if (!comboPlanningModalItem) return {}
+ return effectiveComboMethodProfile(
+ comboPlanningModalItem.catalog_method_profile || {},
+ comboPlanningModalItem.planning_method_profile
+ )
+ }, [comboPlanningModalItem])
return (
+
+ Stationen und Einzelübungen entsprechen der Kombination im Katalog. Einzelübungen hier auszutauschen ist
+ derzeit nicht vorgesehen (würde die Katalog-Übung ändern). Die Bereiche unten überschreiben nur diesen
+ Termin, sofern du von den Katalogvorgaben abweichst.
+
+ {comboPlanningResolvedSlots.length > 0 ? (
+
+
+
+ ) : (
+
+ Stationen werden geladen … oder die Kombination hat im Katalog keine Stationsliste.
+
+ )}
+
+ Zeiten und Steuerung für diesen Termin
+
{
try {
const ex = await api.getExercise(id)
+ const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
cache.set(id, {
title: ex.title || '',
- exercise_kind: String(ex.exercise_kind || 'simple').toLowerCase().trim(),
+ exercise_kind: ek,
variants: Array.isArray(ex.variants) ? ex.variants : [],
visibility: ex.visibility || 'private',
club_id: ex.club_id ?? null,
@@ -255,6 +258,7 @@ export async function enrichSectionsWithVariants(sections) {
status: ex.status || 'draft',
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
+ combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
})
} catch {
cache.set(id, {
@@ -267,6 +271,7 @@ export async function enrichSectionsWithVariants(sections) {
status: 'draft',
method_archetype: '',
method_profile: {},
+ combination_slots: [],
})
}
})
@@ -301,11 +306,29 @@ export async function enrichSectionsWithVariants(sections) {
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
+ ...(isCombo ? { combination_slots: c.combination_slots || [] } : {}),
}
}),
}))
}
+/**
+ * Outline für CombinationMethodProfileEditor: pro‑Slot‑Zeiten nur sichtbar, wenn Stationen übergeben werden.
+ */
+export function comboSlotsOutlineForProfileEditor(combinationSlots) {
+ if (!Array.isArray(combinationSlots) || combinationSlots.length === 0) return null
+ const sorted = sortCombinationSlotsForDisplay(combinationSlots)
+ return sorted.map((s, i) => {
+ const rawIx = s.slot_index
+ const si =
+ rawIx === '' || rawIx == null ? null : typeof rawIx === 'number' ? rawIx : parseInt(String(rawIx), 10)
+ return {
+ slot_index: Number.isFinite(si) ? si : i,
+ title: (s.title != null ? String(s.title) : '').trim(),
+ }
+ })
+}
+
export function parseMin(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)
--
2.43.0
From 5dc93d9a8ce1aecfc4238d5c6a62c4ef23aaadcf Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 14:11:53 +0200
Subject: [PATCH 21/27] feat(training-unit-editor): integrate new summary
function and enhance combination exercise display
- Introduced `summarizeSlotProfileBrief` utility for concise slot profile summaries, improving the display of combination exercises.
- Updated `CombinationCoachSlots` and `ExercisePeekModal` components to utilize the new summary function for better user experience.
- Enhanced `TrainingUnitSectionsEditor` to manage combination slots more effectively, including improved title handling and display options.
- Adjusted `TrainingPlanningPage` to support additional peek context for combination exercises, streamlining the planning process.
Co-Authored-By: Claude Sonnet 4.6
---
.../src/components/CombinationCoachSlots.jsx | 44 +-----
frontend/src/components/ExercisePeekModal.jsx | 70 +++++++-
.../components/TrainingUnitSectionsEditor.jsx | 149 ++++++++++++++++--
frontend/src/pages/TrainingPlanningPage.jsx | 9 +-
.../src/utils/combinationMethodProfileUi.js | 41 +++++
.../src/utils/trainingUnitSectionsForm.js | 44 +++++-
6 files changed, 298 insertions(+), 59 deletions(-)
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index 99d5260..fbcf995 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -10,47 +10,7 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
-import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
-
-function summarizeSlotProfilesRow(r) {
- if (!r) return null
- const adv = r.advance_mode || 'timed'
- const bits = []
- if (adv === 'timed') {
- bits.push('Zeit')
- if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
- } else if (adv === 'rep') {
- bits.push('Ziel‑Wdh.')
- const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
- if (r.consecutive_reps != null) {
- if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
- else bits.push(`${r.consecutive_reps}×`)
- }
- } else {
- bits.push('Coach')
- const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
- if (r.consecutive_reps != null) {
- if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
- else bits.push(`Richtwert ${r.consecutive_reps}×`)
- } else if (r.rep_series_count != null && r.rep_series_count >= 2) {
- bits.push(`${r.rep_series_count} Serien`)
- }
- }
- if (r.intra_rep_rest_sec != null) {
- if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
- else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
- bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
- else if (
- adv === 'manual' &&
- r.rep_series_count != null &&
- r.rep_series_count >= 2
- ) {
- bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
- }
- }
- if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
- return bits.join(' · ')
-}
+import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
export default function CombinationCoachSlots({
combinationSlots,
@@ -233,7 +193,7 @@ export default function CombinationCoachSlots({
`Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
- const timingSummary = summarizeSlotProfilesRow(slotTimingByIx.get(ix))
+ const timingSummary = summarizeSlotProfileBrief(slotTimingByIx.get(ix))
return (
diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx
index 208cac4..b87613b 100644
--- a/frontend/src/components/ExercisePeekModal.jsx
+++ b/frontend/src/components/ExercisePeekModal.jsx
@@ -1,10 +1,13 @@
/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
*/
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
+import CombinationCoachSlots from './CombinationCoachSlots'
+import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
+import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function TagMini({ exercise }) {
const parts = []
@@ -29,6 +32,8 @@ export default function ExercisePeekModal({
variantId,
onClose,
titleFallback,
+ /** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */
+ peekExtras,
}) {
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
@@ -39,6 +44,22 @@ export default function ExercisePeekModal({
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
+ const isCombination =
+ exercise &&
+ String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
+
+ const comboMethodProfileEffective = useMemo(() => {
+ if (!exercise || !isCombination) return {}
+ const fromPeek =
+ peekExtras?.catalog_method_profile &&
+ typeof peekExtras.catalog_method_profile === 'object' &&
+ !Array.isArray(peekExtras.catalog_method_profile) &&
+ Object.keys(peekExtras.catalog_method_profile).length > 0
+ ? peekExtras.catalog_method_profile
+ : exercise.method_profile || {}
+ return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
+ }, [exercise, isCombination, peekExtras])
+
useEffect(() => {
if (!open) {
setExercise(null)
@@ -69,6 +90,8 @@ export default function ExercisePeekModal({
if (!open) return null
+ const sheetWide = Boolean(isCombination && exercise && !loading)
+
return (
e.target === e.currentTarget && onClose()}>
{err}}
{!loading && exercise && (
<>
+ {isCombination ? (
+ <>
+
+ Kombination
+
+ {(() => {
+ const ak = String(exercise.method_archetype || '').trim()
+ const lbl = ak ? combinationArchetypeLabel(ak) : null
+ return lbl || ak || 'Archetyp nicht gesetzt'
+ })()}
+
+ {peekExtras?.planning_method_profile != null &&
+ typeof peekExtras.planning_method_profile === 'object' &&
+ !Array.isArray(peekExtras.planning_method_profile) ? (
+
+ · Planung angepasst
+
+ ) : null}
+
+
+
+ >
+ ) : null}
+
{variant ? (
[Number(r.slot_index), r]))
+ const titles = it.combo_member_title_by_id || {}
+ return slots.map((slot, idx) => {
+ const siRaw = slot.slot_index
+ const siParsed =
+ siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
+ const ix = Number.isFinite(siParsed) ? siParsed : idx
+ const stationLbl = ((slot.title || '').trim() || `Station ${ix}`)
+ const candIds = (slot.candidate_exercise_ids || [])
+ .map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
+ .filter((n) => Number.isFinite(n))
+ const namesJoined =
+ candIds.length === 0
+ ? '(keine Übung)'
+ : candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
+ const timing = summarizeSlotProfileBrief(byIx.get(ix))
+ let line = `${stationLbl}: ${namesJoined}`
+ if (timing) line += ` · ${timing}`
+ return line
+ })
+}
+
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@@ -964,6 +1018,24 @@ export default function TrainingUnitSectionsEditor({
? undefined
: Number(it.exercise_variant_id)
+ const stripArchRaw =
+ isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
+ const stripArchLbl =
+ stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null
+ const stripBullets =
+ isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : []
+ const stripMpEff =
+ isCombination && it.exercise_id
+ ? effectiveComboMethodProfile(
+ it.catalog_method_profile || {},
+ it.planning_method_profile,
+ )
+ : null
+ const stripGlobalRough =
+ isCombination && it.exercise_id && stripMpEff
+ ? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw)
+ : null
+
return (
{!planningCompactLegend &&
@@ -1047,7 +1119,16 @@ export default function TrainingUnitSectionsEditor({
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
- onPeekExercise(Number(it.exercise_id), variantIdPeek)
+ onPeekExercise(
+ Number(it.exercise_id),
+ variantIdPeek,
+ isCombination
+ ? {
+ catalog_method_profile: it.catalog_method_profile,
+ planning_method_profile: it.planning_method_profile,
+ }
+ : undefined,
+ )
}
>
Vorschau
@@ -1142,29 +1223,73 @@ export default function TrainingUnitSectionsEditor({
style={{
display: 'flex',
flexWrap: 'wrap',
- alignItems: 'center',
- gap: '8px 10px',
- padding: '6px 12px 8px',
+ alignItems: 'flex-start',
+ gap: '10px',
+ padding: '8px 12px 10px',
paddingLeft: enableItemDragReorder ? 44 : 12,
borderTop: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
-
- Ablauf:
- {compactComboPlanningCaption(it)}
-
+
+ Archetyp:
+
+ {stripArchLbl || stripArchRaw || '—'}
+ {stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
+
+ ({stripArchRaw})
+
+ ) : null}
+
+ {compactComboPlanningCaption(it)}
+
+ {stripGlobalRough ? (
+
+ Block:
+ {stripGlobalRough}
+
+ ) : null}
+ {stripBullets.length > 0 ? (
+
+ {stripBullets.map((line, bi) => (
+
+ {line}
+
+ ))}
+
+ ) : (
+
+ Stationen laden oder noch keine Kombi-Stationen im Katalog …
+
+ )}
+
setComboPlanningModal({ sIdx, iIdx })}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 67ff567..1ee21b8 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -2892,8 +2892,12 @@ function TrainingPlanningPage() {
})
setExercisePickerOpen(true)
}}
- onPeekExercise={(id, variantId) =>
- setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
+ onPeekExercise={(id, variantId, peekExtras) =>
+ setPlanningPeekCtx({
+ exerciseId: id,
+ variantId: variantId ?? null,
+ peekExtras: peekExtras ?? null,
+ })
}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
/>
@@ -3078,6 +3082,7 @@ function TrainingPlanningPage() {
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
+ peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index 719d435..986372e 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -242,6 +242,47 @@ export function readSlotProfilesV1(profileObj) {
}).filter(Boolean)
}
+/** Kurztext für Listen/strip (Coach „Plan:“ — gleiche Logik). */
+export function summarizeSlotProfileBrief(r) {
+ if (!r) return null
+ const adv = r.advance_mode || 'timed'
+ const bits = []
+ if (adv === 'timed') {
+ bits.push('Zeit')
+ if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
+ } else if (adv === 'rep') {
+ bits.push('Ziel‑Wdh.')
+ const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
+ if (r.consecutive_reps != null) {
+ if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
+ else bits.push(`${r.consecutive_reps}×`)
+ }
+ } else {
+ bits.push('Coach')
+ const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
+ if (r.consecutive_reps != null) {
+ if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
+ else bits.push(`Richtwert ${r.consecutive_reps}×`)
+ } else if (r.rep_series_count != null && r.rep_series_count >= 2) {
+ bits.push(`${r.rep_series_count} Serien`)
+ }
+ }
+ if (r.intra_rep_rest_sec != null) {
+ if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
+ else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
+ bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
+ else if (
+ adv === 'manual' &&
+ r.rep_series_count != null &&
+ r.rep_series_count >= 2
+ ) {
+ bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
+ }
+ }
+ if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
+ return bits.join(' · ')
+}
+
function normalizeOptionalNonNegInt(v) {
if (v === '' || v === undefined || v === null) return undefined
const n = typeof v === 'number' ? v : parseInt(String(v), 10)
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js
index ea48945..f0f2be9 100644
--- a/frontend/src/utils/trainingUnitSectionsForm.js
+++ b/frontend/src/utils/trainingUnitSectionsForm.js
@@ -276,6 +276,48 @@ export async function enrichSectionsWithVariants(sections) {
}
})
)
+
+ const titleById = new Map()
+ for (const id of unique) {
+ const row = cache.get(id)
+ const t = (row?.title || '').trim()
+ if (t) titleById.set(Number(id), t)
+ }
+ const comboCandidateExtra = new Set()
+ for (const id of unique) {
+ const row = cache.get(id)
+ if (String(row?.exercise_kind || '').toLowerCase().trim() !== 'combination') continue
+ for (const slot of row.combination_slots || []) {
+ for (const raw of slot.candidate_exercise_ids || []) {
+ const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
+ if (Number.isFinite(n) && !titleById.has(n)) comboCandidateExtra.add(n)
+ }
+ }
+ }
+ await Promise.all(
+ [...comboCandidateExtra].map(async (cid) => {
+ try {
+ const ex = await api.getExercise(cid)
+ titleById.set(cid, ((ex.title || '').trim() || `Übung #${cid}`))
+ } catch {
+ titleById.set(cid, `Übung #${cid}`)
+ }
+ }),
+ )
+
+ function comboMemberTitleByIdForSlots(slots) {
+ const o = {}
+ for (const slot of slots || []) {
+ for (const raw of slot.candidate_exercise_ids || []) {
+ const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
+ if (!Number.isFinite(n)) continue
+ const key = String(n)
+ if (!o[key]) o[key] = titleById.get(n) || `Übung #${n}`
+ }
+ }
+ return o
+ }
+
return sections.map((sec) => ({
...sec,
items: (sec.items || []).map((it) => {
@@ -306,7 +348,7 @@ export async function enrichSectionsWithVariants(sections) {
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
- ...(isCombo ? { combination_slots: c.combination_slots || [] } : {}),
+ ...(isCombo ? { combination_slots: c.combination_slots || [], combo_member_title_by_id: comboMemberTitleByIdForSlots(c.combination_slots || []) } : {}),
}
}),
}))
--
2.43.0
From a8942a9e4ec3271a31c66226bb1709de9203c63a Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 14:24:55 +0200
Subject: [PATCH 22/27] feat(version): bump to 0.8.110 and enhance combination
exercise features
- Updated app version to 0.8.110, reflecting recent improvements in combination exercise handling.
- Introduced `load_combination_slots_for_exercise` function to streamline fetching combination slots for exercises.
- Enhanced `TrainingPlanningPage` and `ExercisePeekModal` to utilize the new combination slots functionality, improving user experience.
- Updated changelog to document the latest changes and feature enhancements.
Co-Authored-By: Claude Sonnet 4.6
---
backend/routers/exercises.py | 73 +++----
backend/routers/training_planning.py | 10 +
backend/version.py | 14 +-
frontend/src/app.css | 190 ++++++++++++++++++
.../src/components/CombinationPlanBracket.jsx | 128 ++++++++++++
frontend/src/components/ExercisePeekModal.jsx | 46 +----
.../components/TrainingUnitSectionsEditor.jsx | 13 +-
frontend/src/pages/TrainingUnitRunPage.jsx | 40 +++-
.../src/utils/combinationMethodProfileUi.js | 39 ++++
frontend/src/version.js | 6 +-
10 files changed, 475 insertions(+), 84 deletions(-)
create mode 100644 frontend/src/components/CombinationPlanBracket.jsx
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 5509383..1cdae79 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -974,6 +974,43 @@ def assert_exercise_not_combination(cur, exercise_id: int) -> None:
)
+def load_combination_slots_for_exercise(cur, exercise_id: int) -> List[dict]:
+ """Stationsliste einer Kombinationsübung (gleiches Format wie GET /api/exercises/:id)."""
+ cur.execute(
+ """SELECT id, slot_index, title FROM combination_exercise_slots
+ WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""",
+ (exercise_id,),
+ )
+ slot_rows = [r2d(r) for r in cur.fetchall()]
+ slots_out: List[dict] = []
+ for sr in slot_rows:
+ slot_pk = sr["id"]
+ cur.execute(
+ """SELECT candidate_exercise_id FROM combination_slot_candidates
+ WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""",
+ (slot_pk,),
+ )
+ crows = cur.fetchall()
+ cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows]
+ cand_meta: Dict[int, Optional[str]] = {}
+ if cids:
+ ph = ",".join(["%s"] * len(cids))
+ cur.execute(
+ f"SELECT id, title FROM exercises WHERE id IN ({ph})",
+ tuple(cids),
+ )
+ cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()}
+ slots_out.append(
+ {
+ "slot_index": sr["slot_index"],
+ "title": sr.get("title"),
+ "candidate_exercise_ids": cids,
+ "candidates": [{"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids],
+ }
+ )
+ return slots_out
+
+
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
@@ -1102,41 +1139,7 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
exercise["combination_slots"] = []
if exercise["exercise_kind"] == "combination":
- cur.execute(
- """SELECT id, slot_index, title FROM combination_exercise_slots
- WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""",
- (exercise_id,),
- )
- slot_rows = [r2d(r) for r in cur.fetchall()]
- slots_out: List[dict] = []
- for sr in slot_rows:
- slot_pk = sr["id"]
- cur.execute(
- """SELECT candidate_exercise_id FROM combination_slot_candidates
- WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""",
- (slot_pk,),
- )
- crows = cur.fetchall()
- cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows]
- cand_meta: Dict[int, Optional[str]] = {}
- if cids:
- ph = ",".join(["%s"] * len(cids))
- cur.execute(
- f"SELECT id, title FROM exercises WHERE id IN ({ph})",
- tuple(cids),
- )
- cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()}
- slots_out.append(
- {
- "slot_index": sr["slot_index"],
- "title": sr.get("title"),
- "candidate_exercise_ids": cids,
- "candidates": [
- {"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids
- ],
- }
- )
- exercise["combination_slots"] = slots_out
+ exercise["combination_slots"] = load_combination_slots_for_exercise(cur, exercise_id)
return exercise
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index ff0172e..baac6d3 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -19,6 +19,8 @@ from club_tenancy import (
)
from routers.training_modules import load_training_module_for_apply
+from routers.exercises import load_combination_slots_for_exercise
+
router = APIRouter(prefix="/api", tags=["training_planning"])
@@ -493,6 +495,14 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
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"] = []
secs.append(sec)
return secs
diff --git a/backend/version.py b/backend/version.py
index 98c30eb..b011ef4 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.109"
+APP_VERSION = "0.8.110"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -21,10 +21,10 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.27.2", # Kombi: Serien‑Standard 1 + Archetyp‑Map ARCHETYPE_DEFAULT_REP_SERIES_COUNT; Payload rep_series_count ab 1
+ "exercises": "2.27.3", # load_combination_slots_for_exercise (gemeinsam mit GET Übung); Hydrate für Planung
"training_units": "0.2.0",
"training_programs": "0.1.0",
- "planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
+ "planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
@@ -35,6 +35,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.110",
+ "date": "2026-05-12",
+ "changes": [
+ "GET /api/training-units/:id: Bei Kombinationsübungen werden `combination_slots` inkl. Kandidaten-Titel mitgeliefert (für Plan & Ablauf / Druck).",
+ "Hilfsfunktion `load_combination_slots_for_exercise` im exercises-Router; GET Übung nutzt dieselbe Ladelogik.",
+ ],
+ },
{
"version": "0.8.109",
"date": "2026-05-12",
diff --git a/frontend/src/app.css b/frontend/src/app.css
index ff76e8b..3f479fc 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -6210,6 +6210,178 @@ a.analysis-split__nav-item {
line-height: 1.48;
}
+/* Kombinationsplan — Klammer (Vorschau, Plan & Ablauf, Druck) */
+.combo-plan-bracket {
+ display: flex;
+ gap: 0;
+ align-items: stretch;
+ margin: 0.35rem 0 0;
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+.combo-plan-bracket__accent {
+ width: 6px;
+ flex-shrink: 0;
+ background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dark) 100%);
+}
+.combo-plan-bracket__body {
+ flex: 1;
+ min-width: 0;
+ padding: 10px 12px 12px;
+}
+.combo-plan-bracket__head {
+ margin-bottom: 10px;
+}
+.combo-plan-bracket__head-main {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 8px 12px;
+}
+.combo-plan-bracket__kicker {
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text3);
+}
+.combo-plan-bracket__archetype {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--text1);
+}
+.combo-plan-bracket__archetype-id {
+ font-weight: 500;
+ font-size: 0.78rem;
+ color: var(--text3);
+}
+.combo-plan-bracket__badge {
+ font-size: 0.72rem;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--accent-soft, hsla(160, 42%, 90%, 1));
+ color: var(--accent-dark);
+}
+.combo-plan-bracket__hint {
+ margin: 6px 0 0;
+ font-size: 0.78rem;
+ line-height: 1.42;
+ color: var(--text2);
+}
+.combo-plan-bracket__globals {
+ margin-bottom: 12px;
+}
+.combo-plan-bracket__globals-title {
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text3);
+ margin-bottom: 8px;
+}
+.combo-plan-bracket__globals-empty {
+ margin: 0 0 12px;
+ font-size: 0.78rem;
+ color: var(--text3);
+ line-height: 1.4;
+}
+.combo-plan-bracket__chip-row {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.combo-plan-bracket__chip {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 6.5rem;
+ max-width: 14rem;
+ padding: 8px 10px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+}
+.combo-plan-bracket__chip-cap {
+ font-size: 0.68rem;
+ color: var(--text3);
+ line-height: 1.25;
+}
+.combo-plan-bracket__chip-val {
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: var(--text1);
+}
+.combo-plan-bracket__stations {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.combo-plan-bracket__station {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ padding: 10px 10px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+}
+.combo-plan-bracket__station-index {
+ flex-shrink: 0;
+ min-width: 2.25rem;
+ height: 2.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ font-size: 0.72rem;
+ font-weight: 800;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ color: var(--accent-dark);
+}
+.combo-plan-bracket__station-main {
+ flex: 1;
+ min-width: 0;
+}
+.combo-plan-bracket__station-title {
+ font-weight: 700;
+ font-size: 0.9rem;
+ color: var(--text1);
+ margin-bottom: 4px;
+}
+.combo-plan-bracket__station-exercises {
+ font-size: 0.84rem;
+ color: var(--text2);
+ line-height: 1.38;
+ margin-bottom: 6px;
+}
+.combo-plan-bracket__station-timing {
+ font-size: 0.78rem;
+ line-height: 1.42;
+ color: var(--text1);
+}
+.combo-plan-bracket__station-timing--muted {
+ color: var(--text3);
+ font-style: italic;
+}
+.combo-plan-bracket__timing-label {
+ font-weight: 700;
+ color: var(--text3);
+ margin-right: 6px;
+}
+.training-run-combo-embed {
+ margin-top: 0.65rem;
+}
+
@media print {
.desktop-sidebar,
.bottom-nav,
@@ -6236,6 +6408,24 @@ a.analysis-split__nav-item {
break-inside: avoid;
page-break-inside: avoid;
}
+ .combo-plan-bracket {
+ border-color: #222 !important;
+ background: #fff !important;
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+ .combo-plan-bracket__accent {
+ background: #085041 !important;
+ }
+ .combo-plan-bracket__chip,
+ .combo-plan-bracket__station {
+ border-color: #444 !important;
+ background: #f4f6f8 !important;
+ }
+ .combo-plan-bracket__station-index {
+ border-color: #444 !important;
+ color: #06352a !important;
+ }
}
/* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */
diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx
new file mode 100644
index 0000000..043146a
--- /dev/null
+++ b/frontend/src/components/CombinationPlanBracket.jsx
@@ -0,0 +1,128 @@
+/**
+ * Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
+ */
+import React, { useMemo } from 'react'
+import {
+ archetypeCoachHint,
+ combinationArchetypeLabel,
+ sortCombinationSlotsForDisplay,
+} from '../constants/combinationArchetypes'
+import { describeGlobalComboProfile, readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
+
+function candidateLine(slot) {
+ const cands = slot.candidates
+ if (Array.isArray(cands) && cands.length > 0) {
+ return cands
+ .map((c) =>
+ ((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
+ )
+ .filter(Boolean)
+ .join(' ↔ ')
+ }
+ const ids = slot.candidate_exercise_ids || []
+ return ids
+ .map((raw) => {
+ const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
+ return Number.isFinite(n) ? `Übung #${n}` : ''
+ })
+ .filter(Boolean)
+ .join(' ↔ ')
+}
+
+export default function CombinationPlanBracket({
+ methodArchetype,
+ methodProfile,
+ combinationSlots,
+ planningAdjusted = false,
+}) {
+ const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
+ const archLabel = arch ? combinationArchetypeLabel(arch) : null
+ const globals = describeGlobalComboProfile(arch, methodProfile || {})
+ const slotsSorted = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots || []), [combinationSlots])
+ const timingByIx = useMemo(() => {
+ const mp = methodProfile || {}
+ const rows = readSlotProfilesV1(mp)
+ const m = new Map()
+ for (const r of rows) {
+ m.set(Number(r.slot_index), r)
+ }
+ return m
+ }, [methodProfile])
+
+ const coachHint = arch ? archetypeCoachHint(arch) : ''
+
+ return (
+
+
+
+
+
+ {globals.length > 0 ? (
+
+ Runden · Zeiten · Pausen (global)
+
+ {globals.map((g) => (
+
+ {g.caption}
+ {g.value}
+
+ ))}
+
+
+ ) : (
+
+ Keine globalen Zahlenfelder gesetzt — Steuerung erfolgt nur je Station oder über den Freitext der Kombination.
+
+ )}
+
+
+ {slotsSorted.map((slot, si) => {
+ const siRaw = slot.slot_index
+ const ixParsed =
+ siRaw === '' || siRaw == null ? si : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
+ const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
+ const stationTitle = ((slot.title || '').trim() || `Station ${stationIx}`).trim()
+ const names = candidateLine(slot)
+ const timing = summarizeSlotProfileBrief(timingByIx.get(stationIx))
+
+ return (
+
+
+ S{stationIx}
+
+
+
{stationTitle}
+
{names || '(keine Einzelübung)'}
+ {timing ? (
+
+ Zeit / Steuerung
+ {timing}
+
+ ) : (
+
+ Keine eigene Stations‑Zeit im Profil — ggf. nur globale Vorgaben oder Freitext.
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx
index b87613b..e3efdb8 100644
--- a/frontend/src/components/ExercisePeekModal.jsx
+++ b/frontend/src/components/ExercisePeekModal.jsx
@@ -5,8 +5,7 @@ import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
-import CombinationCoachSlots from './CombinationCoachSlots'
-import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
+import CombinationPlanBracket from './CombinationPlanBracket'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function TagMini({ exercise }) {
@@ -100,7 +99,7 @@ export default function ExercisePeekModal({
aria-modal="true"
aria-labelledby="exercise-peek-title"
style={{
- maxWidth: sheetWide ? 'min(760px, 96vw)' : '620px',
+ maxWidth: sheetWide ? 'min(840px, 96vw)' : '620px',
width: '100%',
maxHeight: '88vh',
display: 'flex',
@@ -128,42 +127,17 @@ export default function ExercisePeekModal({
<>
{isCombination ? (
<>
-
- Kombination
-
- {(() => {
- const ak = String(exercise.method_archetype || '').trim()
- const lbl = ak ? combinationArchetypeLabel(ak) : null
- return lbl || ak || 'Archetyp nicht gesetzt'
- })()}
-
- {peekExtras?.planning_method_profile != null &&
- typeof peekExtras.planning_method_profile === 'object' &&
- !Array.isArray(peekExtras.planning_method_profile) ? (
-
- · Planung angepasst
-
- ) : null}
-
-
>
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 42ba42c..2cb0a0c 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1,7 +1,7 @@
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
-import CombinationCoachSlots from './CombinationCoachSlots'
+import CombinationPlanBracket from './CombinationPlanBracket'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
@@ -1611,12 +1611,15 @@ export default function TrainingUnitSectionsEditor({
{comboPlanningResolvedSlots.length > 0 ? (
-
) : (
diff --git a/frontend/src/pages/TrainingUnitRunPage.jsx b/frontend/src/pages/TrainingUnitRunPage.jsx
index a8c7341..5a7f4e1 100644
--- a/frontend/src/pages/TrainingUnitRunPage.jsx
+++ b/frontend/src/pages/TrainingUnitRunPage.jsx
@@ -5,7 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePeekModal from '../components/ExercisePeekModal'
+import CombinationPlanBracket from '../components/CombinationPlanBracket'
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
+import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function storageKey(unitId) {
return `sj_training_run_checked_${unitId}`
@@ -146,6 +148,7 @@ export default function TrainingUnitRunPage() {
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId}
variantId={peekCtx?.variantId ?? undefined}
+ peekExtras={peekCtx?.peekExtras ?? undefined}
onClose={() => setPeekCtx(null)}
/>
@@ -271,7 +274,15 @@ export default function TrainingUnitRunPage() {
const plan = formatMin(it.planned_duration_min)
const extras = []
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
- const metaParts = [...extras, plan].filter(Boolean)
+ const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
+ const isComboRow = exKind === 'combination'
+ const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
+ const comboEffectiveProfile = isComboRow
+ ? effectiveComboMethodProfile(
+ it.catalog_method_profile || {},
+ it.planning_method_profile ?? null,
+ )
+ : null
return (
@@ -309,6 +320,22 @@ export default function TrainingUnitRunPage() {
)}
)}
+ {isComboRow && it.exercise_id ? (
+
+
+
+ ) : null}
{it.exercise_id && (
setPeekCtx({
exerciseId: it.exercise_id,
- variantId: it.exercise_variant_id != null ? Number(it.exercise_variant_id) : null,
+ variantId:
+ it.exercise_variant_id != null
+ ? Number(it.exercise_variant_id)
+ : null,
+ peekExtras: isComboRow
+ ? {
+ catalog_method_profile: it.catalog_method_profile,
+ planning_method_profile: it.planning_method_profile,
+ }
+ : undefined,
})
}
>
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index 986372e..c3ce328 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -170,6 +170,45 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
free_method_block: [],
})
+function shortenComboGuiCaption(label) {
+ const t = (label || '').trim()
+ if (!t) return ''
+ const cut = t.split('(')[0].trim()
+ return cut.length > 52 ? `${cut.slice(0, 50)}…` : cut
+}
+
+/**
+ * Globale Archetyp-Felder aus method_profile für Lesetext (Vorschau, Druck).
+ * Ignoriert slot_profiles_v1 (kommt separat je Station).
+ */
+export function describeGlobalComboProfile(archetypeKey, profileObj) {
+ const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : ''
+ if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return []
+ const defs = METHOD_PROFILE_GUI_FIELDS[arch] || []
+ const rows = []
+ for (const def of defs) {
+ const val = profileObj[def.key]
+ if (val === undefined || val === null || val === '') continue
+ if (def.kind === 'bool') {
+ const on = val === true || val === 'true' || val === 1 || val === '1'
+ rows.push({
+ key: def.key,
+ caption: shortenComboGuiCaption(def.label),
+ detailLabel: def.label,
+ value: on ? 'Ja' : 'Nein',
+ })
+ } else {
+ rows.push({
+ key: def.key,
+ caption: shortenComboGuiCaption(def.label),
+ detailLabel: def.label,
+ value: String(val),
+ })
+ }
+ }
+ return rows
+}
+
/**
* Aktualisiert method_profile unter Beibehaltung nicht-GUI Schlüssel.
*/
diff --git a/frontend/src/version.js b/frontend/src/version.js
index 365be55..a09e434 100644
--- a/frontend/src/version.js
+++ b/frontend/src/version.js
@@ -1,7 +1,7 @@
// Shinkan Jinkendo Frontend Version
-export const APP_VERSION = "0.8.94"
-export const BUILD_DATE = "2026-05-11"
+export const APP_VERSION = "0.8.110"
+export const BUILD_DATE = "2026-05-12"
export const PAGE_VERSIONS = {
LoginPage: "1.0.2",
@@ -16,7 +16,7 @@ export const PAGE_VERSIONS = {
TrainingPlanningPage: "1.4.0",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
- TrainingUnitRunPage: "1.1.0",
+ TrainingUnitRunPage: "1.2.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0",
TrainerContextsPage: "1.0.0",
--
2.43.0
From ed15f737270a5a6a548717ee6af323c6d0dc3f93 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 14:25:58 +0200
Subject: [PATCH 23/27] feat(combo-planning): enhance modal UI and styling for
combination planning editing
- Updated the TrainingUnitSectionsEditor to integrate new CSS classes for the combo planning modal, improving the visual consistency with the exercise preview.
- Added responsive design features to the modal, including max-width and max-height adjustments for better usability on different screen sizes.
- Introduced new toolbar and hint elements to enhance user interaction and provide clearer guidance within the combo planning context.
- Refactored existing modal structure for improved accessibility and user experience.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/app.css | 41 ++++
.../components/TrainingUnitSectionsEditor.jsx | 190 ++++++++----------
2 files changed, 122 insertions(+), 109 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 3f479fc..f23f9cf 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -6382,6 +6382,47 @@ a.analysis-split__nav-item {
margin-top: 0.65rem;
}
+/* Kombi-Planung bearbeiten (Planungseditor): gleiches Modal-Chrome wie Übungs-Vorschau */
+.combo-planning-edit-backdrop.admin-modal-backdrop {
+ z-index: 10060;
+}
+.combo-planning-edit-sheet.admin-modal-sheet {
+ max-width: min(880px, calc(100vw - 24px));
+}
+@media (min-width: 640px) {
+ .combo-planning-edit-sheet.admin-modal-sheet {
+ max-height: min(88vh, 860px);
+ }
+}
+.combo-planning-edit-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+.combo-planning-edit-hint {
+ margin: 0 0 14px;
+ font-size: 0.8rem;
+ color: var(--text2);
+ line-height: 1.45;
+}
+.combo-planning-edit-card {
+ margin-top: 4px;
+ padding: 14px 16px 16px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ border-left: 4px solid var(--accent);
+}
+.combo-planning-edit-card__title {
+ margin: 0 0 12px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--text3);
+}
+
@media print {
.desktop-sidebar,
.bottom-nav,
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 2cb0a0c..78ecf1c 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1537,134 +1537,106 @@ export default function TrainingUnitSectionsEditor({
comboPlanningModalSX != null &&
comboPlanningModalIX != null ? (
{
if (e.target === e.currentTarget) setComboPlanningModal(null)
}}
>
e.stopPropagation()}
- style={{
- maxWidth: 'min(920px, 96vw)',
- maxHeight: 'min(800px, 88vh)',
- overflow: 'auto',
- }}
>
-
- Ablaufprofil dieser Kombination für diese Planung
-
-
-
- {(comboPlanningModalItem.exercise_title || '').trim() ||
- `Kombination #${comboPlanningModalItem.exercise_id}`}
-
-
- ({compactComboPlanningCaption(comboPlanningModalItem)})
-
-
-
-
- updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
- }
- >
- Planung wie Katalog
-
-
- updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
- ...(comboPlanningModalItem.catalog_method_profile || {}),
- })
- }
- >
- Aus Katalog kopieren …
-
-
-
- Stationen und Einzelübungen entsprechen der Kombination im Katalog. Einzelübungen hier auszutauschen ist
- derzeit nicht vorgesehen (würde die Katalog-Übung ändern). Die Bereiche unten überschreiben nur diesen
- Termin, sofern du von den Katalogvorgaben abweichst.
-
- {comboPlanningResolvedSlots.length > 0 ? (
-
-
+
+
+
+ {(comboPlanningModalItem.exercise_title || '').trim() ||
+ `Kombination #${comboPlanningModalItem.exercise_id}`}
+
+
+ Planung für diesen Termin · {compactComboPlanningCaption(comboPlanningModalItem)}
+
- ) : (
-
- Stationen werden geladen … oder die Kombination hat im Katalog keine Stationsliste.
-
- )}
-
- Zeiten und Steuerung für diesen Termin
-
-
{
- try {
- const obj = JSON.parse(json || '{}')
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
- updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', obj)
- }
- } catch {
- /* Ungültiges JSON — Hinweis im Editor */
- }
- }}
- comboSlotsOutline={comboPlanningSlotsOutline}
- />
-
setComboPlanningModal(null)}
>
Schließen
+
+
+
+ updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
+ }
+ >
+ Planung wie Katalog
+
+
+ updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
+ ...(comboPlanningModalItem.catalog_method_profile || {}),
+ })
+ }
+ >
+ Aus Katalog kopieren …
+
+
+
+ Vorschau unten entspricht der effektiven Planung (Katalog oder Anpassung). Stationen und Einzelübungen
+ kommen aus dem Katalog; hier änderst du nur Zeiten, Runden und Steuerung für diese Einheit.
+
+ {comboPlanningResolvedSlots.length > 0 ? (
+
+
+
+ ) : (
+
+ Stationen werden geladen … oder die Kombination hat im Katalog keine Stationsliste.
+
+ )}
+
+
Globale und stationsbezogene Anpassungen
+ {
+ try {
+ const obj = JSON.parse(json || '{}')
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
+ updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', obj)
+ }
+ } catch {
+ /* Ungültiges JSON — Hinweis im Editor */
+ }
+ }}
+ comboSlotsOutline={comboPlanningSlotsOutline}
+ />
+
+
) : null}
--
2.43.0
From 79dabbca5a24b0b64c120e5fbd6a498326a031c5 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 14:34:17 +0200
Subject: [PATCH 24/27] feat(combo-planning): replace summarizeSlotProfileBrief
with effectiveStationTimingSummary
- Updated CombinationCoachSlots, CombinationPlanBracket, and TrainingUnitSectionsEditor components to utilize effectiveStationTimingSummary for improved timing display.
- Adjusted station title handling to enhance clarity and consistency across components.
- Refactored utility functions to streamline slot timing summaries and improve overall user experience in combination planning.
---
.../src/components/CombinationCoachSlots.jsx | 6 +-
.../src/components/CombinationPlanBracket.jsx | 14 ++--
.../components/TrainingUnitSectionsEditor.jsx | 7 +-
.../src/utils/combinationMethodProfileUi.js | 76 +++++++++++++++++++
4 files changed, 92 insertions(+), 11 deletions(-)
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index fbcf995..d9a1954 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -10,7 +10,7 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
-import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
+import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
export default function CombinationCoachSlots({
combinationSlots,
@@ -190,10 +190,10 @@ export default function CombinationCoachSlots({
const slotTitle =
(slot.title && String(slot.title).trim()) ||
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
- `Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
+ `Station ${si + 1}`
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
- const timingSummary = summarizeSlotProfileBrief(slotTimingByIx.get(ix))
+ const timingSummary = effectiveStationTimingSummary(archeKey, methodProfile || {}, slotTimingByIx.get(ix))
return (
diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx
index 043146a..fa8e497 100644
--- a/frontend/src/components/CombinationPlanBracket.jsx
+++ b/frontend/src/components/CombinationPlanBracket.jsx
@@ -7,7 +7,7 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
-import { describeGlobalComboProfile, readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
+import { describeGlobalComboProfile, effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
function candidateLine(slot) {
const cands = slot.candidates
@@ -95,14 +95,18 @@ export default function CombinationPlanBracket({
const ixParsed =
siRaw === '' || siRaw == null ? si : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
- const stationTitle = ((slot.title || '').trim() || `Station ${stationIx}`).trim()
+ const displayStep = si + 1
+ const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
const names = candidateLine(slot)
- const timing = summarizeSlotProfileBrief(timingByIx.get(stationIx))
+ const timing = effectiveStationTimingSummary(arch, methodProfile || {}, timingByIx.get(stationIx))
return (
-
- S{stationIx}
+
+ S{displayStep}
{stationTitle}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 78ecf1c..9493f42 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -12,7 +12,7 @@ import {
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import api from '../utils/api'
-import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
+import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
@@ -109,6 +109,7 @@ function comboPlanningStripBulletTexts(it) {
const slots = sortCombinationSlotsForDisplay(it.combination_slots || [])
if (!slots.length) return []
const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile)
+ const archRaw = String(it.catalog_method_archetype || '').trim()
const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r]))
const titles = it.combo_member_title_by_id || {}
return slots.map((slot, idx) => {
@@ -116,7 +117,7 @@ function comboPlanningStripBulletTexts(it) {
const siParsed =
siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
const ix = Number.isFinite(siParsed) ? siParsed : idx
- const stationLbl = ((slot.title || '').trim() || `Station ${ix}`)
+ const stationLbl = ((slot.title || '').trim() || `Station ${idx + 1}`)
const candIds = (slot.candidate_exercise_ids || [])
.map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
.filter((n) => Number.isFinite(n))
@@ -124,7 +125,7 @@ function comboPlanningStripBulletTexts(it) {
candIds.length === 0
? '(keine Übung)'
: candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
- const timing = summarizeSlotProfileBrief(byIx.get(ix))
+ const timing = effectiveStationTimingSummary(archRaw, mp, byIx.get(ix))
let line = `${stationLbl}: ${namesJoined}`
if (timing) line += ` · ${timing}`
return line
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index c3ce328..d5a8b9f 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -322,6 +322,82 @@ export function summarizeSlotProfileBrief(r) {
return bits.join(' · ')
}
+function globalTimingHintsForArchetype(arch, mp) {
+ if (!mp || typeof mp !== 'object' || Array.isArray(mp)) return []
+ const bits = []
+ switch (arch) {
+ case 'circuit_rotate_time':
+ if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s Arbeit je Station`)
+ if (mp.transition_seconds != null && mp.transition_seconds !== '')
+ bits.push(`Rotation ${mp.transition_seconds}s`)
+ if (mp.rest_seconds != null && mp.rest_seconds !== '') bits.push(`Pause ${mp.rest_seconds}s`)
+ if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Umlauf‑Runden`)
+ break
+ case 'sequence_linear':
+ if (mp.hint_step_duration_sec != null && mp.hint_step_duration_sec !== '')
+ bits.push(`~${mp.hint_step_duration_sec}s je Station`)
+ if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Sequenz‑Durchläufe`)
+ if (mp.block_intro_sec != null && mp.block_intro_sec !== '') bits.push(`Block‑Intro ${mp.block_intro_sec}s`)
+ break
+ case 'time_domain_interval':
+ if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s Intervall‑Arbeit`)
+ if (mp.rest_seconds != null && mp.rest_seconds !== '') bits.push(`${mp.rest_seconds}s Erholung`)
+ if (mp.interval_rounds != null && mp.interval_rounds !== '') bits.push(`${mp.interval_rounds} Intervall‑Zyklen`)
+ break
+ case 'pair_superset':
+ if (mp.work_seconds_per_side != null && mp.work_seconds_per_side !== '')
+ bits.push(`${mp.work_seconds_per_side}s Arbeit`)
+ if (mp.switch_seconds != null && mp.switch_seconds !== '') bits.push(`Wechsel ${mp.switch_seconds}s`)
+ break
+ case 'station_parcour':
+ if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Parcours‑Runden`)
+ break
+ case 'circuit_all_parallel':
+ if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Runden`)
+ if (mp.explain_before_seconds != null && mp.explain_before_seconds !== '')
+ bits.push(`Erklärung ${mp.explain_before_seconds}s`)
+ break
+ default:
+ break
+ }
+ return bits
+}
+
+function isWeakSlotTimingSummary(txt) {
+ if (!txt || typeof txt !== 'string') return true
+ const t = txt.trim()
+ return t === 'Zeit' || t === 'Coach' || t === 'Ziel‑Wdh.'
+}
+
+/**
+ * Stationszeile für Lesetext: Slot‑Zeiten + bei Bedarf globale Eckdaten (Zirkel‑Sekunden, Runden …).
+ */
+export function effectiveStationTimingSummary(archetypeKey, profileObj, slotRow) {
+ const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : ''
+ const mp = profileObj && typeof profileObj === 'object' && !Array.isArray(profileObj) ? profileObj : {}
+ const slotTxt = summarizeSlotProfileBrief(slotRow)
+ const hints = globalTimingHintsForArchetype(arch, mp)
+ const hintStr = hints.join(' · ')
+
+ if (!isWeakSlotTimingSummary(slotTxt)) {
+ const extras = []
+ for (const h of hints) {
+ if (
+ /Runden|Durchläufe|Zyklen|Umlauf/i.test(h) &&
+ slotTxt &&
+ !/Runden|Serien|×|\d+s Arbeit|\d+s Erholung|\d+s Intervall/i.test(slotTxt)
+ ) {
+ extras.push(h)
+ }
+ }
+ return extras.length ? `${slotTxt} · ${extras.join(' · ')}` : slotTxt
+ }
+
+ if (hintStr) return hintStr
+ if (slotTxt) return slotTxt
+ return null
+}
+
function normalizeOptionalNonNegInt(v) {
if (v === '' || v === undefined || v === null) return undefined
const n = typeof v === 'number' ? v : parseInt(String(v), 10)
--
2.43.0
From d3ddc52118f129922d8b97b09eea86e22439170a Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 14:42:01 +0200
Subject: [PATCH 25/27] feat(combo-planning): enhance combination profile
handling and UI improvements
- Updated CombinationCoachSlots to integrate a new function for formatting inline profile values, improving data display consistency.
- Refactored CombinationMethodProfileEditor to streamline slot index handling and enhance title clarity.
- Improved CombinationPlanBracket by removing unnecessary elements for a cleaner UI.
- Enhanced ExerciseFullContent to support additional catalog method profile snapshots, improving exercise detail accuracy.
- Updated CSS for combo plan brackets to enhance visual presentation and alignment.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/app.css | 28 +-----
.../src/components/CombinationCoachSlots.jsx | 95 +++++++++++++------
.../CombinationMethodProfileEditor.jsx | 17 +---
.../src/components/CombinationPlanBracket.jsx | 13 +--
.../src/components/ExerciseFullContent.jsx | 15 ++-
frontend/src/pages/ExerciseDetailPage.jsx | 9 +-
frontend/src/pages/TrainingCoachPage.jsx | 5 +
.../src/utils/comboPlanningMethodProfile.js | 55 +++++++++--
8 files changed, 142 insertions(+), 95 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f23f9cf..a9fcbfd 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -6318,38 +6318,22 @@ a.analysis-split__nav-item {
color: var(--text1);
}
.combo-plan-bracket__stations {
- list-style: none;
- padding: 0;
+ list-style: decimal;
+ list-style-position: outside;
+ padding: 0 0 0 1.35rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.combo-plan-bracket__station {
- display: flex;
- gap: 10px;
- align-items: flex-start;
+ display: block;
padding: 10px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
-.combo-plan-bracket__station-index {
- flex-shrink: 0;
- min-width: 2.25rem;
- height: 2.25rem;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- font-size: 0.72rem;
- font-weight: 800;
- background: var(--surface);
- border: 1px solid var(--border);
- color: var(--accent-dark);
-}
.combo-plan-bracket__station-main {
- flex: 1;
min-width: 0;
}
.combo-plan-bracket__station-title {
@@ -6463,10 +6447,6 @@ a.analysis-split__nav-item {
border-color: #444 !important;
background: #f4f6f8 !important;
}
- .combo-plan-bracket__station-index {
- border-color: #444 !important;
- color: #06352a !important;
- }
}
/* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index d9a1954..a5286af 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -10,7 +10,24 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
-import { effectiveStationTimingSummary, readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
+import {
+ describeGlobalComboProfile,
+ effectiveStationTimingSummary,
+ METHOD_PROFILE_GUI_FIELDS,
+ readSlotProfilesV1,
+} from '../utils/combinationMethodProfileUi'
+
+function formatInlineProfileValue(val) {
+ if (val === null || val === undefined) return '—'
+ if (typeof val === 'boolean') return val ? 'ja' : 'nein'
+ if (typeof val === 'number' && Number.isFinite(val)) return String(val)
+ if (typeof val === 'string') return val.trim() === '' ? '—' : val
+ try {
+ return JSON.stringify(val)
+ } catch {
+ return String(val)
+ }
+}
export default function CombinationCoachSlots({
combinationSlots,
@@ -93,12 +110,25 @@ export default function CombinationCoachSlots({
return m
}, [methodProfile])
- const methodProfileKvSansSlots = useMemo(() => {
+ const globalComboRows = useMemo(
+ () => describeGlobalComboProfile(archeKey, methodProfile || {}),
+ [archeKey, methodProfile],
+ )
+
+ const profileExtraEntries = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
- return Object.entries(methodProfile)
- .filter(([k]) => k !== 'slot_profiles_v1')
- .sort(([a], [b]) => a.localeCompare(b, 'de'))
- }, [methodProfile])
+ const known = new Set(['slot_profiles_v1'])
+ for (const f of METHOD_PROFILE_GUI_FIELDS[archeKey] || []) {
+ known.add(f.key)
+ }
+ const out = []
+ for (const [k, val] of Object.entries(methodProfile)) {
+ if (known.has(k)) continue
+ if (val === null || val === undefined || val === '') continue
+ out.push([k, val])
+ }
+ return out.sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'de'))
+ }, [methodProfile, archeKey])
return (
{archDisplay ? (
-
- {archDisplay}
- {archeKey && archDisplay !== archeKey ? (
-
- ({archeKey})
-
- ) : null}
-
+ {archDisplay}
) : null}
{compactPlanningView ? null : (
@@ -148,28 +171,40 @@ export default function CombinationCoachSlots({
}}
>
- Geplantes Ablaufprofil (Katalog)
+ Globale Eckdaten (wie im Editor)
- {methodProfileKvSansSlots.length === 0 ? (
+ {globalComboRows.length === 0 && profileExtraEntries.length === 0 ? (
- Nur stationsbezogene Daten (Zeiten/Zähl‑Steuerung) — siehe je Station unter der Überschrift „Plan:“.
+ Keine globalen Zahlenfelder gesetzt — Zeiten und Steuerung siehe je Station unter „Plan:“ (oder nur im Freitext der Kombination).
) : (
- {methodProfileKvSansSlots.map(([k, val]) => (
-
+ {globalComboRows.map((row) => (
+
+
{row.detailLabel}
+ {row.value}
+
+ ))}
+ {profileExtraEntries.map(([k, val]) => (
+
{k}
-
- {typeof val === 'boolean'
- ? val
- ? 'ja'
- : 'nein'
- : typeof val === 'number'
- ? String(val)
- : typeof val === 'string'
- ? val
- : JSON.stringify(val)}
-
+ {formatInlineProfileValue(val)}
))}
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index a8fae3f..4236ad5 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'
-import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
+import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
@@ -299,13 +299,13 @@ export default function CombinationMethodProfileEditor({
Zirkel erst die globalen Arbeit‑Sekunden.
- {outlineSorted.map((slot) => {
+ {outlineSorted.map((slot, ordIdx) => {
const siRaw = slot.slot_index
const si =
siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
- const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
+ const ttl = ((slot.title || '').trim() || `Station ${ordIdx + 1}`).trim()
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
@@ -323,10 +323,7 @@ export default function CombinationMethodProfileEditor({
background: 'var(--surface2)',
}}
>
-
- Station {si}
- {ttl}
-
+
{ttl}
Steuerung
@@ -392,11 +389,7 @@ export default function CombinationMethodProfileEditor({
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="1"
- value={
- row.rep_series_count != null && String(row.rep_series_count) !== ''
- ? String(row.rep_series_count)
- : String(defaultRepSeriesCountForArchetype(methodArchetype))
- }
+ value={String(parseComboRepSeriesCountUi(row.rep_series_count))}
onChange={(e) => onSlotRepSeriesCount(si, e.target.value)}
/>
diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx
index fa8e497..7c919dd 100644
--- a/frontend/src/components/CombinationPlanBracket.jsx
+++ b/frontend/src/components/CombinationPlanBracket.jsx
@@ -58,12 +58,7 @@ export default function CombinationPlanBracket({