Verbesserung UX für Übungen #44

Merged
Lars merged 5 commits from develop into main 2026-05-21 15:02:15 +02:00
21 changed files with 1318 additions and 426 deletions

View File

@ -104,6 +104,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
**Skills-System:**
- [x] Hierarchisches Schema, Fokusbereich-Zuordnung, Exercise-Skill mit Levels
- [x] **Gewichtetes Fähigkeiten-Profil (Phase 3):** Module, Rahmenprogramme, Regressionspfade; Peer-Kontext getrennt; Listen-Filter + Discovery — **`technical/SKILL_SCORING_SPEC.md`**
**Admin-UI:**

View File

@ -407,10 +407,9 @@ skill_level_definitions (
- Reaktion (Koordination, target_level: 2, intensity: mittel)
**Attribute pro Fähigkeitsbezug:**
- is_primary (Haupt- oder Nebenfähigkeit)
- intensity (niedrig/mittel/hoch)
- required_level (Voraussetzung, 1-5)
- target_level (Ziel-Level, 1-5)
- `intensity` — Nutzeneinschätzung: **niedrig | mittel | hoch** (Standard **mittel**)
- `required_level` / `target_level` — Stufen-Spanne (kanonische Slugs basis … optimierung)
- `is_primary` — Legacy-Feld; **nicht mehr in der UI**, beim Speichern immer false; Scoring ignoriert es
**🆕 Fokusbereich-Filterung:**
- Bei Übungen mit Fokusbereich "Karate" sollten primär KARATE-Fähigkeiten zugeordnet werden
@ -474,6 +473,34 @@ skill_level_definitions (
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem SlotBlueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/LineageKonzept: Konzeptpapier Schritt **E**).
### Trainingsmodul (Bibliothek)
**Abgrenzung:** Wiederverwendbare **Übungsfolge** (`training_modules` + `training_module_items`) — kein Kalendertermin, kein Rahmen-Slot. Übernahme in geplante Einheiten über Planung (`apply-training-module`).
**Governance:** wie andere Bibliotheksartefakte (`visibility`, `club_id`, `library_content_visibility_sql`).
### Gewichtetes Fähigkeiten-Profil (Planungs-Bausteine, Phase 3)
**Zweck:** Aus den verknüpften Übungen eines Planungsartefakts wird ein **Fähigkeiten-Profil** berechnet (Trainingsgewicht je Fähigkeit). Trainer vergleichen Bausteine **innerhalb desselben Typs**, um z.B. das passendste Modul für eine Ziel-Fähigkeit zu finden.
**Artefakttypen (getrennte Peer-Kontexte):**
| Typ | Vergleich |
|-----|-----------|
| `training_module` | nur sichtbare **Module** |
| `framework_program` | nur sichtbare **Rahmenprogramme** |
| `progression_graph` | nur sichtbare **Regressionspfade** |
**Metriken (Nutzer):**
- **Score / Gewicht** — absolut (Dauer × Häufigkeit × Intensität × Stufen-Spanne)
- **Prozent** — Anteil am stärksten sichtbaren Peer **desselben Typs** für diese Fähigkeit (max. 100 %)
- **★** — stärkster Peer in diesem Kontext
**UI:** Profile in Editoren; KPI-Kacheln und Filter in Listen (`/planning/framework-programs`, `/planning/training-modules`); Discovery auf der Fähigkeiten-Seite.
**Technik:** `backend/skill_scoring.py`, `routers/skill_profiles.py` — Spec **`technical/SKILL_SCORING_SPEC.md`**.
### Parallele Trainingsstreams (Breakout)
**Fachlich:** Eine Kalender**Einheit** kann aus **Phasen** bestehen — z.B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **RahmenprogrammSlot** (SerienSession über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
@ -648,12 +675,13 @@ skill_level_definitions (
- [ ] Level-Definitionen aus Fähigkeitsmatrix extrahieren (optional)
- [ ] Skills-Beschreibungen aus Wiki importieren (Migration 024)
- [ ] Admin-UI für Fähigkeiten-Kategorien (CRUD)
- [ ] Skill-Filter in Übungssuche integrieren
- [x] Skill-Filter in Übungssuche (SkillTreeMultiSelect + Stufen)
- [x] Gewichtetes Fähigkeiten-Profil für Planungs-Bausteine (Module, Rahmen, Pfade) — siehe `technical/SKILL_SCORING_SPEC.md`
- [ ] Reifegradmodelle definieren (Kombination Fokusbereich + Stil + Zielgruppe)
- [ ] KI-Unterstützung für Trainingsplanung (basierend auf Fähigkeiten-Level)
---
**Letzte Aktualisierung:** 2026-04-27
**Letzte Aktualisierung:** 2026-05-20
**Verantwortlich:** Claude Code
**Review:** Pending

View File

@ -1,6 +1,6 @@
# Gelieferte Features & technische Basis (Q2 2026)
**Stand:** 2026-05-12
**Stand:** 2026-05-20
**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**. TrainingsrahmenBibliothek + SlotBlueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§34**. **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/`.
@ -169,11 +169,35 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
---
## 15. Verweise
## 15. Gewichtetes Fähigkeiten-Scoring (Phase 3, Stand 2026-05-20)
Norm: **`technical/SKILL_SCORING_SPEC.md`**.
### 15.1 Backend
- **`skill_scoring.py`:** Gewichtung (Dauer × Vorkommen × Intensität × Stufen); `compute_planning_corpus_by_type()` mit getrennten Corpora; `universal_percent` capped auf 100 %
- **`routers/skill_profiles.py`:** Profile-GET pro Artefakt; `POST /api/skill-profiles/batch-summaries`; `GET /api/skill-discovery/suggestions`
- Sichtbarkeit: **`library_content_visibility_sql`** (Planungs-Bibliothek, nicht „nur Verein club“)
### 15.2 Frontend
- **Listen:** Rahmenprogramme + Trainingsmodule — Filter-Modal (wie Übungen), Chips, `SkillTreeMultiSelect` (Portal-Dropdown)
- **KPI:** `SkillProfileCompact` — Top je Unterkategorie, Score + Peer-%
- **Editoren + Modal:** `SkillProfilePanel`, `SkillProfileFullModal`
- **Discovery:** `SkillDiscoveryPanel` auf Fähigkeiten-Seite
### 15.3 Offen
- Corpus-Caching; pytest für Typ-Trennung; Filter-Persistenz; Skill-Filter Import-Dialog „Rahmen übernehmen“
---
## 16. Verweise
| Thema | Dokument |
|--------|----------|
| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` |
| Fähigkeiten-Scoring Planung | `technical/SKILL_SCORING_SPEC.md` |
| API Übungen | `technical/EXERCISES_API_SPEC.md` |
| Domänenmodell | `functional/DOMAIN_MODEL.md` |
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |

View File

@ -1,12 +1,33 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
**Stand:** 2026-05-20
**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
**Status:** Variante A (regelbasiert) umgesetzt — **v1.3** (Peer-Kontext getrennt + Listen-Filter)
**Modul:** `backend/skill_scoring.py`, Router `backend/routers/skill_profiles.py`
## Ziel
Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken.
Trainer wählen **Schwerpunkt-Fähigkeiten** und finden passende **Bausteine** für die Trainingsplanung:
- **Trainingsmodule** — wiederverwendbare Übungsfolgen
- **Rahmenprogramme** — Programme mit Zielen und Session-Slots
- **Regressionspfade** (Progressionsgraphen) — Übungsketten
Das Scoring beantwortet: *Wie stark trainiert dieser Baustein eine Fähigkeit?* und *Wie stark ist er im Vergleich zu anderen **sichtbaren** Bausteinen **desselben Typs**?*
## Fachliche Kernregel: Peer-Kontext (nicht vermischen)
| Planungs-Artefakt | Vergleichsgruppe (`universal_percent`) |
|-------------------|----------------------------------------|
| Trainingsmodul | nur andere **sichtbare Module** |
| Rahmenprogramm | nur andere **sichtbare Rahmenprogramme** |
| Regressionspfad | nur andere **sichtbare Pfade** |
**Nicht** verglichen werden:
- Module vs. Rahmenprogramme vs. Pfade (kein Mix)
- Artefakte anderer Vereine, auf die der Nutzer keinen Planungszugriff hat
**Sichtbarkeit:** `library_content_visibility_sql` — private, vereinsinterne und offizielle Inhalte gemäß Mandant/Rolle, analog zu anderen Bibliothekslisten.
## Datenquellen
@ -19,7 +40,7 @@ Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rah
Fähigkeiten je Übung: `exercise_skills``skills` (nur `status = active`).
## Gewichtungsformel (v1.1)
## Gewichtungsformel (v1.1 / v1.2)
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
@ -44,8 +65,6 @@ Kanonische Slugs: basis … optimierung (15). Fehlen beide: Faktor 1,0.
- **Mittelpunkt** = durchschnittliche Stufe
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,961,20
Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
### Bewusst nicht im Scoring
| Feld | Grund |
@ -53,44 +72,113 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
Aggregation:
## Aggregierte Metriken
- Summe pro `skill_id``weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
| Feld | Bedeutung |
|------|-----------|
| `weight` / `score` | Absolutes **Trainingsgewicht** (gewichtete Minuten) — über alle Fähigkeiten eines Artefakts vergleichbar |
| `share_percent` | Anteil am `total_weight` **innerhalb dieses Artefakts** (summiert 100 %) — sekundär |
| `by_main_category[]` | Je Unterkategorie `top_skill` (stärkste Fähigkeit nach Gewicht) |
| `universal_percent` | Anteil am **Maximum derselben Fähigkeit im Peer-Kontext** (max. 100 %) |
| `is_club_best_for_skill` | Stärkster sichtbarer Peer für diese Fähigkeit (★ in UI) |
| `club_best` | Referenz-Peer (Titel, Typ, Gewicht) — **Legacy-Name**, fachlich Peer-Best |
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
### Berechnung `universal_percent`
```
effective_ref = max(max_weight_in_peer_corpus(skill_id), eigenes_gewicht)
universal_percent = min(100, weight / effective_ref × 100)
```
Corpus je Typ: `compute_planning_corpus_by_type()` scannt sichtbare Artefakte getrennt nach `framework_program`, `training_module`, `progression_graph`.
Discovery-Sortierung nutzt **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil. Discovery verwendet typ-getrennte Referenz (`fw_ref`, `mod_ref`, `graph_ref`).
## API
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` |
| GET | `/api/training-modules/{id}/skill-profile` | `overall` |
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` |
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile`; `reference_scale` (Peer-Kontext Rahmenprogramme) |
| GET | `/api/training-modules/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Module) |
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Pfade) |
| POST | `/api/skill-profiles/batch-summaries` | Kompakte Profile für Listen; Body: `frameworkProgramIds`, `trainingModuleIds`, …; Response: `summaries`, `reference_scale_by_type`, `club_best_by_skill` |
| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` |
Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`).
Zugriff: `get_tenant_context` + `library_content_visibility_sql` wie Parent-Artefakt.
### `reference_scale` / `reference_scale_by_type`
```json
{
"scope": "planning_peer",
"artifact_type": "training_module",
"artifacts_scanned": 12,
"skills_in_corpus": 34,
"description": "Prozent = Anteil am stärksten sichtbaren Eintrag unter Trainingsmodulen …"
}
```
## UI
- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session
- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
### Bearbeitung (Vollprofil)
Profil wird nach **Speichern** neu geladen (`skillProfileTick`).
| Ort | Panel | `artifactType` |
|-----|-------|----------------|
| Rahmenprogramm bearbeiten | Fähigkeiten-Schwerpunkte (+ Sessions) | `framework_program` |
| Trainingsmodul bearbeiten | Fähigkeiten im Modul | `training_module` |
| Progressionsgraph | Fähigkeiten entlang des Pfads | `progression_graph` |
Anzeige: Top je Kategorie (Editor) oder alle Fähigkeiten (Modal). Hinweise nennen Peer-Kontext explizit (z. B. „72 % Rahmenpr.“).
### Listen & Filter (UX wie Übungsliste)
| Liste | Filter |
|-------|--------|
| Rahmenprogramme (`/planning/framework-programs`) | Suche, Katalog (Fokus/Trainingsart/Zielgruppe), Session-Dauer, **Fähigkeiten** (`SkillTreeMultiSelect`), Mindest-% im Peer-Kontext, Sortierung nach Stärke |
| Trainingsmodule (`/planning/training-modules`) | Suche, **Fähigkeiten** (+ Min-%, Sortierung) |
- **Filter-Button** mit Badge, entfernbare **Chips**, Einstellungen im **Modal** (`PlanningArtifactFilterModal`)
- KPI-Kacheln: Top-Fähigkeit **je Unterkategorie** mit Score + Peer-%
- Vollprofil-Modal: `SkillProfileFullModal` mit `displayMode=full`
Profil wird nach Speichern neu geladen (`skillProfileTick` in Editoren).
### Discovery
**Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + API `/api/skill-discovery/suggestions` (optional Filter `types`).
## Frontend-Module (Auswahl)
| Pfad | Rolle |
|------|--------|
| `frontend/src/components/planning/PlanningArtifactFilterModal.jsx` | Filter-Modal |
| `frontend/src/components/planning/PlanningSkillFilterSection.jsx` | Fähigkeiten-Block im Modal |
| `frontend/src/utils/planningArtifactFilterChips.js` | Chip-Labels + Entfernen |
| `frontend/src/utils/frameworkProgramListHelpers.js` | Client-Filter Rahmenprogramme |
| `frontend/src/utils/trainingModuleListHelpers.js` | Client-Filter Module |
| `frontend/src/components/skills/SkillProfileCompact.jsx` | KPI-Kacheln in Listen |
| `frontend/src/components/SkillTreeMultiSelect.jsx` | Baumauswahl (Portal-Dropdown in Modals) |
## Grenzen / später
- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index
- Kein DB-Cache (`skill_profile_json`) — on-the-fly; bei >50 Artefakten pro Typ serverseitiger Index/Caching
- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0
- KI-Zusammenfassung (Variante B) nicht Teil von v1.0
- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
- Filter-Persistenz („Als Standard speichern“) wie bei Übungen — noch nicht für Planungslisten
- Fähigkeiten-Filter im Dialog **Planung → Rahmen übernehmen** — Katalog ja, Skill-Filter optional nachziehen
- API-Feldnamen `club_*` / `skillMinClubPercent` — technische Altlast, semantisch Peer-Kontext
## Tests
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score, Cap 100 %
- **Offen:** dedizierte Tests für `compute_planning_corpus_by_type` (Typ-Trennung)
## Verweise
| Dokument | Inhalt |
|----------|--------|
| `functional/DOMAIN_MODEL.md` | Domänenabschnitt Planungs-Fähigkeiten-Profil |
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick Listen/Filter |
| `docs/HANDOVER.md` | Handover-Abschnitt Phase 3 |
| `technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Sichtbarkeit / Mandant |

View File

@ -16,6 +16,7 @@
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURRTabelle). |
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **RahmenSlots** (SerienSessions). |
| `technical/SKILL_SCORING_SPEC.md` | **Fähigkeiten-Profil** der RahmenSlots / Module / Pfade; Listen-Filter und PeerVergleich (nur gleicher Artefakttyp). |
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 12. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „AlternativePakete“ in der UI).

View File

@ -2,7 +2,7 @@
**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 (Abgleich mit Code App **0.8.110**, siehe `backend/version.py`)
**Stand dieses Dokuments:** 2026-05-20 (Abgleich mit Code, siehe `backend/version.py`)
## Ziele
@ -12,7 +12,7 @@ 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)** |
| **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“; **Ergänzung 2026-05-20:** Fähigkeiten-Profil + Listen-Filter (Peer-Vergleich nur unter Modulen) — `technical/SKILL_SCORING_SPEC.md` | **umgesetzt (MVP Schritt 1)** |
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt |
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** — Merge Katalog+Planung; `CombinationPlanBracket` in Peek/Run; globale Profilzahlen mit Labels (`describeGlobalComboProfile`); Stations-/Timing-Zusammenfassung inkl. Wdh.-Hinweise. **Stufe B/C****offen**10.6, Anhang A) |

