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:** **Skills-System:**
- [x] Hierarchisches Schema, Fokusbereich-Zuordnung, Exercise-Skill mit Levels - [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:** **Admin-UI:**

View File

@ -407,10 +407,9 @@ skill_level_definitions (
- Reaktion (Koordination, target_level: 2, intensity: mittel) - Reaktion (Koordination, target_level: 2, intensity: mittel)
**Attribute pro Fähigkeitsbezug:** **Attribute pro Fähigkeitsbezug:**
- is_primary (Haupt- oder Nebenfähigkeit) - `intensity` — Nutzeneinschätzung: **niedrig | mittel | hoch** (Standard **mittel**)
- intensity (niedrig/mittel/hoch) - `required_level` / `target_level` — Stufen-Spanne (kanonische Slugs basis … optimierung)
- required_level (Voraussetzung, 1-5) - `is_primary` — Legacy-Feld; **nicht mehr in der UI**, beim Speichern immer false; Scoring ignoriert es
- target_level (Ziel-Level, 1-5)
**🆕 Fokusbereich-Filterung:** **🆕 Fokusbereich-Filterung:**
- Bei Übungen mit Fokusbereich "Karate" sollten primär KARATE-Fähigkeiten zugeordnet werden - 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**). **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) ### 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. **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) - [ ] Level-Definitionen aus Fähigkeitsmatrix extrahieren (optional)
- [ ] Skills-Beschreibungen aus Wiki importieren (Migration 024) - [ ] Skills-Beschreibungen aus Wiki importieren (Migration 024)
- [ ] Admin-UI für Fähigkeiten-Kategorien (CRUD) - [ ] 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) - [ ] Reifegradmodelle definieren (Kombination Fokusbereich + Stil + Zielgruppe)
- [ ] KI-Unterstützung für Trainingsplanung (basierend auf Fähigkeiten-Level) - [ ] KI-Unterstützung für Trainingsplanung (basierend auf Fähigkeiten-Level)
--- ---
**Letzte Aktualisierung:** 2026-04-27 **Letzte Aktualisierung:** 2026-05-20
**Verantwortlich:** Claude Code **Verantwortlich:** Claude Code
**Review:** Pending **Review:** Pending

View File