View File

@ -12,6 +12,7 @@
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Fähigkeiten-Scoring (Planungs-Bausteine) | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
@ -90,6 +91,7 @@ Kurz (Stand 2026-05-14): App- und DB-Version siehe **`backend/version.py`**; Ker
### Log (Auszug)
- 2026-05-20: **Fähigkeiten-Scoring Phase 3** — gewichtete Profile für Module/Rahmen/Pfade; Peer-Vergleich getrennt nach Artefakttyp; Listen-Filter + Discovery — siehe `SKILL_SCORING_SPEC.md`, `docs/HANDOVER.md` §2.6, `FEATURES_DELIVERED_2026-Q2.md` §15.
- 2026-05-07: **Medien** — zentrales Archiv (`media_assets`), Bibliothek-UI, Lifecycle/Papierkorb, `from-asset`, Speicherpfade `library/…`, Governance `official`/Copyright; **0.8.59** aktiver Verein UI/API-Sync — siehe `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12, `docs/HANDOVER.md`.
- 2026-05-05: Rahmen nur Bibliothek (**036**), SlotAblauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`.
- 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`.

View File

@ -113,6 +113,25 @@ def normalize_exercise_skill_level(value) -> Optional[str]:
return s
return _LEGACY_SKILL_LEVEL_SLUG.get(s)
_ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"})
def normalize_exercise_skill_intensity(value) -> str:
"""Kanonische Nutzeneinschätzung; leer/ungültig → mittel (kein leerer Wert)."""
if value is None:
return "mittel"
key = str(value).strip().lower()
if key in ("low",):
return "niedrig"
if key in ("medium",):
return "mittel"
if key in ("high",):
return "hoch"
if key in _ALLOWED_SKILL_INTENSITY:
return key
return "mittel"
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
MAX_EXERCISE_MEDIA = 10
# Upload-Limits (Übungs-Medien): Trainer wie bisher kleiner; Admin/Superadmin höheres Limit für große Videos
@ -1102,13 +1121,15 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
FROM exercise_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, s.name""",
ORDER BY s.name""",
(exercise_id,)
)
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
for sk in exercise["skills"]:
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
sk["intensity"] = normalize_exercise_skill_intensity(sk.get("intensity"))
sk["is_primary"] = False
# Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
cur.execute(
@ -1223,8 +1244,8 @@ def assign_exercise_relations(
(
exercise_id,
skill["skill_id"],
skill.get("is_primary", False),
skill.get("intensity"),
False,
normalize_exercise_skill_intensity(skill.get("intensity")),
normalize_exercise_skill_level(skill.get("required_level")),
normalize_exercise_skill_level(skill.get("target_level")),
skill.get("ai_suggested", False),

View File

@ -18,6 +18,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth
from smw_client import SmwClient, SmwClientError
from smw_mapper import map_wiki_to_exercise, map_wiki_to_skill, map_wiki_to_method, build_skill_assignments
from routers.exercises import normalize_exercise_skill_intensity
logger = logging.getLogger(__name__)
@ -615,14 +616,16 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
target_level = EXCLUDED.target_level,
is_primary = EXCLUDED.is_primary""",
required_level = EXCLUDED.required_level,
intensity = EXCLUDED.intensity,
is_primary = false""",
(
exercise_id,
sid,
assignment.get("target_level"),
assignment.get("required_level"),
assignment.get("intensity"),
assignment.get("is_primary", False),
normalize_exercise_skill_intensity(assignment.get("intensity")),
False,
)
)
conn.commit()

View File

@ -377,8 +377,8 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
"skill_name": skill_name,
"target_level": target_slug,
"required_level": None,
"intensity": None,
"is_primary": idx == 0,
"intensity": "mittel",
"is_primary": False,
})
return assignments

View File

@ -0,0 +1,20 @@
"""Normalisierung Übung ↔ Fähigkeit Intensität."""
from routers.exercises import normalize_exercise_skill_intensity
def test_normalize_exercise_skill_intensity_defaults_to_mittel():
assert normalize_exercise_skill_intensity(None) == "mittel"
assert normalize_exercise_skill_intensity("") == "mittel"
assert normalize_exercise_skill_intensity("") == "mittel"
def test_normalize_exercise_skill_intensity_canonical():
assert normalize_exercise_skill_intensity("niedrig") == "niedrig"
assert normalize_exercise_skill_intensity("mittel") == "mittel"
assert normalize_exercise_skill_intensity("hoch") == "hoch"
def test_normalize_exercise_skill_intensity_legacy_aliases():
assert normalize_exercise_skill_intensity("low") == "niedrig"
assert normalize_exercise_skill_intensity("medium") == "mittel"
assert normalize_exercise_skill_intensity("high") == "hoch"

View File

@ -66,6 +66,8 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
- **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).
- **Planungs-Vorschläge (Phase 3):** Auf der Fähigkeiten-Seite können Schwerpunkte gewählt werden; Shinkan schlägt passende **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** vor (Sortierung nach Trainingsgewicht).
- **Fähigkeiten-Profile an Planungs-Bausteinen:** Listen zeigen pro Modul/Rahmenprogramm KPI-Kacheln (Top je Kategorie) mit **Score** und **Peer-Prozent** — Vergleich nur unter sichtbaren Bausteinen **desselben Typs** (Modul vs. Modul, nicht Modul vs. Plan). Filter analog zur Übungsliste (Modal, Chips, Baumauswahl).
### 4.3 Reifegradmodelle (Fähigkeitsmatrix)
@ -78,6 +80,8 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
- **Phasen & parallele Streams (Breakout):** Eine Einheit kann aus abwechselnden **Ganzgruppenphasen** und **Parallelphasen** bestehen; in einer Parallelphase führen **mehrere Streams** (Teilstrecken) je eigene Abschnitte/Übungen. Planung über Breakout-UI; API liefert **`phases`** und flache **`sections`** (Migration **063**, siehe **`docs/HANDOVER.md`**). Technische Details: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden (Phasen in Vorlagen: Ausbau siehe Handover „offen“).
- **Trainingsrahmenprogramm (Bibliothek):** übergeordnete Programme mit **Zielen** und **Slots**; Slot-Inhalt technisch als **Blueprint-Trainingsunit** abgebildet.
- **Trainingsmodule (Bibliothek):** wiederverwendbare Übungsfolgen; Übernahme in geplante Einheiten; **Fähigkeiten-Profil** und **Filter** in der Modul-Liste (Peer-Vergleich nur unter Modulen).
- **Rahmenprogramm-Liste:** Suche und Filter (Katalog, Dauer, Fähigkeiten) — Peer-Vergleich nur unter Rahmenprogrammen.
- **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 („Plan & Ablauf“):** Ablauf anhand Phasen/Streams darstellen und abarbeiten (inkl. Split-Logik in der Anzeige).
- **Coaching-Modus:** eigener Ablauf mit Schritt-für-Schritt-Timeline, Stream-Wahl pro Parallelphase, Hinweis **„Parallelphase · Abschluss“** (Gruppen zusammenführen) vor der nächsten Ganzgruppenphase oder vor dem nächsten Split; **Nachbereitung** mit Ist-Minuten und Speichern wie in der Planung (inkl. **`phases`**). Nach erfolgreichem Speichern Wechsel zur **Plan- und Ablaufsicht** derselben Einheit. Bei **Kombinationsübungen** zusätzlich **Stations-/Kandidaten-Schicht und Archetyp-Hinweise** (Fachspez **Anhang A**; Ausbauschritte B/C).
@ -129,6 +133,7 @@ Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen**
| Datum | Änderung |
|-------|----------|
| 2026-05-20 | Fähigkeiten-Scoring Phase 3: Peer-Kontext, Listen-Filter Module/Rahmen, Planungs-Vorschläge. |
| 2026-05-14 | Trainingsplanung: Phasen/parallele Streams, Coaching (Rejoin, Nachbereitung → Planansicht); Lücken §5 ergänzt. Verweis `HANDOVER.md`. |
| 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`. |

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-19
**Stand:** 2026-05-20
**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `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**.
@ -35,6 +35,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **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` |
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
@ -69,6 +70,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **`AdminMaturityModelsPage.jsx`**, **`MaturityModelBindingsAdmin.jsx`**, **`MaturityMatrixToolsAdmin.jsx`**; APIs in `api.js`.
### 2.6 Gewichtetes Fähigkeiten-Scoring (Phase 3, Stand 2026-05-20)
- **Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
- **Backend:** `skill_scoring.py`, `routers/skill_profiles.py` — Profile on-the-fly aus Übungsvorkommen + `exercise_skills`; **Peer-Kontext getrennt** (`framework_program` | `training_module` | `progression_graph`) über `library_content_visibility_sql`
- **Metriken:** Trainingsgewicht (`weight`); **Peer-%** (`universal_percent`, max. 100 %) nur unter sichtbaren Bausteinen **desselben Typs**; ★ = stärkster Peer je Fähigkeit
- **API:** Skill-Profile pro Artefakt; `POST /api/skill-profiles/batch-summaries` für Listen; `GET /api/skill-discovery/suggestions`
- **Frontend:** KPI-Kacheln + Filter-Modal (UX wie Übungsliste) auf **`/planning/framework-programs`** und **`/planning/training-modules`**; Panels in Editoren; Discovery auf Fähigkeiten-Seite; `SkillTreeMultiSelect` mit Portal-Dropdown in Modals
- **Offen (Backlog):** Corpus-Caching bei großen Bibliotheken; Tests für Typ-Trennung; Filter-Persistenz; Skill-Filter im Dialog „Rahmen übernehmen“; API-Umbenennung `club_*` → Peer-Namen
---
## 3. Trainingsrahmenprogramm & PlanungsBlueprint (kurz)