@ -1,6 +1,6 @@
# Gelieferte Features & technische Basis (Q2 2026) # 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**) **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/`. 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 | | Thema | Dokument |
|--------|----------| |--------|----------|
| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` | | Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` |
| Fähigkeiten-Scoring Planung | `technical/SKILL_SCORING_SPEC.md` |
| API Übungen | `technical/EXERCISES_API_SPEC.md` | | API Übungen | `technical/EXERCISES_API_SPEC.md` |
| Domänenmodell | `functional/DOMAIN_MODEL.md` | | Domänenmodell | `functional/DOMAIN_MODEL.md` |
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` | | Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |

View File

@ -1,12 +1,33 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3) # Gewichtetes Fähigkeiten-Scoring (Phase 3)
**Stand:** 2026-05-20 **Stand:** 2026-05-20
**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala) **Status:** Variante A (regelbasiert) umgesetzt — **v1.3** (Peer-Kontext getrennt + Listen-Filter)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles` **Modul:** `backend/skill_scoring.py`, Router `backend/routers/skill_profiles.py`
## Ziel ## 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 ## 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`). 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): 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 - **Mittelpunkt** = durchschnittliche Stufe
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,961,20 - 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 ### Bewusst nicht im Scoring
| Feld | Grund | | 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 | | `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt | | `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**) | Feld | Bedeutung |
- `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) | `weight` / `score` | Absolutes **Trainingsgewicht** (gewichtete Minuten) — über alle Fähigkeiten eines Artefakts vergleichbar |
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade | `share_percent` | Anteil am `total_weight` **innerhalb dieses Artefakts** (summiert 100 %) — sekundär |
- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht) | `by_main_category[]` | Je Unterkategorie `top_skill` (stärkste Fähigkeit nach Gewicht) |
- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs | `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 ## API
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
|---------|------|--------------| |---------|------|--------------|
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` | | 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` | | GET | `/api/training-modules/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Module) |
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` | | 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` | | 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 ## UI
- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session ### Bearbeitung (Vollprofil)
- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
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 ## 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) - 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 - 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 ## 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. | | `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). | | `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). | | `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). **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) **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` **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 ## Ziele
@ -12,7 +12,7 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
| Phase | Inhalt | Status | | 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 | | **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 | | **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) | | **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` | > | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` | > | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.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`** | > | Handover / nächste Session | **`docs/HANDOVER.md`** |
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** | > | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.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) ### 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-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-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`. - 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 s
return _LEGACY_SKILL_LEVEL_SLUG.get(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"))) MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
MAX_EXERCISE_MEDIA = 10 MAX_EXERCISE_MEDIA = 10
# Upload-Limits (Übungs-Medien): Trainer wie bisher kleiner; Admin/Superadmin höheres Limit für große Videos # 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 FROM exercise_skills es
JOIN skills s ON es.skill_id = s.id JOIN skills s ON es.skill_id = s.id
WHERE es.exercise_id = %s WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, s.name""", ORDER BY s.name""",
(exercise_id,) (exercise_id,)
) )
exercise["skills"] = [r2d(r) for r in cur.fetchall()] exercise["skills"] = [r2d(r) for r in cur.fetchall()]
for sk in exercise["skills"]: for sk in exercise["skills"]:
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level")) sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_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) # Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
cur.execute( cur.execute(
@ -1223,8 +1244,8 @@ def assign_exercise_relations(
( (
exercise_id, exercise_id,
skill["skill_id"], skill["skill_id"],
skill.get("is_primary", False), False,
skill.get("intensity"), normalize_exercise_skill_intensity(skill.get("intensity")),
normalize_exercise_skill_level(skill.get("required_level")), normalize_exercise_skill_level(skill.get("required_level")),
normalize_exercise_skill_level(skill.get("target_level")), normalize_exercise_skill_level(skill.get("target_level")),
skill.get("ai_suggested", False), 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 auth import require_auth
from smw_client import SmwClient, SmwClientError 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 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__) 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) VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
target_level = EXCLUDED.target_level, target_level = EXCLUDED.target_level,
is_primary = EXCLUDED.is_primary""", required_level = EXCLUDED.required_level,
intensity = EXCLUDED.intensity,
is_primary = false""",
( (
exercise_id, exercise_id,
sid, sid,
assignment.get("target_level"), assignment.get("target_level"),
assignment.get("required_level"), assignment.get("required_level"),
assignment.get("intensity"), normalize_exercise_skill_intensity(assignment.get("intensity")),
assignment.get("is_primary", False), False,
) )
) )
conn.commit() conn.commit()

View File

@ -377,8 +377,8 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
"skill_name": skill_name, "skill_name": skill_name,
"target_level": target_slug, "target_level": target_slug,
"required_level": None, "required_level": None,
"intensity": None, "intensity": "mittel",
"is_primary": idx == 0, "is_primary": False,
}) })
return assignments 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. - **Globaler Fähigkeitskatalog** mit hierarchischer Struktur (Kategorien, Stufen); Zuordnung zu Übungen.
- **Trainingsmethoden-Katalog** (bestehende Domäne). - **Trainingsmethoden-Katalog** (bestehende Domäne).
- **Admin/Katalog-Pflege** für Fokusbereiche, Stile, Zielgruppen und Zusammenhänge (Plattform-Admin-Bereich). - **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) ### 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`. - **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“). - **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. - **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). - **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). - **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). - **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 | | 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-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 | 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`. | | 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 # 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` **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**. 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` | | **Umsetzungsplan** (Module/Kombination/Coach) | `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` |
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` | | Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.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 | | **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** | | **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`. - **`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) ## 3. Trainingsrahmenprogramm & PlanungsBlueprint (kurz)

View File

@ -4,6 +4,7 @@
import { stripHtmlToText } from '../utils/htmlUtils' import { stripHtmlToText } from '../utils/htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi' import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { normalizeExerciseSkillIntensity } from '../constants/exerciseSkillIntensity'
import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js' 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. */ /** 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: [], age_groups: [],
skills: (formData.skills || []).map((s) => ({ skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id, skill_id: s.skill_id,
is_primary: !!s.is_primary, intensity: normalizeExerciseSkillIntensity(s.intensity),
intensity: s.intensity || null,
required_level: s.required_level || null, required_level: s.required_level || null,
target_level: s.target_level || null, target_level: s.target_level || null,
})), })),

View File

@ -6548,6 +6548,367 @@ html.modal-scroll-locked .app-main {
min-width: 0; 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 { .skills-editor-row {
display: grid; display: grid;
grid-template-columns: 1fr auto; 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, buildExerciseMediaDragPayload,
} from '../../utils/exerciseInlineMediaRefs' } from '../../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll' import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
import SkillTreeSelect from '../SkillTreeSelect' import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree' import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels' import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext' import { useAuth } from '../../context/AuthContext'
import { useToast } from '../../context/ToastContext' import { useToast } from '../../context/ToastContext'
import { import {
@ -26,9 +26,10 @@ import {
} from '../../utils/activeClub' } from '../../utils/activeClub'
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes' import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes'
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi' 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 UnsavedChangesPrompt from '../UnsavedChangesPrompt'
import PageFormEditorChrome from '../PageFormEditorChrome' import PageFormEditorChrome from '../PageFormEditorChrome'
import { ExerciseFormTabBar, ExerciseFormPanel } from './ExerciseFormLayout'
import { useNavReturn } from '../../hooks/useNavReturn' import { useNavReturn } from '../../hooks/useNavReturn'
import { import {
EXERCISES_LIST_PATH, EXERCISES_LIST_PATH,
@ -39,12 +40,10 @@ import {
} from '../../utils/navReturnContext' } from '../../utils/navReturnContext'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
const INTENSITY_OPTIONS = [ import {
{ value: '', label: '—' }, EXERCISE_SKILL_INTENSITY_DEFAULT,
{ value: 'niedrig', label: 'niedrig' }, normalizeExerciseSkillIntensity,
{ value: 'mittel', label: 'mittel' }, } from '../../constants/exerciseSkillIntensity'
{ value: 'hoch', label: 'hoch' },
]
const VARIANT_DIFFICULTY = [ const VARIANT_DIFFICULTY = [
{ value: '', label: '—' }, { 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“. */ /** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
function ExerciseVariantFields({ function ExerciseVariantFields({
row, row,
@ -391,8 +410,7 @@ function detailToForm(exercise) {
skills: skills:
exercise.skills?.map((s) => ({ exercise.skills?.map((s) => ({
skill_id: s.skill_id, skill_id: s.skill_id,
is_primary: s.is_primary || false, intensity: normalizeExerciseSkillIntensity(s.intensity),
intensity: s.intensity || '',
required_level: normalizeSkillLevelSlug(s.required_level), required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_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() { function ExerciseFormPageRoot() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@ -547,7 +500,61 @@ function ExerciseFormPageRoot() {
const [variantSavingId, setVariantSavingId] = useState(null) const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false) const [variantBusy, setVariantBusy] = useState(false)
const [variantEditSelection, setVariantEditSelection] = useState(null) 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 [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null) const [mediaSavingId, setMediaSavingId] = useState(null)
@ -656,9 +663,11 @@ function ExerciseFormPageRoot() {
try { try {
const exercise = await api.getExercise(exerciseId) const exercise = await api.getExercise(exerciseId)
if (cancelled) return if (cancelled) return
const variantRows = (exercise.variants || []).map(apiVariantToRow)
setFormData(detailToForm(exercise)) setFormData(detailToForm(exercise))
setMediaList(exercise.media || []) setMediaList(exercise.media || [])
setVariants((exercise.variants || []).map(apiVariantToRow)) setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setVariantDraft(emptyVariantDraft()) setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null) setVariantEditSelection(null)
setFormDirty(false) setFormDirty(false)
@ -685,10 +694,10 @@ function ExerciseFormPageRoot() {
}, [variants, variantEditSelection]) }, [variants, variantEditSelection])
useEffect(() => { useEffect(() => {
if (variantEditSelection != null && variantsDetailsRef.current) { if (variantEditSelection != null && isEdit && formData.exercise_kind !== 'combination') {
variantsDetailsRef.current.open = true setActiveFormTab('varianten')
} }
}, [variantEditSelection]) }, [variantEditSelection, isEdit, formData.exercise_kind])
const updateFormField = (field, value) => { const updateFormField = (field, value) => {
setFormDirty(true) setFormDirty(true)
@ -820,8 +829,7 @@ function ExerciseFormPageRoot() {
...formData.skills, ...formData.skills,
{ {
skill_id: id, skill_id: id,
is_primary: formData.skills.length === 0, intensity: EXERCISE_SKILL_INTENSITY_DEFAULT,
intensity: '',
required_level: '', required_level: '',
target_level: '', target_level: '',
}, },
@ -829,10 +837,10 @@ function ExerciseFormPageRoot() {
setSkillPick('') setSkillPick('')
} }
const setSkillPrimary = (idx) => { const removeSkillRow = (idx) => {
updateFormField( updateFormField(
'skills', '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 refreshVariants = useCallback(async () => {
const next = formData.skills.filter((_, i) => i !== idx) if (!exerciseId) return
if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true const ex = await api.getExercise(exerciseId)
updateFormField('skills', next) 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( const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
@ -855,6 +914,10 @@ function ExerciseFormPageRoot() {
toast.error('Titel mindestens 3 Zeichen') toast.error('Titel mindestens 3 Zeichen')
return false return false
} }
if (isEdit && exerciseId) {
const variantsOk = await persistPendingVariantChanges()
if (!variantsOk) return false
}
const payloadBase = { const payloadBase = {
...formData, ...formData,
equipment: equipment:
@ -951,7 +1014,9 @@ function ExerciseFormPageRoot() {
} }
const ex = await api.getExercise(exerciseId) const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || []) setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow)) const variantRows = (ex.variants || []).map(apiVariantToRow)
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setFormDirty(false) setFormDirty(false)
toast.success('Gespeichert.') toast.success('Gespeichert.')
if (closeAfter) goBack() if (closeAfter) goBack()
@ -973,7 +1038,7 @@ function ExerciseFormPageRoot() {
setSaving(false) setSaving(false)
} }
}, },
[exerciseId, formData, isEdit, navigate, location, toast, goBack], [exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
) )
const handleSubmit = useCallback( 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) => { const updateVariantField = (id, patch) => {
setFormDirty(true) setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v))) setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
@ -1224,8 +1283,25 @@ function ExerciseFormPageRoot() {
</p> </p>
) : null} ) : null}
<div className="card"> <div className="card exercise-form-edit">
<form id="exercise-form" onSubmit={handleSubmit}> <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"> <div className="form-row">
<label className="form-label">Titel *</label> <label className="form-label">Titel *</label>
<input <input
@ -1251,16 +1327,7 @@ function ExerciseFormPageRoot() {
/> />
</div> </div>
<div <div className="exercise-form-type-box">
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="form-row"> <div className="form-row">
<label className="form-label">Art</label> <label className="form-label">Art</label>
<select <select
@ -1280,12 +1347,144 @@ function ExerciseFormPageRoot() {
} }
: {}), : {}),
})) }))
if (nk === 'combination') setActiveFormTab('kombination')
}} }}
> >
<option value="simple">Einzelübung</option> <option value="simple">Einzelübung</option>
<option value="combination">Kombinationsübung (Stationen / Pool)</option> <option value="combination">Kombinationsübung (Stationen / Pool)</option>
</select> </select>
</div> </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' ? ( {formData.exercise_kind === 'combination' ? (
<> <>
<div className="form-row"> <div className="form-row">
@ -1699,9 +1898,20 @@ function ExerciseFormPageRoot() {
/> />
</div> </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"> <div className="form-row">
<label className="form-label">Ziel *</label> <label className="form-label">Ziel *</label>
<RichTextEditor <RichTextEditor
@ -1753,252 +1963,78 @@ function ExerciseFormPageRoot() {
onExerciseMediaListChanged={refreshMedia} onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
</ExerciseFormPanel>
<div className="form-row"> <ExerciseFormPanel
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label> tab="einordnung"
<textarea activeTab={activeFormTab}
className="form-input" tone="classify"
rows={3} title="Einordnung"
value={formData.equipmentLines} hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
onChange={(e) => updateFormField('equipmentLines', e.target.value)} >
placeholder="Matten&#10;Pratzen" <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="Optional — „+ Eintrag“."
/>
<ExerciseCatalogAssocEditor
title="Stilrichtungen"
rows={formData.training_styles_multi}
setRows={(r) => updateFormField('training_styles_multi', r)}
options={styleDirections.map((sd) => ({
...sd,
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
}))}
idKey="training_style_id"
emptyLabel="Optional."
/>
<ExerciseCatalogAssocEditor
title="Trainingsstil"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Optional."
/>
<ExerciseCatalogAssocEditor
title="Zielgruppen"
rows={formData.target_groups_multi}
setRows={(r) => updateFormField('target_groups_multi', r)}
options={targetGroups}
idKey="target_group_id"
emptyLabel="Optional."
showPrimary={false}
/>
</div>
<ExerciseSkillsEditor
rows={formData.skills}
skillsCatalog={skillsCatalog}
skillPick={skillPick}
onSkillPickChange={setSkillPick}
onAdd={addSkillRow}
onRemove={removeSkillRow}
onUpdateField={updateSkillField}
/> />
</div> </section>
</ExerciseFormPanel>
<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“)"
rows={formData.focus_areas_multi}
setRows={(r) => updateFormField('focus_areas_multi', r)}
options={focusAreas}
idKey="focus_area_id"
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
/>
<MultiAssocBlock
title="Stilrichtungen (0…n, z. B. Shotokan)"
rows={formData.training_styles_multi}
setRows={(r) => updateFormField('training_styles_multi', r)}
options={styleDirections.map((sd) => ({
...sd,
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
}))}
idKey="training_style_id"
emptyLabel="Keine Stilrichtung gewählt."
/>
<MultiAssocBlock
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Kein Trainingsstil gewählt."
/>
<MultiAssocBlock
title="Zielgruppen (0…n)"
rows={formData.target_groups_multi}
setRows={(r) => updateFormField('target_groups_multi', r)}
options={targetGroups}
idKey="target_group_id"
emptyLabel="Keine Zielgruppe gewählt."
/>
<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…"
/>
<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>
<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}
</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>
{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."
>
{variants.length > 0 && ( {variants.length > 0 && (
<div className="form-row"> <div className="form-row">
<label className="form-label" htmlFor="variant-edit-select"> <label className="form-label" htmlFor="variant-edit-select">
@ -2106,12 +2142,13 @@ function ExerciseFormPageRoot() {
</button> </button>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-secondary"
style={{ marginLeft: 'auto', fontSize: '12px' }} style={{ marginLeft: 'auto', fontSize: '12px' }}
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy} disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
onClick={() => saveVariantRow(selectedVariantForEdit)} 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>
<button <button
type="button" type="button"
@ -2140,25 +2177,19 @@ function ExerciseFormPageRoot() {
Wähle eine Variante zum Bearbeiten oder Neue Variante anlegen. Wähle eine Variante zum Bearbeiten oder Neue Variante anlegen.
</p> </p>
)} )}
</div> </ExerciseFormPanel>
</details> ) : null}
)}
{isEdit && formData.exercise_kind !== 'combination' && ( {isEdit ? (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}> <ExerciseFormPanel
<summary className="exercise-variants-summary"> tab="medien"
<span className="exercise-variants-summary__title">Progressionsgraph</span> activeTab={activeFormTab}
<span className="exercise-variants-summary__badge">Übung Übung</span> tone="media"
</summary> title="Medien & Erweiterungen"
<div className="exercise-variants-details__body"> hint="Verknüpfte Dateien, Progressionsgraph und Medienarchiv."
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} /> >
</div> <div className="exercise-form-subsection exercise-form-subsection--media">
</details> <h4 className="exercise-form-subsection__title">Medien</h4>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}> <p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}>
Neue Uploads oder Embeds über die Textfeld-Symbolleiste (Medien im Text / Embed im Text). Hier 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 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 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. Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
</p> </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 && ( {archiveOpen && (
<div <div
role="dialog" role="dialog"
@ -2442,8 +2482,11 @@ function ExerciseFormPageRoot() {
onClose={() => setReportTarget(null)} onClose={() => setReportTarget(null)}
/> />
)} )}
</div> </ExerciseFormPanel>
)} ) : null}
</form>
</div>
<ExercisePickerModal <ExercisePickerModal
open={comboStationPickerIx !== null} 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 CombinationPlanBracket from '../components/CombinationPlanBracket'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import { formatSkillLevelSlug } from '../constants/skillLevels' import { formatSkillLevelSlug } from '../constants/skillLevels'
import { formatExerciseSkillIntensityLabel } from '../constants/exerciseSkillIntensity'
function TagRow({ exercise }) { function TagRow({ exercise }) {
const tags = [] const tags = []
@ -244,9 +245,9 @@ function ExerciseDetailPage() {
const lvl = const lvl =
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : '' rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
return ( 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.skill_name}
{s.intensity ? ` · ${s.intensity}` : ''} {` · ${formatExerciseSkillIntensityLabel(s.intensity)}`}
{lvl} {lvl}
</span> </span>
) )