View File

@ -4,6 +4,7 @@
import { stripHtmlToText } from '../utils/htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { normalizeExerciseSkillIntensity } from '../constants/exerciseSkillIntensity'
import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js'
/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */
@ -89,8 +90,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
age_groups: [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
intensity: s.intensity || null,
intensity: normalizeExerciseSkillIntensity(s.intensity),
required_level: s.required_level || null,
target_level: s.target_level || null,
})),

View File

@ -6548,6 +6548,367 @@ html.modal-scroll-locked .app-main {
min-width: 0;
}
/* Übungsformular — Register-Tabs & farbige Bereiche */
.exercise-form-edit {
padding-top: 4px;
}
.exercise-form-edit__tabbar {
display: flex;
align-items: stretch;
margin: 0 0 16px;
padding: 0 0 12px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 6;
background: var(--surface);
}
.exercise-form-edit__tabbar .admin-page-subtabs {
flex: 1;
min-width: 0;
}
.exercise-form-panel {
padding: 4px 0 8px 14px;
margin-bottom: 4px;
border-left: 3px solid var(--border);
}
.exercise-form-panel--basics {
border-left-color: var(--accent);
}
.exercise-form-panel--guide {
border-left-color: color-mix(in srgb, #2563eb 70%, var(--accent));
}
.exercise-form-panel--classify {
border-left-color: color-mix(in srgb, #7c3aed 65%, var(--accent));
}
.exercise-form-panel--combo {
border-left-color: color-mix(in srgb, #d97706 70%, var(--accent-dark));
}
.exercise-form-panel--variants {
border-left-color: color-mix(in srgb, #0891b2 70%, var(--accent));
}
.exercise-form-panel--media {
border-left-color: color-mix(in srgb, var(--text3) 55%, var(--border));
}
.exercise-form-panel__title {
margin: 0 0 4px;
font-size: 1.05rem;
font-weight: 700;
}
.exercise-form-panel__hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--text3);
line-height: 1.45;
}
.exercise-form-panel__body {
min-width: 0;
}
.exercise-form-type-box {
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
margin-bottom: 12px;
}
.exercise-form-type-box__hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--text2);
line-height: 1.45;
}
.exercise-form-inline-tab-link {
padding: 0;
border: none;
background: none;
color: var(--accent-dark);
font: inherit;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.exercise-form-subsection {
padding: 12px 0;
border-top: 1px dashed var(--border);
margin-top: 8px;
}
.exercise-form-subsection:first-child {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.exercise-form-subsection__title {
margin: 0 0 6px;
font-size: 0.95rem;
font-weight: 700;
}
.exercise-form-subsection__hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--text3);
line-height: 1.4;
}
/* Übungsformular — Klassifikation & Meta-Chips */
.exercise-form-meta-panel {
margin: 0;
padding: 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface2);
}
.exercise-form-meta-panel__title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 700;
}
.exercise-form-meta-panel__grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 720px) {
.exercise-form-meta-panel__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.exercise-meta-block {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.exercise-meta-block--skills {
margin-top: 10px;
}
.exercise-meta-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.exercise-meta-block__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--text1);
}
.exercise-meta-block__add {
font-size: 11px;
padding: 3px 8px;
flex-shrink: 0;
}
.exercise-meta-block__hint,
.exercise-meta-block__empty {
margin: 0 0 8px;
font-size: 12px;
color: var(--text3);
line-height: 1.35;
}
.exercise-meta-block__empty {
margin-bottom: 0;
}
.exercise-meta-block__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.exercise-catalog-chip {
display: inline-flex;
align-items: center;
gap: 2px;
max-width: 100%;
padding: 2px 4px 2px 2px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface2);
}
.exercise-catalog-chip__select {
border: none;
background: transparent;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
color: var(--text1);
min-width: 0;
max-width: min(220px, 72vw);
cursor: pointer;
}
.exercise-catalog-chip__select:focus {
outline: none;
}
.exercise-catalog-chip__primary,
.exercise-catalog-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--text3);
font-size: 12px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.exercise-catalog-chip__primary--on {
color: var(--accent-dark);
}
.exercise-catalog-chip__remove:hover,
.exercise-catalog-chip__primary:hover {
background: color-mix(in srgb, var(--border) 40%, transparent);
color: var(--text1);
}
.exercise-skills-add {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: stretch;
margin-bottom: 10px;
}
.exercise-skills-add .skill-tree-select {
flex: 1 1 180px;
min-width: 0;
}
.exercise-skills-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.exercise-skill-chip {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
@media (min-width: 640px) {
.exercise-skill-chip {
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
}
}
.exercise-skill-chip__identity {
flex: 0 1 auto;
min-width: 0;
max-width: min(240px, 100%);
}
.exercise-skill-chip__name {
display: block;
font-size: 13px;
font-weight: 700;
color: var(--text1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exercise-skill-chip__path {
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exercise-skill-chip__controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px 10px;
flex: 1 1 auto;
min-width: 0;
}
.exercise-skill-chip__field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.exercise-skill-chip__caption {
font-size: 10px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.exercise-skill-chip__levels {
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
gap: 6px;
}
.exercise-skill-level-select {
width: 52px;
min-width: 52px;
padding: 5px 6px;
font-size: 13px;
text-align: center;
}
.exercise-skill-chip__dash {
padding-bottom: 7px;
color: var(--text3);
font-size: 12px;
font-weight: 600;
}
.exercise-intensity-segment {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.exercise-intensity-segment__btn {
padding: 5px 8px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
font-size: 11px;
font-family: inherit;
color: var(--text2);
cursor: pointer;
white-space: nowrap;
}
.exercise-intensity-segment__btn:last-child {
border-right: none;
}
.exercise-intensity-segment__btn--active {
background: color-mix(in srgb, var(--accent) 16%, var(--surface));
color: var(--accent-dark);
font-weight: 700;
}
.exercise-skill-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-left: auto;
padding: 0;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text3);
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
}
.exercise-skill-chip__remove:hover {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}
.skills-editor-row {
display: grid;
grid-template-columns: 1fr auto;

View File

@ -0,0 +1,97 @@
import React from 'react'
/**
* Kompakte Katalog-Zuordnung (Fokus, Stile, Zielgruppen ) als Chip-Zeilen.
*/
export default function ExerciseCatalogAssocEditor({
title,
rows,
setRows,
options,
idKey,
emptyLabel,
showPrimary = true,
}) {
const setPrimary = (idx) => {
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
}
const updateRow = (idx, patch) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
if (patch.is_primary === true) {
next.forEach((r, i) => {
if (i !== idx) r.is_primary = false
})
}
setRows(next)
}
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
const removeRow = (idx) => {
const next = rows.filter((_, i) => i !== idx)
if (next.length && showPrimary && !next.some((r) => r.is_primary)) next[0].is_primary = true
setRows(next)
}
const optionLabel = (o) => {
const parts = []
if (o.icon) parts.push(o.icon)
parts.push(o.name)
if (o.abbreviation) parts.push(`(${o.abbreviation})`)
return parts.join(' ')
}
return (
<div className="exercise-meta-block">
<div className="exercise-meta-block__head">
<h4 className="exercise-meta-block__title">{title}</h4>
<button type="button" className="btn btn-secondary exercise-meta-block__add" onClick={addRow}>
+ Eintrag
</button>
</div>
{rows.length === 0 ? (
<p className="exercise-meta-block__empty">{emptyLabel}</p>
) : (
<div className="exercise-meta-block__chips" role="list">
{rows.map((row, idx) => (
<div key={idx} className="exercise-catalog-chip" role="listitem">
<select
className="exercise-catalog-chip__select"
value={row[idKey] || ''}
aria-label={`${title} wählen`}
onChange={(e) =>
updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })
}
>
<option value=""> wählen </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{optionLabel(o)}
</option>
))}
</select>
{showPrimary ? (
<button
type="button"
className={`exercise-catalog-chip__primary${row.is_primary ? ' exercise-catalog-chip__primary--on' : ''}`}
title={row.is_primary ? 'Primär' : 'Als primär markieren'}
aria-pressed={!!row.is_primary}
onClick={() => setPrimary(idx)}
>
</button>
) : null}
<button
type="button"
className="exercise-catalog-chip__remove"
aria-label="Entfernen"
title="Entfernen"
onClick={() => removeRow(idx)}
>
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,32 @@
import React from 'react'
import PageSectionNav from '../PageSectionNav'
export function ExerciseFormTabBar({ activeTab, onChange, items }) {
return (
<div className="exercise-form-edit__tabbar">
<PageSectionNav
ariaLabel="Übungsbereiche"
value={activeTab}
onChange={onChange}
items={items}
className="page-section-nav--embedded exercise-form-edit__section-nav"
/>
</div>
)
}
export function ExerciseFormPanel({ tab, activeTab, tone = 'default', title, hint, children }) {
if (activeTab !== tab) return null
return (
<section
id={`exercise-form-panel-${tab}`}
className={`exercise-form-panel exercise-form-panel--${tone}`}
role="tabpanel"
aria-labelledby={`exercise-form-tab-${tab}`}
>
{title ? <h3 className="exercise-form-panel__title">{title}</h3> : null}
{hint ? <p className="exercise-form-panel__hint">{hint}</p> : null}
<div className="exercise-form-panel__body">{children}</div>
</section>
)
}

View File

@ -14,9 +14,9 @@ import {
buildExerciseMediaDragPayload,
} from '../../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
import SkillTreeSelect from '../SkillTreeSelect'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
import { useToast } from '../../context/ToastContext'
import {
@ -26,9 +26,10 @@ import {
} from '../../utils/activeClub'
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'
import { GripVertical, FileText, BookOpen, Tags, Layers, GitBranch, Image as ImageIcon } from 'lucide-react'
import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
import PageFormEditorChrome from '../PageFormEditorChrome'
import { ExerciseFormTabBar, ExerciseFormPanel } from './ExerciseFormLayout'
import { useNavReturn } from '../../hooks/useNavReturn'
import {
EXERCISES_LIST_PATH,
@ -39,12 +40,10 @@ import {
} from '../../utils/navReturnContext'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
{ value: 'niedrig', label: 'niedrig' },
{ value: 'mittel', label: 'mittel' },
{ value: 'hoch', label: 'hoch' },
]
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity,
} from '../../constants/exerciseSkillIntensity'
const VARIANT_DIFFICULTY = [
{ value: '', label: '—' },
@ -192,6 +191,26 @@ function buildVariantPayloadFromRow(row) {
}
}
function snapshotVariantPayload(row) {
return JSON.stringify(buildVariantPayloadFromRow(row))
}
function variantDraftHasContent(draft) {
if (!draft) return false
const p = buildVariantPayloadFromRow(draft)
return (
p.variant_name.length > 0 ||
Boolean(p.description) ||
Boolean(p.execution_changes) ||
p.duration_min != null ||
p.duration_max != null ||
(Array.isArray(p.equipment_changes) && p.equipment_changes.length > 0) ||
Boolean(p.difficulty_adjustment) ||
(p.progression_level != null && p.progression_level !== 1) ||
p.prerequisite_variant_id != null
)
}
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
function ExerciseVariantFields({
row,
@ -391,8 +410,7 @@ function detailToForm(exercise) {
skills:
exercise.skills?.map((s) => ({
skill_id: s.skill_id,
is_primary: s.is_primary || false,
intensity: s.intensity || '',
intensity: normalizeExerciseSkillIntensity(s.intensity),
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
})) || [],
@ -411,71 +429,6 @@ function detailToForm(exercise) {
}
}
function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
const setPrimary = (idx) => {
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
}
const updateRow = (idx, patch) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
if (patch.is_primary === true) {
next.forEach((r, i) => {
if (i !== idx) r.is_primary = false
})
}
setRows(next)
}
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
const removeRow = (idx) => {
const next = rows.filter((_, i) => i !== idx)
if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
setRows(next)
}
return (
<div className="multi-assoc-block">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<h3>{title}</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={addRow}>
+ Eintrag
</button>
</div>
{rows.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>{emptyLabel}</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="multi-assoc-row">
<select
className="form-input"
value={row[idKey] || ''}
onChange={(e) => updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
>
<option value=""> wählen </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.icon ? `${o.icon} ` : ''}
{o.name}
{o.abbreviation ? ` (${o.abbreviation})` : ''}
</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', whiteSpace: 'nowrap' }}>
<input
type="radio"
name={`primary-${idKey}`}
checked={!!row.is_primary}
onChange={() => setPrimary(idx)}
/>
primär
</label>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeRow(idx)}>
</button>
</div>
))}
</div>
)
}
function ExerciseFormPageRoot() {
const { id: routeId } = useParams()
const navigate = useNavigate()
@ -547,7 +500,61 @@ function ExerciseFormPageRoot() {
const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false)
const [variantEditSelection, setVariantEditSelection] = useState(null)
const variantsDetailsRef = useRef(null)
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
const variantsSavedSnapshotRef = useRef({})
const exerciseFormTabs = useMemo(() => {
const tabs = [
{ id: 'stammdaten', label: 'Stammdaten', icon: FileText },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'einordnung', label: 'Einordnung', icon: Tags },
]
if (formData.exercise_kind === 'combination') {
tabs.push({ id: 'kombination', label: 'Kombination', icon: Layers })
}
if (isEdit) {
if (formData.exercise_kind !== 'combination') {
tabs.push({
id: 'varianten',
label: variants.length > 0 ? `Varianten (${variants.length})` : 'Varianten',
icon: GitBranch,
})
}
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon })
} else {
tabs.push({ id: 'varianten', label: 'Varianten', icon: GitBranch, disabled: true })
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon, disabled: true })
}
return tabs
}, [formData.exercise_kind, isEdit, variants.length])
useEffect(() => {
const allowed = new Set(exerciseFormTabs.filter((t) => !t.disabled).map((t) => t.id))
if (!allowed.has(activeFormTab)) setActiveFormTab('stammdaten')
}, [exerciseFormTabs, activeFormTab])
useEffect(() => {
if (formData.exercise_kind === 'combination' && activeFormTab === 'varianten') {
setActiveFormTab('kombination')
}
}, [formData.exercise_kind, activeFormTab])
const syncVariantsSavedSnapshot = useCallback((rows) => {
const snap = {}
for (const v of rows || []) {
if (v?.id != null) snap[v.id] = snapshotVariantPayload(v)
}
variantsSavedSnapshotRef.current = snap
}, [])
const getDirtyVariantRows = useCallback((rows) => {
return (rows || []).filter((v) => {
if (v?.id == null) return false
const saved = variantsSavedSnapshotRef.current[v.id]
if (saved == null) return true
return snapshotVariantPayload(v) !== saved
})
}, [])
const [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null)
@ -656,9 +663,11 @@ function ExerciseFormPageRoot() {
try {
const exercise = await api.getExercise(exerciseId)
if (cancelled) return
const variantRows = (exercise.variants || []).map(apiVariantToRow)
setFormData(detailToForm(exercise))
setMediaList(exercise.media || [])
setVariants((exercise.variants || []).map(apiVariantToRow))
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
@ -685,10 +694,10 @@ function ExerciseFormPageRoot() {
}, [variants, variantEditSelection])
useEffect(() => {
if (variantEditSelection != null && variantsDetailsRef.current) {
variantsDetailsRef.current.open = true
if (variantEditSelection != null && isEdit && formData.exercise_kind !== 'combination') {
setActiveFormTab('varianten')
}
}, [variantEditSelection])
}, [variantEditSelection, isEdit, formData.exercise_kind])
const updateFormField = (field, value) => {
setFormDirty(true)
@ -820,8 +829,7 @@ function ExerciseFormPageRoot() {
...formData.skills,
{
skill_id: id,
is_primary: formData.skills.length === 0,
intensity: '',
intensity: EXERCISE_SKILL_INTENSITY_DEFAULT,
required_level: '',
target_level: '',
},
@ -829,10 +837,10 @@ function ExerciseFormPageRoot() {
setSkillPick('')
}
const setSkillPrimary = (idx) => {
const removeSkillRow = (idx) => {
updateFormField(
'skills',
formData.skills.map((s, i) => ({ ...s, is_primary: i === idx })),
formData.skills.filter((_, i) => i !== idx),
)
}
@ -843,11 +851,62 @@ function ExerciseFormPageRoot() {
)
}
const removeSkillRow = (idx) => {
const next = formData.skills.filter((_, i) => i !== idx)
if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true
updateFormField('skills', next)
const refreshVariants = useCallback(async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
const rows = (ex.variants || []).map(apiVariantToRow)
syncVariantsSavedSnapshot(rows)
setVariants(rows)
}, [exerciseId, syncVariantsSavedSnapshot])
const persistPendingVariantChanges = useCallback(async () => {
if (!exerciseId) return true
const dirtyRows = getDirtyVariantRows(variants)
if (dirtyRows.length > 0) {
setVariantBusy(true)
try {
for (const row of dirtyRows) {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
toast.error(`Variante „${row.variant_name || `#${row.id}`}“: Name mindestens 3 Zeichen`)
return false
}
setVariantSavingId(row.id)
await api.updateExerciseVariant(exerciseId, row.id, payload)
}
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantSavingId(null)
setVariantBusy(false)
}
}
if (variantDraftHasContent(variantDraft)) {
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
toast.error('Variantenentwurf: Name mindestens 3 Zeichen, sonst Felder verwerfen oder ausfüllen.')
return false
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
if (created?.id != null) setVariantEditSelection(created.id)
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantBusy(false)
}
}
return true
}, [exerciseId, variantDraft, variants, getDirtyVariantRows, refreshVariants, toast])
const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
@ -855,6 +914,10 @@ function ExerciseFormPageRoot() {
toast.error('Titel mindestens 3 Zeichen')
return false
}
if (isEdit && exerciseId) {
const variantsOk = await persistPendingVariantChanges()
if (!variantsOk) return false
}
const payloadBase = {
...formData,
equipment:
@ -951,7 +1014,9 @@ function ExerciseFormPageRoot() {
}
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
const variantRows = (ex.variants || []).map(apiVariantToRow)
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setFormDirty(false)
toast.success('Gespeichert.')
if (closeAfter) goBack()
@ -973,7 +1038,7 @@ function ExerciseFormPageRoot() {
setSaving(false)
}
},
[exerciseId, formData, isEdit, navigate, location, toast, goBack],
[exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
)
const handleSubmit = useCallback(
@ -1103,12 +1168,6 @@ function ExerciseFormPageRoot() {
}
}
const refreshVariants = async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
setVariants((ex.variants || []).map(apiVariantToRow))
}
const updateVariantField = (id, patch) => {
setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
@ -1224,8 +1283,25 @@ function ExerciseFormPageRoot() {
</p>
) : null}
<div className="card">
<div className="card exercise-form-edit">
<form id="exercise-form" onSubmit={handleSubmit}>
<ExerciseFormTabBar
activeTab={activeFormTab}
onChange={setActiveFormTab}
items={exerciseFormTabs}
/>
<ExerciseFormPanel
tab="stammdaten"
activeTab={activeFormTab}
tone="basics"
title="Stammdaten"
hint={
isEdit
? 'Titel, Rahmendaten und Sichtbarkeit — Inhalt und Einordnung in den anderen Tabs.'
: 'Titel und Rahmendaten. Varianten, Medien und Progressionsgraph sind nach dem ersten Speichern verfügbar.'
}
>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
@ -1251,16 +1327,7 @@ function ExerciseFormPageRoot() {
/>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
marginBottom: '12px',
}}
>
<h3 style={{ marginTop: 0, marginBottom: '10px', fontSize: '1rem' }}>Übungstyp</h3>
<div className="exercise-form-type-box">
<div className="form-row">
<label className="form-label">Art</label>
<select
@ -1280,12 +1347,144 @@ function ExerciseFormPageRoot() {
}
: {}),
}))
if (nk === 'combination') setActiveFormTab('kombination')
}}
>
<option value="simple">Einzelübung</option>
<option value="combination">Kombinationsübung (Stationen / Pool)</option>
</select>
</div>
{formData.exercise_kind === 'combination' ? (
<p className="exercise-form-type-box__hint">
Stationen und Ablaufprofil im Tab{' '}
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('kombination')}>
Kombination
</button>
.
</p>
) : null}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={formData.duration_min}
onChange={(e) =>
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={formData.duration_max}
onChange={(e) =>
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Gruppe Min</label>
<input
type="number"
className="form-input"
value={formData.group_size_min}
onChange={(e) =>
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe Max</label>
<input
type="number"
className="form-input"
value={formData.group_size_max}
onChange={(e) =>
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
<textarea
className="form-input"
rows={3}
value={formData.equipmentLines}
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
placeholder="Matten&#10;Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={formData.visibility}
onChange={(e) => updateFormField('visibility', e.target.value)}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="draft">Entwurf</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Freigegeben</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
<div className="form-row" style={{ marginTop: '10px' }}>
<label className="form-label">Verein (Sichtbarkeit)</label>
<select
className="form-input"
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
onChange={(e) => {
const v = e.target.value
updateFormField('club_id', v === '' ? null : Number(v))
}}
>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{(c.name || '').trim() || `Verein #${c.id}`}
</option>
))}
</select>
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
</p>
</div>
) : null}
</ExerciseFormPanel>
<ExerciseFormPanel
tab="kombination"
activeTab={activeFormTab}
tone="combo"
title="Kombinationsübung"
hint="Stationen, Übungs-Pools und globales Ablaufprofil für Coach und Planung."
>
{formData.exercise_kind === 'combination' ? (
<>
<div className="form-row">
@ -1699,9 +1898,20 @@ function ExerciseFormPageRoot() {
/>
</div>
</>
) : null}
</div>
) : (
<p className="exercise-form-panel__hint" style={{ margin: 0 }}>
Wähle unter <strong>Stammdaten</strong> die Art Kombinationsübung, um Stationen zu planen.
</p>
)}
</ExerciseFormPanel>
<ExerciseFormPanel
tab="anleitung"
activeTab={activeFormTab}
tone="guide"
title="Anleitung"
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
>
<div className="form-row">
<label className="form-label">Ziel *</label>
<RichTextEditor
@ -1753,79 +1963,28 @@ function ExerciseFormPageRoot() {
onExerciseMediaListChanged={refreshMedia}
/>
</div>
</ExerciseFormPanel>
<div className="form-row">
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
<textarea
className="form-input"
rows={3}
value={formData.equipmentLines}
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
placeholder="Matten&#10;Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={formData.duration_min}
onChange={(e) =>
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={formData.duration_max}
onChange={(e) =>
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Gruppe Min</label>
<input
type="number"
className="form-input"
value={formData.group_size_min}
onChange={(e) =>
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe Max</label>
<input
type="number"
className="form-input"
value={formData.group_size_max}
onChange={(e) =>
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<MultiAssocBlock
title="Fokusbereiche (0…n, ein „primär“)"
<ExerciseFormPanel
tab="einordnung"
activeTab={activeFormTab}
tone="classify"
title="Einordnung"
hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
>
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
<div className="exercise-form-meta-panel__grid">
<ExerciseCatalogAssocEditor
title="Fokusbereiche"
rows={formData.focus_areas_multi}
setRows={(r) => updateFormField('focus_areas_multi', r)}
options={focusAreas}
idKey="focus_area_id"
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
emptyLabel="Optional — „+ Eintrag“."
/>
<MultiAssocBlock
title="Stilrichtungen (0…n, z. B. Shotokan)"
<ExerciseCatalogAssocEditor
title="Stilrichtungen"
rows={formData.training_styles_multi}
setRows={(r) => updateFormField('training_styles_multi', r)}
options={styleDirections.map((sd) => ({
@ -1833,172 +1992,49 @@ function ExerciseFormPageRoot() {
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
}))}
idKey="training_style_id"
emptyLabel="Keine Stilrichtung gewählt."
emptyLabel="Optional."
/>
<MultiAssocBlock
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
<ExerciseCatalogAssocEditor
title="Trainingsstil"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Kein Trainingsstil gewählt."
emptyLabel="Optional."
/>
<MultiAssocBlock
title="Zielgruppen (0…n)"
<ExerciseCatalogAssocEditor
title="Zielgruppen"
rows={formData.target_groups_multi}
setRows={(r) => updateFormField('target_groups_multi', r)}
options={targetGroups}
idKey="target_group_id"
emptyLabel="Keine Zielgruppe gewählt."
emptyLabel="Optional."
showPrimary={false}
/>
</div>
<div className="form-row">
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
<SkillTreeSelect
value={skillPick}
onChange={setSkillPick}
skills={skillsCatalog}
excludeIds={formData.skills.map((s) => s.skill_id)}
placeholder="Fähigkeit wählen…"
<ExerciseSkillsEditor
rows={formData.skills}
skillsCatalog={skillsCatalog}
skillPick={skillPick}
onSkillPickChange={setSkillPick}
onAdd={addSkillRow}
onRemove={removeSkillRow}
onUpdateField={updateSkillField}
/>
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
Hinzufügen
</button>
</div>
{formData.skills.map((row, idx) => {
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
return (
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
<div>
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
{sk ? (
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
{skillCatalogPathLabel(sk)}
</span>
) : null}
</div>
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="radio"
name="skill-primary"
checked={row.is_primary}
onChange={() => setSkillPrimary(idx)}
/>
primär
</label>
<select
className="form-input"
value={row.intensity || ''}
onChange={(e) => updateSkillField(idx, 'intensity', e.target.value)}
>
{INTENSITY_OPTIONS.map((o) => (
<option key={o.value || 'i'} value={o.value}>
{o.label}
</option>
))}
</select>
<select
className="form-input"
value={row.required_level || ''}
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`r-${o.value}`} value={o.value}>
von {o.label}
</option>
))}
</select>
<select
className="form-input"
value={row.target_level || ''}
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`t-${o.value}`} value={o.value}>
bis {o.label}
</option>
))}
</select>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSkillRow(idx)}>
Entf.
</button>
</div>
)
})}
</div>
</section>
</ExerciseFormPanel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={formData.visibility}
onChange={(e) => updateFormField('visibility', e.target.value)}
{isEdit && formData.exercise_kind !== 'combination' ? (
<ExerciseFormPanel
tab="varianten"
activeTab={activeFormTab}
tone="variants"
title="Übungsvarianten"
hint="Pro Durchgang eine Variante. Änderungen werden mit Speichern in der Aktionsleiste mitgesichert."
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="draft">Entwurf</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Freigegeben</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
<div className="form-row" style={{ marginTop: '10px' }}>
<label className="form-label">Verein (Sichtbarkeit)</label>
<select
className="form-input"
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
onChange={(e) => {
const v = e.target.value
updateFormField('club_id', v === '' ? null : Number(v))
}}
>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{(c.name || '').trim() || `Verein #${c.id}`}
</option>
))}
</select>
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
</p>
</div>
) : null}
</form>
</div>
{isEdit && formData.exercise_kind !== 'combination' && (
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Übungsvarianten</span>
<span className="exercise-variants-summary__badge">
{variants.length === 0
? 'keine'
: `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
</span>
</summary>
<div className="exercise-variants-details__body">
<p className="exercise-variants-hint">
Pro Durchgang nur eine Variante bearbeiten weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
Training; Voraussetzung nutzt ihr später für Progressions-Serien.
</p>
{variants.length > 0 && (
<div className="form-row">
<label className="form-label" htmlFor="variant-edit-select">
@ -2106,12 +2142,13 @@ function ExerciseFormPageRoot() {
</button>
<button
type="button"
className="btn btn-primary"
className="btn btn-secondary"
style={{ marginLeft: 'auto', fontSize: '12px' }}
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
onClick={() => saveVariantRow(selectedVariantForEdit)}
title="Optional — Änderungen werden auch über die Aktionsleiste gespeichert"
>
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Variante jetzt speichern'}
</button>
<button
type="button"
@ -2140,25 +2177,19 @@ function ExerciseFormPageRoot() {
Wähle eine Variante zum Bearbeiten oder Neue Variante anlegen.
</p>
)}
</div>
</details>
)}
</ExerciseFormPanel>
) : null}
{isEdit && formData.exercise_kind !== 'combination' && (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Progressionsgraph</span>
<span className="exercise-variants-summary__badge">Übung Übung</span>
</summary>
<div className="exercise-variants-details__body">
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
</details>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
{isEdit ? (
<ExerciseFormPanel
tab="medien"
activeTab={activeFormTab}
tone="media"
title="Medien & Erweiterungen"
hint="Verknüpfte Dateien, Progressionsgraph und Medienarchiv."
>
<div className="exercise-form-subsection exercise-form-subsection--media">
<h4 className="exercise-form-subsection__title">Medien</h4>
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}>
Neue Uploads oder Embeds über die Textfeld-Symbolleiste (Medien im Text / Embed im Text). Hier
verwaltest du Verknüpfungen Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
@ -2302,6 +2333,15 @@ function ExerciseFormPageRoot() {
Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
</p>
</div>
{formData.exercise_kind !== 'combination' ? (
<div className="exercise-form-subsection exercise-form-subsection--graph">
<h4 className="exercise-form-subsection__title">Progressionsgraph</h4>
<p className="exercise-form-subsection__hint">Übergänge zu anderen Übungen für Progressions-Serien.</p>
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
) : null}
{archiveOpen && (
<div
role="dialog"
@ -2442,8 +2482,11 @@ function ExerciseFormPageRoot() {
onClose={() => setReportTarget(null)}
/>
)}
</ExerciseFormPanel>
) : null}
</form>
</div>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}

View File

@ -0,0 +1,134 @@
import React from 'react'
import SkillTreeSelect from '../SkillTreeSelect'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
EXERCISE_SKILL_INTENSITY_OPTIONS,
normalizeExerciseSkillIntensity,
} from '../../constants/exerciseSkillIntensity'
export default function ExerciseSkillsEditor({
rows,
skillsCatalog,
skillPick,
onSkillPickChange,
onAdd,
onRemove,
onUpdateField,
}) {
return (
<div className="exercise-meta-block exercise-meta-block--skills">
<div className="exercise-meta-block__head">
<h4 className="exercise-meta-block__title">Fähigkeiten</h4>
</div>
<p className="exercise-meta-block__hint">Je Übung mehrere Fähigkeiten mit Intensität und Niveau (vonbis).</p>
<div className="exercise-skills-add">
<SkillTreeSelect
value={skillPick}
onChange={onSkillPickChange}
skills={skillsCatalog}
excludeIds={rows.map((s) => s.skill_id)}
placeholder="Fähigkeit wählen…"
/>
<button type="button" className="btn btn-secondary" onClick={onAdd}>
Hinzufügen
</button>
</div>
{rows.length === 0 ? (
<p className="exercise-meta-block__empty">Noch keine Fähigkeit zugeordnet.</p>
) : (
<ul className="exercise-skills-list">
{rows.map((row, idx) => {
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
const intensity = normalizeExerciseSkillIntensity(row.intensity)
return (
<li key={`${row.skill_id}-${idx}`} className="exercise-skill-chip">
<div className="exercise-skill-chip__identity">
<span className="exercise-skill-chip__name">{sk?.name || `Skill #${row.skill_id}`}</span>
{sk ? (
<span className="exercise-skill-chip__path" title={skillCatalogPathLabel(sk)}>
{skillCatalogPathLabel(sk)}
</span>
) : null}
</div>
<div className="exercise-skill-chip__controls">
<div className="exercise-skill-chip__field">
<span className="exercise-skill-chip__caption">Intensität</span>
<div className="exercise-intensity-segment" role="radiogroup" aria-label="Intensität">
{EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={intensity === o.value}
className={`exercise-intensity-segment__btn${
intensity === o.value ? ' exercise-intensity-segment__btn--active' : ''
}`}
onClick={() => onUpdateField(idx, 'intensity', o.value)}
>
{o.label}
</button>
))}
</div>
</div>
<div className="exercise-skill-chip__levels">
<label className="exercise-skill-chip__field">
<span className="exercise-skill-chip__caption">von</span>
<select
className="form-input exercise-skill-level-select"
value={row.required_level || ''}
title="Mindest-Niveau"
onChange={(e) => onUpdateField(idx, 'required_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`r-${o.value}`} value={o.value} title={o.label}>
{o.level != null ? o.level : ''}
</option>
))}
</select>
</label>
<span className="exercise-skill-chip__dash" aria-hidden>
</span>
<label className="exercise-skill-chip__field">
<span className="exercise-skill-chip__caption">bis</span>
<select
className="form-input exercise-skill-level-select"
value={row.target_level || ''}
title="Ziel-Niveau"
onChange={(e) => onUpdateField(idx, 'target_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`t-${o.value}`} value={o.value} title={o.label}>
{o.level != null ? o.level : ''}
</option>
))}
</select>
</label>
</div>
<button
type="button"
className="exercise-skill-chip__remove"
aria-label="Fähigkeit entfernen"
title="Entfernen"
onClick={() => onRemove(idx)}
>
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
)
}
export { EXERCISE_SKILL_INTENSITY_DEFAULT }

View File

@ -0,0 +1,21 @@
/** Kanonische Intensität Übung ↔ Fähigkeit (Scoring + Formular). */
export const EXERCISE_SKILL_INTENSITY_DEFAULT = 'mittel'
export const EXERCISE_SKILL_INTENSITY_OPTIONS = [
{ value: 'niedrig', label: 'niedrig' },
{ value: 'mittel', label: 'mittel' },
{ value: 'hoch', label: 'hoch' },
]
export function normalizeExerciseSkillIntensity(value) {
const key = String(value ?? '').trim().toLowerCase()
if (key === 'niedrig' || key === 'low') return 'niedrig'
if (key === 'hoch' || key === 'high') return 'hoch'
return 'mittel'
}
export function formatExerciseSkillIntensityLabel(value) {
const n = normalizeExerciseSkillIntensity(value)
return EXERCISE_SKILL_INTENSITY_OPTIONS.find((o) => o.value === n)?.label || n
}

View File

@ -12,6 +12,7 @@ import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaS
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import ExercisePeekModal from '../components/ExercisePeekModal'
import { formatSkillLevelSlug } from '../constants/skillLevels'
import { formatExerciseSkillIntensityLabel } from '../constants/exerciseSkillIntensity'
function TagRow({ exercise }) {
const tags = []
@ -244,9 +245,9 @@ function ExerciseDetailPage() {
const lvl =
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
return (
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
<span key={s.id} className="exercise-tag">
{s.skill_name}
{s.intensity ? ` · ${s.intensity}` : ''}
{` · ${formatExerciseSkillIntensityLabel(s.intensity)}`}
{lvl}
</span>
)