From 732b322c5236928e6c696a41947e19ddd66d2771 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 20 May 2026 16:42:25 +0200
Subject: [PATCH 01/10] Implement Phase 3 Features for Skill Profiles and
Discovery
- Updated the framework program documentation to reflect the completion of Phase 3 v1.0, including new skill scoring and API enhancements.
- Added new API endpoints for skill profile retrieval and suggestions, improving the ability to aggregate and display skills based on training data.
- Introduced new UI components for skill profiles and discovery in the frontend, enhancing user interaction with training frameworks and skills.
- Updated version information to 0.8.151, reflecting the addition of skill profiles and related features.
---
.claude/docs/technical/SKILL_SCORING_SPEC.md | 65 ++++
...EWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md | 23 +-
backend/main.py | 3 +-
backend/routers/skill_profiles.py | 354 ++++++++++++++++++
backend/skill_scoring.py | 345 +++++++++++++++++
backend/tests/test_skill_scoring.py | 60 +++
backend/version.py | 12 +-
frontend/src/api/skillProfiles.js | 26 ++
frontend/src/app.css | 276 ++++++++++++++
.../ExerciseProgressionGraphPanel.jsx | 38 ++
.../components/skills/SkillDiscoveryPanel.jsx | 163 ++++++++
.../components/skills/SkillProfilePanel.jsx | 189 ++++++++++
frontend/src/pages/SkillsPage.jsx | 4 +
.../TrainingFrameworkProgramEditPage.jsx | 43 +++
frontend/src/pages/TrainingModuleEditPage.jsx | 40 ++
frontend/src/utils/api.js | 2 +
16 files changed, 1626 insertions(+), 17 deletions(-)
create mode 100644 .claude/docs/technical/SKILL_SCORING_SPEC.md
create mode 100644 backend/routers/skill_profiles.py
create mode 100644 backend/skill_scoring.py
create mode 100644 backend/tests/test_skill_scoring.py
create mode 100644 frontend/src/api/skillProfiles.js
create mode 100644 frontend/src/components/skills/SkillDiscoveryPanel.jsx
create mode 100644 frontend/src/components/skills/SkillProfilePanel.jsx
diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
new file mode 100644
index 0000000..7a138ba
--- /dev/null
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -0,0 +1,65 @@
+# Gewichtetes Fähigkeiten-Scoring (Phase 3)
+
+**Stand:** 2026-05-20
+**Status:** Variante A (regelbasiert) umgesetzt — v1.0
+**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
+
+## 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.
+
+## Datenquellen
+
+| Artefakt | Übungen aus |
+|----------|-------------|
+| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` |
+| Rahmenprogramm (pro Slot) | Blueprint einer Session |
+| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) |
+| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) |
+
+Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
+
+## Gewichtungsformel (v1.0)
+
+Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
+
+1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
+2. Pro verknüpfte Fähigkeit der Übung:
+ - `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor`
+ - Link-Faktor = 1.0 × (1.5 wenn `is_primary`) × Intensität (`niedrig` 0.85, `mittel` 1.0, `hoch` 1.2) × Entwicklungsbeitrag (`low` 0.9, `medium` 1.0, `high` 1.15)
+
+Aggregation:
+
+- Summe pro `skill_id` → `weight`
+- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil)
+
+## API
+
+| Methode | Pfad | Beschreibung |
+|---------|------|--------------|
+| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` |
+| GET | `/api/training-modules/{id}/skill-profile` | `overall` |
+| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` |
+| GET | `/api/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`).
+
+## UI
+
+- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session
+- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
+- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
+- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
+
+Profil wird nach **Speichern** neu geladen (`skillProfileTick`).
+
+## Grenzen / später
+
+- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index
+- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
+- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0
+- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
+
+## Tests
+
+- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score
diff --git a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md
index d9d8663..312f74a 100644
--- a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md
+++ b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md
@@ -1,7 +1,7 @@
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
**Stand:** 2026-05-20
-**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen
+**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen
## Phase 1 (umgesetzt)
@@ -29,23 +29,16 @@
**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek.
-## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch)
+## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0)
-### Ziel
+**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
-Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben).
+- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph
+- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge
+- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite
+- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche
-### Variante A — Regelbasiert (ohne KI)
-
-1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln.
-2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit).
-3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten.
-4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail.
-
-**Vorteil:** reproduzierbar, offline, Governance-konform.
-**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“.
-
-### Variante B — KI-Zusammenfassung (OpenRouter, optional)
+### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen)
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
diff --git a/backend/main.py b/backend/main.py
index 4fa97c7..d15db0b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -208,6 +208,7 @@ app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
+app.include_router(skill_profiles.router)
app.include_router(training_planning.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router)
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
new file mode 100644
index 0000000..49474cd
--- /dev/null
+++ b/backend/routers/skill_profiles.py
@@ -0,0 +1,354 @@
+"""
+Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte.
+
+GET …/skill-profile — gewichtetes Profil aus verknüpften Übungen.
+GET /api/skill-discovery/suggestions — Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten.
+"""
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from db import get_db, get_cursor, r2d
+from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
+
+from skill_scoring import (
+ GRAPH_DEFAULT_ITEM_MINUTES,
+ ExerciseOccurrence,
+ collect_module_exercise_occurrences,
+ collect_progression_graph_exercise_occurrences,
+ collect_unit_exercise_occurrences,
+ compute_skill_profile,
+ match_score_for_skill_ids,
+ profile_for_occurrences,
+)
+
+from routers.training_framework_programs import _framework_access
+from routers.training_modules import _module_access
+from routers.exercise_progression_graphs import _require_graph_read
+
+router = APIRouter(prefix="/api", tags=["skill_profiles"])
+
+
+def _parse_skill_ids_param(raw: Optional[str]) -> List[int]:
+ if not raw or not str(raw).strip():
+ return []
+ out: List[int] = []
+ for part in str(raw).split(","):
+ part = part.strip()
+ if not part:
+ continue
+ try:
+ n = int(part)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None
+ if n > 0 and n not in out:
+ out.append(n)
+ return out
+
+
+@router.get("/training-framework-programs/{framework_id}/skill-profile")
+def framework_program_skill_profile(
+ framework_id: int,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _framework_access(cur, framework_id, profile_id, role)
+ cur.execute(
+ """
+ SELECT s.id, s.sort_order, s.title,
+ tu.id AS blueprint_unit_id
+ FROM training_framework_slots s
+ LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
+ WHERE s.framework_program_id = %s
+ ORDER BY s.sort_order
+ """,
+ (framework_id,),
+ )
+ slots_raw = [r2d(r) for r in cur.fetchall()]
+
+ all_occurrences: List[ExerciseOccurrence] = []
+ slot_profiles: List[Dict[str, Any]] = []
+
+ for slot in slots_raw:
+ uid = slot.get("blueprint_unit_id")
+ slot_occ: List[ExerciseOccurrence] = []
+ slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}"
+ if uid:
+ raw_occ = collect_unit_exercise_occurrences(cur, int(uid))
+ slot_occ = [
+ ExerciseOccurrence(
+ exercise_id=o.exercise_id,
+ planned_duration_min=o.planned_duration_min,
+ context_label=slot_label,
+ )
+ for o in raw_occ
+ ]
+ all_occurrences.extend(slot_occ)
+ else:
+ slot_occ = []
+ slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile()
+ slot_profiles.append(
+ {
+ "slot_id": slot["id"],
+ "slot_title": slot.get("title"),
+ "sort_order": slot.get("sort_order"),
+ "blueprint_training_unit_id": uid,
+ "exercise_occurrence_count": len(slot_occ),
+ "profile": slot_profile,
+ }
+ )
+
+ overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile()
+
+ return {
+ "artifact_type": "framework_program",
+ "artifact_id": framework_id,
+ "artifact_title": row.get("title"),
+ "overall": overall,
+ "slots": slot_profiles,
+ }
+
+
+@router.get("/training-modules/{module_id}/skill-profile")
+def training_module_skill_profile(
+ module_id: int,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _module_access(cur, module_id, profile_id, role)
+ occurrences = collect_module_exercise_occurrences(cur, module_id)
+ overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile()
+ return {
+ "artifact_type": "training_module",
+ "artifact_id": module_id,
+ "artifact_title": row.get("title"),
+ "overall": overall,
+ }
+
+
+@router.get("/exercise-progression-graphs/{graph_id}/skill-profile")
+def progression_graph_skill_profile(
+ graph_id: int,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _require_graph_read(cur, graph_id, profile_id, role)
+ occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
+ overall = profile_for_occurrences(
+ cur, occurrences, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ ) if occurrences else _empty_profile()
+ return {
+ "artifact_type": "progression_graph",
+ "artifact_id": graph_id,
+ "artifact_title": row.get("name"),
+ "overall": overall,
+ }
+
+
+def _empty_profile() -> Dict[str, Any]:
+ return compute_skill_profile([], {})
+
+
+@router.get("/skill-discovery/suggestions")
+def skill_discovery_suggestions(
+ skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
+ types: Optional[str] = Query(
+ default="framework_program,training_module,progression_graph",
+ description="Artefakttypen, komma-getrennt",
+ ),
+ limit: int = Query(default=20, ge=1, le=50),
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ """
+ Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt.
+ """
+ wanted = _parse_skill_ids_param(skill_ids)
+ if not wanted:
+ raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)")
+
+ type_set = {t.strip() for t in (types or "").split(",") if t.strip()}
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ results: List[Dict[str, Any]] = []
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ if "framework_program" in type_set:
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="fp",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT fp.id, fp.title
+ FROM training_framework_programs fp
+ WHERE ({vis_clause})
+ ORDER BY fp.updated_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ vis_params,
+ )
+ for fp_row in cur.fetchall():
+ fid = int(fp_row["id"])
+ try:
+ _framework_access(cur, fid, profile_id, role)
+ except HTTPException:
+ continue
+ cur.execute(
+ """
+ SELECT tu.id
+ FROM training_framework_slots s
+ INNER JOIN training_units tu ON tu.framework_slot_id = s.id
+ WHERE s.framework_program_id = %s
+ """,
+ (fid,),
+ )
+ occ: List[ExerciseOccurrence] = []
+ for u in cur.fetchall():
+ occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
+ if not occ:
+ continue
+ prof = profile_for_occurrences(cur, occ)
+ match = match_score_for_skill_ids(prof, wanted)
+ if match["match_weight"] <= 0:
+ continue
+ results.append(
+ {
+ "artifact_type": "framework_program",
+ "artifact_id": fid,
+ "artifact_title": fp_row["title"],
+ "path": f"/planning/framework-programs/{fid}",
+ "match": match,
+ "skill_profile_summary": {
+ "total_weight": prof.get("total_weight"),
+ "top_skills": [
+ {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
+ for s in (prof.get("skills") or [])[:5]
+ ],
+ },
+ }
+ )
+
+ if "training_module" in type_set:
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="m",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT m.id, m.title
+ FROM training_modules m
+ WHERE ({vis_clause})
+ ORDER BY m.updated_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ vis_params,
+ )
+ for m_row in cur.fetchall():
+ mid = int(m_row["id"])
+ try:
+ _module_access(cur, mid, profile_id, role)
+ except HTTPException:
+ continue
+ occ = collect_module_exercise_occurrences(cur, mid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(cur, occ)
+ match = match_score_for_skill_ids(prof, wanted)
+ if match["match_weight"] <= 0:
+ continue
+ results.append(
+ {
+ "artifact_type": "training_module",
+ "artifact_id": mid,
+ "artifact_title": m_row["title"],
+ "path": f"/planning/training-modules/{mid}",
+ "match": match,
+ "skill_profile_summary": {
+ "total_weight": prof.get("total_weight"),
+ "top_skills": [
+ {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
+ for s in (prof.get("skills") or [])[:5]
+ ],
+ },
+ }
+ )
+
+ if "progression_graph" in type_set:
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="g",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT g.id, g.name
+ FROM exercise_progression_graphs g
+ WHERE ({vis_clause})
+ ORDER BY g.updated_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ vis_params,
+ )
+ for g_row in cur.fetchall():
+ gid = int(g_row["id"])
+ try:
+ _require_graph_read(cur, gid, profile_id, role)
+ except HTTPException:
+ continue
+ occ = collect_progression_graph_exercise_occurrences(cur, gid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(
+ cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ )
+ match = match_score_for_skill_ids(prof, wanted)
+ if match["match_weight"] <= 0:
+ continue
+ results.append(
+ {
+ "artifact_type": "progression_graph",
+ "artifact_id": gid,
+ "artifact_title": g_row["name"],
+ "path": None,
+ "match": match,
+ "skill_profile_summary": {
+ "total_weight": prof.get("total_weight"),
+ "top_skills": [
+ {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
+ for s in (prof.get("skills") or [])[:5]
+ ],
+ },
+ }
+ )
+
+ results.sort(
+ key=lambda x: (
+ -float(x.get("match", {}).get("match_weight") or 0),
+ -(float(x.get("match", {}).get("match_percent") or 0)),
+ )
+ )
+ return {
+ "skill_ids": wanted,
+ "types": sorted(type_set),
+ "suggestions": results[:limit],
+ }
+
+
+def _empty_profile() -> Dict[str, Any]:
+ return compute_skill_profile([], {})
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
new file mode 100644
index 0000000..fe18c34
--- /dev/null
+++ b/backend/skill_scoring.py
@@ -0,0 +1,345 @@
+"""
+Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
+
+Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul,
+Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit,
+Intensität und Entwicklungsbeitrag.
+"""
+from __future__ import annotations
+
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
+
+
+DEFAULT_ITEM_MINUTES = 8
+GRAPH_DEFAULT_ITEM_MINUTES = 10
+
+_INTENSITY_MULT = {
+ "niedrig": 0.85,
+ "low": 0.85,
+ "mittel": 1.0,
+ "medium": 1.0,
+ "hoch": 1.2,
+ "high": 1.2,
+}
+
+_DEV_CONTRIB_MULT = {
+ "low": 0.9,
+ "niedrig": 0.9,
+ "medium": 1.0,
+ "mittel": 1.0,
+ "high": 1.15,
+ "hoch": 1.15,
+}
+
+
+@dataclass(frozen=True)
+class ExerciseOccurrence:
+ exercise_id: int
+ planned_duration_min: Optional[int] = None
+ """Optional label for UI (e.g. slot title)."""
+ context_label: Optional[str] = None
+
+
+def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUTES) -> float:
+ if planned is not None:
+ try:
+ m = int(planned)
+ if m > 0:
+ return float(m)
+ except (TypeError, ValueError):
+ pass
+ return float(default)
+
+
+def _skill_link_multiplier(
+ *,
+ is_primary: bool = False,
+ intensity: Optional[str] = None,
+ development_contribution: Optional[str] = None,
+) -> float:
+ mult = 1.0
+ if is_primary:
+ mult *= 1.5
+ if intensity:
+ key = str(intensity).strip().lower()
+ mult *= _INTENSITY_MULT.get(key, 1.0)
+ if development_contribution:
+ key = str(development_contribution).strip().lower()
+ mult *= _DEV_CONTRIB_MULT.get(key, 1.0)
+ return mult
+
+
+def _round2(val: float) -> float:
+ return round(val, 2)
+
+
+def compute_skill_profile(
+ occurrences: Sequence[ExerciseOccurrence],
+ skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
+ *,
+ default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+) -> Dict[str, Any]:
+ """
+ Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
+ """
+ exercise_meta: Dict[int, Dict[str, Any]] = defaultdict(
+ lambda: {"occurrence_count": 0, "minutes": 0.0, "context_labels": []}
+ )
+ total_occurrences = 0
+ for occ in occurrences or []:
+ eid = int(occ.exercise_id)
+ mins = _item_base_minutes(occ.planned_duration_min, default_item_minutes)
+ exercise_meta[eid]["occurrence_count"] += 1
+ exercise_meta[eid]["minutes"] += mins
+ total_occurrences += 1
+ if occ.context_label and occ.context_label not in exercise_meta[eid]["context_labels"]:
+ exercise_meta[eid]["context_labels"].append(occ.context_label)
+
+ skill_acc: Dict[int, Dict[str, Any]] = {}
+ total_weight = 0.0
+ exercises_with_skills: set[int] = set()
+
+ for eid, meta in exercise_meta.items():
+ links = skill_rows_by_exercise.get(eid) or []
+ if not links:
+ continue
+ exercises_with_skills.add(eid)
+ occ_count = meta["occurrence_count"]
+ minutes_per_occ = meta["minutes"] / occ_count if occ_count else float(default_item_minutes)
+
+ for link in links:
+ sid = link.get("skill_id")
+ if sid is None:
+ continue
+ sid = int(sid)
+ link_mult = _skill_link_multiplier(
+ is_primary=bool(link.get("is_primary")),
+ intensity=link.get("intensity"),
+ development_contribution=link.get("development_contribution"),
+ )
+ contribution = minutes_per_occ * occ_count * link_mult
+ if contribution <= 0:
+ continue
+
+ if sid not in skill_acc:
+ skill_acc[sid] = {
+ "skill_id": sid,
+ "skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
+ "category": link.get("category"),
+ "focus_areas": link.get("focus_areas"),
+ "weight": 0.0,
+ "occurrence_count": 0,
+ "primary_link_count": 0,
+ "exercises": {},
+ }
+ acc = skill_acc[sid]
+ acc["weight"] += contribution
+ acc["occurrence_count"] += occ_count
+ if link.get("is_primary"):
+ acc["primary_link_count"] += occ_count
+ ex_key = str(eid)
+ if ex_key not in acc["exercises"]:
+ acc["exercises"][ex_key] = {
+ "exercise_id": eid,
+ "title": link.get("exercise_title") or f"Übung #{eid}",
+ "weight": 0.0,
+ "occurrence_count": occ_count,
+ }
+ acc["exercises"][ex_key]["weight"] += contribution
+ total_weight += contribution
+
+ skills_out: List[Dict[str, Any]] = []
+ for sid, acc in skill_acc.items():
+ share = (acc["weight"] / total_weight * 100.0) if total_weight > 0 else 0.0
+ ex_list = sorted(
+ acc["exercises"].values(),
+ key=lambda x: (-x["weight"], x.get("title") or ""),
+ )[:8]
+ for ex in ex_list:
+ ex["weight"] = _round2(ex["weight"])
+ if total_weight > 0:
+ ex["share_percent"] = _round2(ex["weight"] / total_weight * 100.0)
+ else:
+ ex["share_percent"] = 0.0
+ skills_out.append(
+ {
+ "skill_id": sid,
+ "skill_name": acc["skill_name"],
+ "category": acc.get("category"),
+ "focus_areas": acc.get("focus_areas"),
+ "weight": _round2(acc["weight"]),
+ "share_percent": _round2(share),
+ "occurrence_count": acc["occurrence_count"],
+ "primary_link_count": acc["primary_link_count"],
+ "top_exercises": ex_list,
+ }
+ )
+ skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or ""))
+
+ by_category: Dict[str, float] = defaultdict(float)
+ for sk in skills_out:
+ cat = (sk.get("category") or "").strip() or "—"
+ by_category[cat] += sk["weight"]
+ category_rows = []
+ for cat, w in sorted(by_category.items(), key=lambda x: (-x[1], x[0])):
+ share = (w / total_weight * 100.0) if total_weight > 0 else 0.0
+ category_rows.append(
+ {"category": cat, "weight": _round2(w), "share_percent": _round2(share)}
+ )
+
+ unique_exercises = len(exercise_meta)
+ return {
+ "computed_at": datetime.now(timezone.utc).isoformat(),
+ "scoring_version": "1.0",
+ "total_weight": _round2(total_weight),
+ "exercise_occurrence_count": total_occurrences,
+ "distinct_exercise_count": unique_exercises,
+ "exercises_with_skills_count": len(exercises_with_skills),
+ "skills": skills_out,
+ "by_category": category_rows,
+ }
+
+
+def fetch_exercise_skills_bulk(
+ cur, exercise_ids: Iterable[int]
+) -> Dict[int, List[Dict[str, Any]]]:
+ ids = sorted({int(x) for x in exercise_ids if x})
+ if not ids:
+ return {}
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(
+ f"""
+ SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity,
+ es.development_contribution, es.required_level, es.target_level,
+ s.name AS skill_name, s.category, s.focus_areas,
+ e.title AS exercise_title
+ FROM exercise_skills es
+ JOIN skills s ON s.id = es.skill_id
+ JOIN exercises e ON e.id = es.exercise_id
+ WHERE es.exercise_id IN ({ph})
+ AND (s.status = 'active' OR s.status IS NULL)
+ ORDER BY es.exercise_id, es.is_primary DESC, es.skill_id
+ """,
+ ids,
+ )
+ out: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
+ for row in cur.fetchall():
+ d = dict(row)
+ eid = int(d["exercise_id"])
+ fa = d.get("focus_areas")
+ if fa is not None and not isinstance(fa, list):
+ try:
+ import json
+
+ fa = json.loads(fa) if isinstance(fa, str) else fa
+ except Exception:
+ fa = []
+ d["focus_areas"] = fa if isinstance(fa, list) else []
+ out[eid].append(d)
+ return dict(out)
+
+
+def collect_unit_exercise_occurrences(cur, unit_id: int) -> List[ExerciseOccurrence]:
+ cur.execute(
+ """
+ SELECT tusi.exercise_id, tusi.planned_duration_min
+ FROM training_unit_section_items tusi
+ INNER JOIN training_unit_sections tus ON tus.id = tusi.section_id
+ WHERE tus.training_unit_id = %s
+ AND tusi.item_type = 'exercise'
+ AND tusi.exercise_id IS NOT NULL
+ ORDER BY tus.order_index, tusi.order_index
+ """,
+ (int(unit_id),),
+ )
+ return [
+ ExerciseOccurrence(
+ exercise_id=int(r["exercise_id"]),
+ planned_duration_min=r.get("planned_duration_min"),
+ )
+ for r in cur.fetchall()
+ ]
+
+
+def collect_module_exercise_occurrences(cur, module_id: int) -> List[ExerciseOccurrence]:
+ cur.execute(
+ """
+ SELECT exercise_id, planned_duration_min
+ FROM training_module_items
+ WHERE module_id = %s
+ AND item_type = 'exercise'
+ AND exercise_id IS NOT NULL
+ ORDER BY order_index
+ """,
+ (int(module_id),),
+ )
+ return [
+ ExerciseOccurrence(
+ exercise_id=int(r["exercise_id"]),
+ planned_duration_min=r.get("planned_duration_min"),
+ )
+ for r in cur.fetchall()
+ ]
+
+
+def collect_progression_graph_exercise_occurrences(cur, graph_id: int) -> List[ExerciseOccurrence]:
+ """Jedes Vorkommen als from- oder to-Endpunkt einer Kante zählt (ohne Dauer → Default)."""
+ cur.execute(
+ """
+ SELECT from_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
+ UNION ALL
+ SELECT to_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
+ """,
+ (int(graph_id), int(graph_id)),
+ )
+ return [
+ ExerciseOccurrence(
+ exercise_id=int(r["exercise_id"]),
+ planned_duration_min=None,
+ context_label=None,
+ )
+ for r in cur.fetchall()
+ ]
+
+
+def profile_for_occurrences(
+ cur,
+ occurrences: Sequence[ExerciseOccurrence],
+ *,
+ default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+) -> Dict[str, Any]:
+ eids = [o.exercise_id for o in occurrences]
+ skills_map = fetch_exercise_skills_bulk(cur, eids)
+ return compute_skill_profile(
+ occurrences, skills_map, default_item_minutes=default_item_minutes
+ )
+
+
+def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
+ """Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge)."""
+ wanted = {int(x) for x in skill_ids if x is not None}
+ if not wanted:
+ return {
+ "match_weight": 0.0,
+ "match_percent": 0.0,
+ "matched_skill_ids": [],
+ "matched_skills": [],
+ }
+ matched = []
+ match_weight = 0.0
+ total = float(profile.get("total_weight") or 0)
+ for sk in profile.get("skills") or []:
+ sid = int(sk["skill_id"])
+ if sid in wanted:
+ matched.append(sk)
+ match_weight += float(sk.get("weight") or 0)
+ match_percent = (match_weight / total * 100.0) if total > 0 else 0.0
+ return {
+ "match_weight": _round2(match_weight),
+ "match_percent": _round2(match_percent),
+ "matched_skill_ids": [int(m["skill_id"]) for m in matched],
+ "matched_skills": matched,
+ }
diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
new file mode 100644
index 0000000..e7640ab
--- /dev/null
+++ b/backend/tests/test_skill_scoring.py
@@ -0,0 +1,60 @@
+"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3)."""
+from skill_scoring import (
+ ExerciseOccurrence,
+ compute_skill_profile,
+ match_score_for_skill_ids,
+ _skill_link_multiplier,
+)
+
+
+def test_skill_link_multiplier_primary_and_intensity():
+ assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2
+ assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85
+
+
+def test_compute_skill_profile_aggregates_weights():
+ occurrences = [
+ ExerciseOccurrence(exercise_id=1, planned_duration_min=60),
+ ExerciseOccurrence(exercise_id=1, planned_duration_min=30),
+ ]
+ skills_map = {
+ 1: [
+ {
+ "skill_id": 10,
+ "skill_name": "Distanz",
+ "category": "kihon",
+ "is_primary": True,
+ "intensity": "hoch",
+ "exercise_title": "Übung A",
+ },
+ {
+ "skill_id": 11,
+ "skill_name": "Balance",
+ "category": "kihon",
+ "is_primary": False,
+ "intensity": "mittel",
+ "exercise_title": "Übung A",
+ },
+ ],
+ }
+ profile = compute_skill_profile(occurrences, skills_map)
+ assert profile["exercise_occurrence_count"] == 2
+ assert profile["distinct_exercise_count"] == 1
+ assert len(profile["skills"]) == 2
+ assert profile["skills"][0]["skill_id"] == 10
+ assert profile["total_weight"] > profile["skills"][1]["weight"]
+ assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1
+
+
+def test_match_score_for_skill_ids():
+ profile = {
+ "total_weight": 100.0,
+ "skills": [
+ {"skill_id": 1, "skill_name": "A", "weight": 40.0},
+ {"skill_id": 2, "skill_name": "B", "weight": 60.0},
+ ],
+ }
+ m = match_score_for_skill_ids(profile, [1])
+ assert m["match_weight"] == 40.0
+ assert m["match_percent"] == 40.0
+ assert m["matched_skill_ids"] == [1]
diff --git a/backend/version.py b/backend/version.py
index 529fd57..2752721 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.150"
+APP_VERSION = "0.8.151"
BUILD_DATE = "2026-05-20"
DB_SCHEMA_VERSION = "20260520066"
@@ -20,6 +20,7 @@ MODULE_VERSIONS = {
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
+ "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
@@ -36,6 +37,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.151",
+ "date": "2026-05-20",
+ "changes": [
+ "Phase 3 Fähigkeiten-Scoring: skill_scoring.py (Dauer × Vorkommen × Primär/Intensität/Beitrag)",
+ "GET skill-profile für Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph",
+ "GET /api/skill-discovery/suggestions nach skill_ids",
+ ],
+ },
{
"version": "0.8.149",
"date": "2026-05-19",
diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js
new file mode 100644
index 0000000..7c20c3c
--- /dev/null
+++ b/frontend/src/api/skillProfiles.js
@@ -0,0 +1,26 @@
+import { request } from './client.js'
+
+export async function getFrameworkProgramSkillProfile(frameworkId) {
+ return request(`/api/training-framework-programs/${frameworkId}/skill-profile`)
+}
+
+export async function getTrainingModuleSkillProfile(moduleId) {
+ return request(`/api/training-modules/${moduleId}/skill-profile`)
+}
+
+export async function getProgressionGraphSkillProfile(graphId) {
+ return request(`/api/exercise-progression-graphs/${graphId}/skill-profile`)
+}
+
+/**
+ * @param {number[]} skillIds
+ * @param {{ types?: string, limit?: number }} opts
+ */
+export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
+ const ids = (skillIds || []).filter((x) => x != null && Number(x) > 0).join(',')
+ if (!ids) return { skill_ids: [], suggestions: [] }
+ const params = new URLSearchParams({ skill_ids: ids })
+ if (opts.types) params.set('types', opts.types)
+ if (opts.limit != null) params.set('limit', String(opts.limit))
+ return request(`/api/skill-discovery/suggestions?${params.toString()}`)
+}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 958b06a..9e693c5 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2475,6 +2475,282 @@ html.modal-scroll-locked .app-main {
}
}
+/* Phase 3: Fähigkeiten-Profil & Planungs-Vorschläge */
+.skill-profile {
+ margin-bottom: 1rem;
+ padding: 0;
+ overflow: hidden;
+}
+.skill-profile--loading,
+.skill-profile--error {
+ padding: 1rem 1.15rem;
+}
+.skill-profile__status {
+ margin: 0;
+ color: var(--text2);
+ font-size: 0.9rem;
+}
+.skill-profile__toggle {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ width: 100%;
+ padding: 0.85rem 1.1rem;
+ border: none;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.skill-profile__toggle-title {
+ font-weight: 700;
+ color: var(--text1);
+}
+.skill-profile__toggle-badge {
+ font-size: 0.78rem;
+ color: var(--accent-dark);
+ background: var(--accent-light);
+ padding: 3px 8px;
+ border-radius: 999px;
+}
+.skill-profile__toggle-icon {
+ margin-left: auto;
+ color: var(--text3);
+}
+.skill-profile__body {
+ padding: 0 1.1rem 1rem;
+ border-top: 1px solid var(--border);
+}
+.skill-profile__hint {
+ margin: 0.65rem 0 0.75rem;
+}
+.skill-profile__empty {
+ margin: 0;
+ color: var(--text2);
+ font-size: 0.88rem;
+ line-height: 1.5;
+}
+.skill-profile__stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 18px;
+ font-size: 0.82rem;
+ color: var(--text2);
+ margin-bottom: 0.75rem;
+}
+.skill-profile__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.skill-profile__list--nested {
+ margin: 8px 0 0;
+ padding-left: 8px;
+ border-left: 2px solid var(--border);
+}
+.skill-profile__row-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.skill-profile__name {
+ font-weight: 600;
+ font-size: 0.88rem;
+ color: var(--text1);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.skill-profile__pct {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+ flex-shrink: 0;
+}
+.skill-profile__bar-track {
+ height: 8px;
+ border-radius: 4px;
+ background: var(--surface2);
+ overflow: hidden;
+}
+.skill-profile__bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ background: linear-gradient(90deg, var(--accent) 0%, var(--accent-dark) 100%);
+ min-width: 2px;
+}
+.skill-profile__meta-hint {
+ display: block;
+ font-size: 0.72rem;
+ color: var(--text3);
+ margin-top: 3px;
+}
+.skill-profile__categories {
+ margin-top: 0.85rem;
+}
+.skill-profile__categories-label,
+.skill-profile__slots-label {
+ display: block;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text3);
+ margin-bottom: 6px;
+}
+.skill-profile__category-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.skill-profile__category-chip {
+ font-size: 0.78rem;
+ padding: 3px 8px;
+ border-radius: 6px;
+ background: var(--surface2);
+ color: var(--text2);
+}
+.skill-profile__slots {
+ margin-top: 1rem;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--border);
+}
+.skill-profile__slot-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.skill-profile__slot-btn {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 6px;
+ width: 100%;
+ padding: 8px 0;
+ border: none;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ color: var(--text1);
+}
+.skill-profile__slot-top {
+ font-size: 0.82rem;
+ color: var(--accent-dark);
+ font-weight: 600;
+}
+.skill-profile__slot-top--muted {
+ color: var(--text3);
+ font-weight: 500;
+}
+
+.skill-discovery {
+ padding: 1.15rem 1.25rem;
+ max-width: 52rem;
+}
+.skill-discovery__title {
+ margin: 0 0 0.35rem;
+ font-size: 1.1rem;
+}
+.skill-discovery__lead {
+ margin: 0 0 1rem;
+ max-width: 40rem;
+}
+.skill-discovery__pick-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
+ gap: 8px;
+ max-height: 220px;
+ overflow-y: auto;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--surface2);
+ margin-bottom: 1rem;
+}
+.skill-discovery__pick {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 8px;
+ font-size: 0.85rem;
+ cursor: pointer;
+ padding: 4px 6px;
+ border-radius: 6px;
+}
+.skill-discovery__pick:hover {
+ background: var(--surface);
+}
+.skill-discovery__pick-name {
+ font-weight: 600;
+ color: var(--text1);
+}
+.skill-discovery__pick-cat {
+ font-size: 0.72rem;
+ color: var(--text3);
+}
+.skill-discovery__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 0.75rem;
+}
+.skill-discovery__error {
+ color: var(--danger);
+ margin: 0 0 0.75rem;
+}
+.skill-discovery__results {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.skill-discovery__result {
+ padding: 0.85rem 1rem;
+ margin-bottom: 0;
+}
+.skill-discovery__result-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+.skill-discovery__result-type {
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text3);
+}
+.skill-discovery__result-match {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+}
+.skill-discovery__result-title {
+ display: block;
+ margin-bottom: 4px;
+}
+.skill-discovery__result-skills {
+ margin: 0 0 8px;
+ font-size: 0.85rem;
+ color: var(--text2);
+}
+.skill-discovery__result-link {
+ text-decoration: none;
+}
+.skill-discovery__no-hit {
+ margin: 0;
+}
+
/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */
.page-form-editor__body .framework-edit {
min-width: 0;
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index dfd8661..983bd08 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
+import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
@@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('')
const [uiTab, setUiTab] = useState('overview')
+ const [skillProfileData, setSkillProfileData] = useState(null)
+ const [skillProfileLoading, setSkillProfileLoading] = useState(false)
+ const [skillProfileError, setSkillProfileError] = useState('')
useEffect(() => {
setSelectedGraphId(null)
@@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({
}
}, [refreshGraphs, tenantClubDepKey])
+ useEffect(() => {
+ if (!selectedGraphId) {
+ setSkillProfileData(null)
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ setSkillProfileLoading(true)
+ setSkillProfileError('')
+ try {
+ const data = await api.getProgressionGraphSkillProfile(selectedGraphId)
+ if (!cancelled) setSkillProfileData(data)
+ } catch (e) {
+ if (!cancelled) {
+ setSkillProfileData(null)
+ setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
+ }
+ } finally {
+ if (!cancelled) setSkillProfileLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [selectedGraphId, edges.length])
+
useEffect(() => {
if (!selectedGraphId) {
setEdges([])
@@ -652,6 +682,14 @@ export default function ExerciseProgressionGraphPanel({
{selectedGraphId && uiTab === 'overview' && (
<>
+
Sequenz / Reihe anlegen
diff --git a/frontend/src/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx
new file mode 100644
index 0000000..dd09642
--- /dev/null
+++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx
@@ -0,0 +1,163 @@
+import React, { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../../utils/api'
+
+const ARTIFACT_LABELS = {
+ framework_program: 'Rahmenprogramm',
+ training_module: 'Trainingsmodul',
+ progression_graph: 'Regressionspfad',
+}
+
+/**
+ * Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
+ */
+export default function SkillDiscoveryPanel({ skills = [] }) {
+ const [selectedIds, setSelectedIds] = useState([])
+ const [query, setQuery] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [result, setResult] = useState(null)
+
+ const filteredSkills = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ const list = (skills || []).filter((s) => s.status !== 'inactive')
+ if (!q) return list
+ return list.filter(
+ (s) =>
+ (s.name || '').toLowerCase().includes(q) ||
+ (s.category || '').toLowerCase().includes(q)
+ )
+ }, [skills, query])
+
+ const toggleSkill = (id) => {
+ const s = String(id)
+ setSelectedIds((prev) =>
+ prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
+ )
+ }
+
+ async function handleSearch() {
+ const ids = selectedIds.map((x) => parseInt(x, 10)).filter((n) => n > 0)
+ if (!ids.length) {
+ setError('Wähle mindestens eine Fähigkeit.')
+ return
+ }
+ setLoading(true)
+ setError('')
+ setResult(null)
+ try {
+ const data = await api.getSkillDiscoverySuggestions(ids, { limit: 25 })
+ setResult(data)
+ } catch (e) {
+ setError(e.message || 'Suche fehlgeschlagen')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
Planungs-Vorschläge
+
+ Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst — Shinkan schlägt passende
+ Rahmenprogramme, Trainingsmodule und Regressionspfade aus der Bibliothek vor (gewichtet nach
+ Übungs-Verknüpfungen).
+
+
+
+ Fähigkeiten filtern
+ setQuery(e.target.value)}
+ placeholder="Name oder Kategorie …"
+ />
+
+
+
+ {filteredSkills.length === 0 ? (
+
Keine Fähigkeiten gefunden.
+ ) : (
+ filteredSkills.map((sk) => (
+
+ toggleSkill(sk.id)}
+ />
+ {sk.name}
+ {sk.category ? (
+ {sk.category}
+ ) : null}
+
+ ))
+ )}
+
+
+
+
+ {loading ? 'Suche …' : 'Bibliothek durchsuchen'}
+
+ {selectedIds.length > 0 ? (
+ {
+ setSelectedIds([])
+ setResult(null)
+ setError('')
+ }}
+ >
+ Auswahl leeren
+
+ ) : null}
+
+
+ {error ?
{error}
: null}
+
+ {result?.suggestions?.length > 0 ? (
+
+ {result.suggestions.map((item) => (
+
+
+
+ {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
+
+
+ Passung {item.match?.match_percent ?? 0}%
+
+
+
+ {item.artifact_title || `#${item.artifact_id}`}
+
+ {item.match?.matched_skills?.length > 0 ? (
+
+ {item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
+
+ ) : null}
+ {item.path ? (
+
+ Öffnen
+
+ ) : item.artifact_type === 'progression_graph' ? (
+
+ Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten.
+
+ ) : null}
+
+ ))}
+
+ ) : result && !loading ? (
+
+ Keine passenden Artefakte in deiner sichtbaren Bibliothek — prüfe Fähigkeiten-Verknüpfungen an
+ den Übungen oder erweitere die Auswahl.
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
new file mode 100644
index 0000000..dd58509
--- /dev/null
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -0,0 +1,189 @@
+import React, { useMemo, useState } from 'react'
+
+function SkillBar({ skill, maxShare }) {
+ const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0
+ return (
+
+
+
+ {skill.skill_name}
+
+ {skill.share_percent}%
+
+
+ {skill.primary_link_count > 0 ? (
+
+ {skill.primary_link_count}× als Primär-Fähigkeit in Übungen
+
+ ) : null}
+
+ )
+}
+
+/**
+ * Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
+ */
+export default function SkillProfilePanel({
+ profile,
+ slots = null,
+ loading = false,
+ error = '',
+ title = 'Fähigkeiten-Profil',
+ hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).',
+ defaultExpanded = true,
+}) {
+ const [expanded, setExpanded] = useState(defaultExpanded)
+ const [slotOpenId, setSlotOpenId] = useState(null)
+
+ const skills = profile?.skills || []
+ const maxShare = useMemo(
+ () => Math.max(...skills.map((s) => s.share_percent || 0), 1),
+ [skills]
+ )
+
+ if (loading) {
+ return (
+
+
Fähigkeiten-Profil wird berechnet…
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ const noData =
+ !profile ||
+ (profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
+
+ return (
+
+
setExpanded((v) => !v)}
+ aria-expanded={expanded}
+ >
+ {title}
+ {!noData && skills.length > 0 ? (
+
+ Top: {skills[0].skill_name} ({skills[0].share_percent}%)
+
+ ) : null}
+
+ {expanded ? '▾' : '▸'}
+
+
+
+ {expanded ? (
+
+
{hint}
+
+ {noData ? (
+
+ Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe
+ Fähigkeiten in der Übungsbearbeitung.
+
+ ) : profile.exercises_with_skills_count === 0 ? (
+
+ {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
+ Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
+
+ ) : (
+ <>
+
+
+ {profile.distinct_exercise_count} Übungen
+
+
+ {skills.length} Fähigkeiten
+
+
+ {profile.exercise_occurrence_count} Positionen
+
+
+
+
+ {skills.slice(0, 12).map((sk) => (
+
+ ))}
+
+
+ {profile.by_category?.length > 1 ? (
+
+
Nach Kategorie
+
+ {profile.by_category.slice(0, 6).map((c) => (
+
+ {c.category} {c.share_percent}%
+
+ ))}
+
+
+ ) : null}
+ >
+ )}
+
+ {slots && slots.length > 0 ? (
+
+
Pro Session
+
+ {slots.map((sl) => {
+ const open = slotOpenId === sl.slot_id
+ const top = sl.profile?.skills?.[0]
+ return (
+
+ setSlotOpenId(open ? null : sl.slot_id)}
+ aria-expanded={open}
+ >
+
+ {(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
+
+ {top ? (
+
+ {top.skill_name} {top.share_percent}%
+
+ ) : (
+
+ {sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
+
+ )}
+
+ {open && sl.profile?.skills?.length > 0 ? (
+
+ {sl.profile.skills.slice(0, 6).map((sk) => (
+ x.share_percent || 0),
+ 1
+ )}
+ />
+ ))}
+
+ ) : null}
+
+ )
+ })}
+
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx
index 60c93d1..691aed9 100644
--- a/frontend/src/pages/SkillsPage.jsx
+++ b/frontend/src/pages/SkillsPage.jsx
@@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import PageSectionNav from '../components/PageSectionNav'
+import SkillDiscoveryPanel from '../components/skills/SkillDiscoveryPanel'
const SKILLS_SECTION_TABS = [
{ id: 'skills', label: 'Fähigkeiten' },
{ id: 'methods', label: 'Trainingsmethoden' },
+ { id: 'discovery', label: 'Planungs-Vorschläge' },
]
function SkillsPage() {
@@ -243,6 +245,8 @@ function SkillsPage() {
>
)}
+ {activeTab === 'discovery' &&
}
+
{/* Methods Tab */}
{activeTab === 'methods' && (
<>
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 6f10322..322660f 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
+import SkillProfilePanel from '../components/skills/SkillProfilePanel'
import { useToast } from '../context/ToastContext'
import { useNavReturn } from '../hooks/useNavReturn'
import {
@@ -248,6 +249,10 @@ export default function TrainingFrameworkProgramEditPage() {
)
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
+ const [skillProfileData, setSkillProfileData] = useState(null)
+ const [skillProfileLoading, setSkillProfileLoading] = useState(false)
+ const [skillProfileError, setSkillProfileError] = useState('')
+ const [skillProfileTick, setSkillProfileTick] = useState(0)
const toast = useToast()
const baselineRef = useRef(null)
@@ -301,6 +306,32 @@ export default function TrainingFrameworkProgramEditPage() {
return () => document.removeEventListener('pointerdown', onPointerDown, true)
}, [])
+ useEffect(() => {
+ if (isNew || !idParam) {
+ setSkillProfileData(null)
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ setSkillProfileLoading(true)
+ setSkillProfileError('')
+ try {
+ const data = await api.getFrameworkProgramSkillProfile(idParam)
+ if (!cancelled) setSkillProfileData(data)
+ } catch (e) {
+ if (!cancelled) {
+ setSkillProfileData(null)
+ setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
+ }
+ } finally {
+ if (!cancelled) setSkillProfileLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [isNew, idParam, skillProfileTick])
+
const loadMeta = useCallback(async () => {
try {
const [cl, fa, sd, tt, tg] = await Promise.all([
@@ -480,6 +511,7 @@ export default function TrainingFrameworkProgramEditPage() {
setBypassDirty(false)
setBaselineReady(true)
toast.success('Gespeichert.')
+ setSkillProfileTick((t) => t + 1)
if (closeAfter) goBack()
return true
} catch (e) {
@@ -940,6 +972,17 @@ export default function TrainingFrameworkProgramEditPage() {
/>
+ {!isNew ? (
+
+ ) : null}
+
{
+ if (isNew || !Number.isFinite(moduleId)) {
+ setSkillProfileData(null)
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ setSkillProfileLoading(true)
+ setSkillProfileError('')
+ try {
+ const data = await api.getTrainingModuleSkillProfile(moduleId)
+ if (!cancelled) setSkillProfileData(data)
+ } catch (e) {
+ if (!cancelled) {
+ setSkillProfileData(null)
+ setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
+ }
+ } finally {
+ if (!cancelled) setSkillProfileLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [isNew, moduleId, skillProfileTick])
+
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
@@ -339,6 +370,7 @@ export default function TrainingModuleEditPage() {
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Gespeichert.')
+ setSkillProfileTick((t) => t + 1)
if (closeAfter) goBack()
return true
} catch (err) {
@@ -391,6 +423,14 @@ export default function TrainingModuleEditPage() {
onSubmit={handleSave}
>
+ {!isNew ? (
+
+ ) : null}
Titel *
setTitle(e.target.value)} />
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 4d999ad..9528ba1 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -7,10 +7,12 @@
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
import * as exercises from '../api/exercises.js'
import * as planning from '../api/planning.js'
+import * as skillProfiles from '../api/skillProfiles.js'
export { ACTIVE_CLUB_STORAGE_KEY }
export * from '../api/exercises.js'
export * from '../api/planning.js'
+export * from '../api/skillProfiles.js'
// ============================================================================
// Auth
--
2.43.0
From f67bf280c3105f3cb7453935efecb504962cc3c5 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 08:11:46 +0200
Subject: [PATCH 02/10] Add Skill Profiles API Integration for Phase 3
---
frontend/src/utils/api.js | 3 +++
1 file changed, 3 insertions(+)
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 9528ba1..64454eb 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -755,6 +755,9 @@ export const api = {
// Training Planning → frontend/src/api/planning.js
...planning,
+ // Fähigkeiten-Profile & Vorschläge (Phase 3) → frontend/src/api/skillProfiles.js
+ ...skillProfiles,
+
// Catalogs
listFocusAreas,
createFocusArea,
--
2.43.0
From 8f8bdf6d8b14865e888acd3f08f1f6329f68427a Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 08:24:23 +0200
Subject: [PATCH 03/10] Update Skill Scoring Specification and Implementation
to v1.1
- Enhanced the skill scoring formula to incorporate intensity and level range factors, improving the accuracy of skill contributions.
- Removed the use of `is_primary` and `development_contribution` from calculations, streamlining the scoring process.
- Updated documentation to reflect changes in the scoring logic and versioning.
- Adjusted frontend components to align with the new scoring criteria, ensuring consistent user experience across the application.
---
.claude/docs/technical/SKILL_SCORING_SPEC.md | 33 ++++++-
backend/skill_scoring.py | 86 +++++++++++++------
backend/tests/test_skill_scoring.py | 32 +++++--
.../ExerciseProgressionGraphPanel.jsx | 2 +-
.../components/skills/SkillProfilePanel.jsx | 7 +-
.../TrainingFrameworkProgramEditPage.jsx | 2 +-
6 files changed, 119 insertions(+), 43 deletions(-)
diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
index 7a138ba..8e6e852 100644
--- a/.claude/docs/technical/SKILL_SCORING_SPEC.md
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -1,7 +1,7 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
**Stand:** 2026-05-20
-**Status:** Variante A (regelbasiert) umgesetzt — v1.0
+**Status:** Variante A (regelbasiert) umgesetzt — **v1.1** (Intensität + Stufen-Spanne, ohne Primär)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
## Ziel
@@ -19,14 +19,39 @@ Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rah
Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
-## Gewichtungsformel (v1.0)
+## Gewichtungsformel (v1.1)
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
2. Pro verknüpfte Fähigkeit der Übung:
- - `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor`
- - Link-Faktor = 1.0 × (1.5 wenn `is_primary`) × Intensität (`niedrig` 0.85, `mittel` 1.0, `hoch` 1.2) × Entwicklungsbeitrag (`low` 0.9, `medium` 1.0, `high` 1.15)
+ - `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor`
+ - **Link-Faktor** = Intensität × Stufen-Faktor
+
+### Intensität (Nutzeneinschätzung, UI-Feld)
+
+| Wert | Faktor |
+|------|--------|
+| niedrig | 0,85 |
+| mittel / leer | 1,0 |
+| hoch | 1,2 |
+
+### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“)
+
+Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0.
+
+- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (1–5)
+- **Mittelpunkt** = durchschnittliche Stufe
+- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20
+
+Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
+
+### Bewusst nicht im Scoring
+
+| Feld | Grund |
+|------|--------|
+| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
+| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
Aggregation:
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index fe18c34..90fdd04 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -1,9 +1,11 @@
"""
Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
-Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul,
-Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit,
-Intensität und Entwicklungsbeitrag.
+Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus:
+geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis).
+
+is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist
+in der UI nicht gepflegt und wird ignoriert.
"""
from __future__ import annotations
@@ -25,16 +27,58 @@ _INTENSITY_MULT = {
"high": 1.2,
}
-_DEV_CONTRIB_MULT = {
- "low": 0.9,
- "niedrig": 0.9,
- "medium": 1.0,
- "mittel": 1.0,
- "high": 1.15,
- "hoch": 1.15,
+# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js
+_LEVEL_RANK = {
+ "basis": 1,
+ "grundlagen": 2,
+ "aufbau": 3,
+ "fortgeschritten": 4,
+ "optimierung": 5,
+ "einsteiger": 1,
+ "experte": 5,
+ "1": 1,
+ "2": 2,
+ "3": 3,
+ "4": 4,
+ "5": 5,
}
+def _level_rank(value: Optional[str]) -> Optional[int]:
+ if value is None:
+ return None
+ key = str(value).strip().lower()
+ if not key:
+ return None
+ rank = _LEVEL_RANK.get(key)
+ return rank if rank is not None else None
+
+
+def _level_range_multiplier(
+ required_level: Optional[str] = None,
+ target_level: Optional[str] = None,
+) -> float:
+ """
+ Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen → etwas höheres Gewicht.
+ Fehlen beide Angaben: neutral (1.0).
+ """
+ rr = _level_rank(required_level)
+ rt = _level_rank(target_level)
+ if rr is None and rt is None:
+ return 1.0
+ if rr is None:
+ rr = rt
+ if rt is None:
+ rt = rr
+ if rr > rt:
+ rr, rt = rt, rr
+ span = max(1, min(5, rt - rr + 1))
+ midpoint = (rr + rt) / 2.0
+ span_mult = 0.92 + 0.04 * span
+ depth_mult = 0.95 + 0.025 * midpoint
+ return span_mult * depth_mult
+
+
@dataclass(frozen=True)
class ExerciseOccurrence:
exercise_id: int
@@ -56,19 +100,15 @@ def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUT
def _skill_link_multiplier(
*,
- is_primary: bool = False,
intensity: Optional[str] = None,
- development_contribution: Optional[str] = None,
+ required_level: Optional[str] = None,
+ target_level: Optional[str] = None,
) -> float:
mult = 1.0
- if is_primary:
- mult *= 1.5
if intensity:
key = str(intensity).strip().lower()
mult *= _INTENSITY_MULT.get(key, 1.0)
- if development_contribution:
- key = str(development_contribution).strip().lower()
- mult *= _DEV_CONTRIB_MULT.get(key, 1.0)
+ mult *= _level_range_multiplier(required_level, target_level)
return mult
@@ -116,9 +156,9 @@ def compute_skill_profile(
continue
sid = int(sid)
link_mult = _skill_link_multiplier(
- is_primary=bool(link.get("is_primary")),
intensity=link.get("intensity"),
- development_contribution=link.get("development_contribution"),
+ required_level=link.get("required_level"),
+ target_level=link.get("target_level"),
)
contribution = minutes_per_occ * occ_count * link_mult
if contribution <= 0:
@@ -132,14 +172,11 @@ def compute_skill_profile(
"focus_areas": link.get("focus_areas"),
"weight": 0.0,
"occurrence_count": 0,
- "primary_link_count": 0,
"exercises": {},
}
acc = skill_acc[sid]
acc["weight"] += contribution
acc["occurrence_count"] += occ_count
- if link.get("is_primary"):
- acc["primary_link_count"] += occ_count
ex_key = str(eid)
if ex_key not in acc["exercises"]:
acc["exercises"][ex_key] = {
@@ -173,7 +210,6 @@ def compute_skill_profile(
"weight": _round2(acc["weight"]),
"share_percent": _round2(share),
"occurrence_count": acc["occurrence_count"],
- "primary_link_count": acc["primary_link_count"],
"top_exercises": ex_list,
}
)
@@ -193,7 +229,7 @@ def compute_skill_profile(
unique_exercises = len(exercise_meta)
return {
"computed_at": datetime.now(timezone.utc).isoformat(),
- "scoring_version": "1.0",
+ "scoring_version": "1.1",
"total_weight": _round2(total_weight),
"exercise_occurrence_count": total_occurrences,
"distinct_exercise_count": unique_exercises,
@@ -221,7 +257,7 @@ def fetch_exercise_skills_bulk(
JOIN exercises e ON e.id = es.exercise_id
WHERE es.exercise_id IN ({ph})
AND (s.status = 'active' OR s.status IS NULL)
- ORDER BY es.exercise_id, es.is_primary DESC, es.skill_id
+ ORDER BY es.exercise_id, s.name, es.skill_id
""",
ids,
)
diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
index e7640ab..df3695c 100644
--- a/backend/tests/test_skill_scoring.py
+++ b/backend/tests/test_skill_scoring.py
@@ -3,13 +3,30 @@ from skill_scoring import (
ExerciseOccurrence,
compute_skill_profile,
match_score_for_skill_ids,
+ _level_range_multiplier,
_skill_link_multiplier,
)
-def test_skill_link_multiplier_primary_and_intensity():
- assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2
- assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85
+def test_skill_link_multiplier_intensity_and_levels():
+ assert _skill_link_multiplier(intensity="hoch") == 1.2
+ assert _skill_link_multiplier(intensity="niedrig") == 0.85
+ wide = _skill_link_multiplier(
+ intensity="mittel",
+ required_level="basis",
+ target_level="optimierung",
+ )
+ narrow = _skill_link_multiplier(
+ intensity="mittel",
+ required_level="grundlagen",
+ target_level="grundlagen",
+ )
+ assert wide > narrow
+
+
+def test_level_range_multiplier_span():
+ assert _level_range_multiplier(None, None) == 1.0
+ assert _level_range_multiplier("aufbau", "fortgeschritten") > _level_range_multiplier("basis", "basis")
def test_compute_skill_profile_aggregates_weights():
@@ -23,21 +40,24 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 10,
"skill_name": "Distanz",
"category": "kihon",
- "is_primary": True,
"intensity": "hoch",
+ "required_level": "grundlagen",
+ "target_level": "aufbau",
"exercise_title": "Übung A",
},
{
"skill_id": 11,
"skill_name": "Balance",
"category": "kihon",
- "is_primary": False,
- "intensity": "mittel",
+ "intensity": "niedrig",
+ "required_level": "basis",
+ "target_level": "basis",
"exercise_title": "Übung A",
},
],
}
profile = compute_skill_profile(occurrences, skills_map)
+ assert profile["scoring_version"] == "1.1"
assert profile["exercise_occurrence_count"] == 2
assert profile["distinct_exercise_count"] == 1
assert len(profile["skills"]) == 2
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index 983bd08..51c1cf8 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -684,7 +684,7 @@ export default function ExerciseProgressionGraphPanel({
<>
- {skill.primary_link_count > 0 ? (
-
- {skill.primary_link_count}× als Primär-Fähigkeit in Übungen
-
- ) : null}
)
}
@@ -34,7 +29,7 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
- hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).',
+ hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis).',
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 322660f..141e5c1 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() {
{!isNew ? (
Date: Thu, 21 May 2026 08:37:58 +0200
Subject: [PATCH 04/10] Update Skill Scoring Specification and Implementation
to v1.2
- Enhanced the skill scoring system with category grouping and a universal scale for improved comparability across programs.
- Introduced new calculations for artifact share percentage and universal percent, allowing for a more nuanced understanding of skill contributions.
- Updated the API to reflect changes in the skill profile structure, including main category and top skill details.
- Improved frontend components to display skills by main category, enhancing user experience in skill discovery and profile visualization.
- Adjusted tests to validate the new scoring logic and ensure accurate representation of skills and their weights.
---
.claude/docs/technical/SKILL_SCORING_SPEC.md | 10 +-
backend/routers/skill_profiles.py | 113 ++++++--
backend/skill_scoring.py | 264 ++++++++++++++++--
backend/tests/test_skill_scoring.py | 39 ++-
frontend/src/app.css | 59 ++++
.../components/skills/SkillDiscoveryPanel.jsx | 84 ++++--
.../components/skills/SkillProfilePanel.jsx | 172 ++++++++----
.../TrainingFrameworkProgramEditPage.jsx | 2 +-
8 files changed, 604 insertions(+), 139 deletions(-)
diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
index 8e6e852..eda832e 100644
--- a/.claude/docs/technical/SKILL_SCORING_SPEC.md
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -1,7 +1,7 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
**Stand:** 2026-05-20
-**Status:** Variante A (regelbasiert) umgesetzt — **v1.1** (Intensität + Stufen-Spanne, ohne Primär)
+**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
## Ziel
@@ -55,8 +55,12 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
Aggregation:
-- Summe pro `skill_id` → `weight`
-- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil)
+- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
+- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
+- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
+- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ)
+
+Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
## API
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
index 49474cd..6253cab 100644
--- a/backend/routers/skill_profiles.py
+++ b/backend/routers/skill_profiles.py
@@ -17,6 +17,7 @@ from skill_scoring import (
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
+ compute_corpus_skill_max_weights,
compute_skill_profile,
match_score_for_skill_ids,
profile_for_occurrences,
@@ -69,6 +70,13 @@ def framework_program_skill_profile(
)
slots_raw = [r2d(r) for r in cur.fetchall()]
+ ref_max = compute_corpus_skill_max_weights(
+ cur,
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
@@ -89,7 +97,11 @@ def framework_program_skill_profile(
all_occurrences.extend(slot_occ)
else:
slot_occ = []
- slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile()
+ slot_profile = (
+ profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max)
+ if slot_occ
+ else _empty_profile()
+ )
slot_profiles.append(
{
"slot_id": slot["id"],
@@ -101,12 +113,20 @@ def framework_program_skill_profile(
}
)
- overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile()
+ overall = (
+ profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max)
+ if all_occurrences
+ else _empty_profile()
+ )
return {
"artifact_type": "framework_program",
"artifact_id": framework_id,
"artifact_title": row.get("title"),
+ "reference_scale": {
+ "skills_in_corpus": len(ref_max),
+ "description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
+ },
"overall": overall,
"slots": slot_profiles,
}
@@ -122,12 +142,25 @@ def training_module_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
+ ref_max = compute_corpus_skill_max_weights(
+ cur,
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
occurrences = collect_module_exercise_occurrences(cur, module_id)
- overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile()
+ overall = (
+ profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
+ if occurrences
+ else _empty_profile()
+ )
return {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
+ "reference_scale": {
+ "skills_in_corpus": len(ref_max),
+ },
"overall": overall,
}
@@ -142,22 +175,34 @@ def progression_graph_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
+ ref_max = compute_corpus_skill_max_weights(
+ cur,
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
- overall = profile_for_occurrences(
- cur, occurrences, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
- ) if occurrences else _empty_profile()
+ overall = (
+ profile_for_occurrences(
+ cur,
+ occurrences,
+ default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
+ reference_max_by_skill=ref_max,
+ )
+ if occurrences
+ else _empty_profile()
+ )
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
+ "reference_scale": {
+ "skills_in_corpus": len(ref_max),
+ },
"overall": overall,
}
-def _empty_profile() -> Dict[str, Any]:
- return compute_skill_profile([], {})
-
-
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
@@ -232,11 +277,8 @@ def skill_discovery_suggestions(
"path": f"/planning/framework-programs/{fid}",
"match": match,
"skill_profile_summary": {
- "total_weight": prof.get("total_weight"),
- "top_skills": [
- {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
- for s in (prof.get("skills") or [])[:5]
- ],
+ "total_score": prof.get("total_score"),
+ "top_by_category": _top_categories_summary(prof),
},
}
)
@@ -279,11 +321,8 @@ def skill_discovery_suggestions(
"path": f"/planning/training-modules/{mid}",
"match": match,
"skill_profile_summary": {
- "total_weight": prof.get("total_weight"),
- "top_skills": [
- {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
- for s in (prof.get("skills") or [])[:5]
- ],
+ "total_score": prof.get("total_score"),
+ "top_by_category": _top_categories_summary(prof),
},
}
)
@@ -328,20 +367,14 @@ def skill_discovery_suggestions(
"path": None,
"match": match,
"skill_profile_summary": {
- "total_weight": prof.get("total_weight"),
- "top_skills": [
- {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
- for s in (prof.get("skills") or [])[:5]
- ],
+ "total_score": prof.get("total_score"),
+ "top_by_category": _top_categories_summary(prof),
},
}
)
results.sort(
- key=lambda x: (
- -float(x.get("match", {}).get("match_weight") or 0),
- -(float(x.get("match", {}).get("match_percent") or 0)),
- )
+ key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
)
return {
"skill_ids": wanted,
@@ -350,5 +383,27 @@ def skill_discovery_suggestions(
}
+def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
+ """Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer."""
+ out: List[Dict[str, Any]] = []
+ for mc in profile.get("by_main_category") or []:
+ for cat in mc.get("categories") or []:
+ top = cat.get("top_skill")
+ if not top:
+ continue
+ out.append(
+ {
+ "main_category_name": mc.get("main_category_name"),
+ "category_name": cat.get("category_name"),
+ "skill_id": top.get("skill_id"),
+ "skill_name": top.get("skill_name"),
+ "score": top.get("score") or top.get("weight"),
+ }
+ )
+ if len(out) >= limit:
+ return out
+ return out
+
+
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 90fdd04..8340a21 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -116,11 +116,93 @@ def _round2(val: float) -> float:
return round(val, 2)
+def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht."""
+ main_map: Dict[int, Dict[str, Any]] = {}
+
+ for sk in skills_out:
+ mc_id = int(sk.get("main_category_id") or 0)
+ mc_name = (sk.get("main_category_name") or "").strip() or "—"
+ cat_id = int(sk.get("category_id") or 0)
+ cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or "—"
+
+ if mc_id not in main_map:
+ main_map[mc_id] = {
+ "main_category_id": mc_id if mc_id else None,
+ "main_category_name": mc_name,
+ "weight": 0.0,
+ "categories": {},
+ }
+ main = main_map[mc_id]
+ main["weight"] += float(sk.get("weight") or 0)
+
+ if cat_id not in main["categories"]:
+ main["categories"][cat_id] = {
+ "category_id": cat_id if cat_id else None,
+ "category_name": cat_name,
+ "weight": 0.0,
+ "skills": [],
+ }
+ cat = main["categories"][cat_id]
+ cat["weight"] += float(sk.get("weight") or 0)
+ cat["skills"].append(sk)
+
+ result: List[Dict[str, Any]] = []
+ for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")):
+ cats_out: List[Dict[str, Any]] = []
+ for cat in sorted(
+ mc["categories"].values(),
+ key=lambda x: (-x["weight"], x.get("category_name") or ""),
+ ):
+ cat_skills = sorted(
+ cat["skills"],
+ key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""),
+ )
+ top = cat_skills[0] if cat_skills else None
+ cats_out.append(
+ {
+ "category_id": cat["category_id"],
+ "category_name": cat["category_name"],
+ "weight": _round2(cat["weight"]),
+ "skills_count": len(cat_skills),
+ "top_skill": top,
+ }
+ )
+ result.append(
+ {
+ "main_category_id": mc["main_category_id"],
+ "main_category_name": mc["main_category_name"],
+ "weight": _round2(mc["weight"]),
+ "categories": cats_out,
+ }
+ )
+ return result
+
+
+def _apply_reference_universal_percent(
+ skills_out: List[Dict[str, Any]],
+ reference_max_by_skill: Optional[Dict[int, float]] = None,
+) -> None:
+ """
+ Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte).
+ """
+ if not reference_max_by_skill:
+ for sk in skills_out:
+ sk["universal_percent"] = None
+ return
+ for sk in skills_out:
+ sid = int(sk["skill_id"])
+ ref = float(reference_max_by_skill.get(sid) or 0)
+ w = float(sk.get("weight") or 0)
+ sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None
+
+
def compute_skill_profile(
occurrences: Sequence[ExerciseOccurrence],
skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
*,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+ reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]:
"""
Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
@@ -168,7 +250,11 @@ def compute_skill_profile(
skill_acc[sid] = {
"skill_id": sid,
"skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
- "category": link.get("category"),
+ "category": link.get("category_name") or link.get("category"),
+ "category_id": link.get("category_id"),
+ "category_name": link.get("category_name") or link.get("category"),
+ "main_category_id": link.get("main_category_id"),
+ "main_category_name": link.get("main_category_name"),
"focus_areas": link.get("focus_areas"),
"weight": 0.0,
"occurrence_count": 0,
@@ -205,9 +291,15 @@ def compute_skill_profile(
{
"skill_id": sid,
"skill_name": acc["skill_name"],
- "category": acc.get("category"),
+ "category": acc.get("category_name") or acc.get("category"),
+ "category_id": acc.get("category_id"),
+ "category_name": acc.get("category_name") or acc.get("category"),
+ "main_category_id": acc.get("main_category_id"),
+ "main_category_name": acc.get("main_category_name"),
"focus_areas": acc.get("focus_areas"),
"weight": _round2(acc["weight"]),
+ "score": _round2(acc["weight"]),
+ "artifact_share_percent": _round2(share),
"share_percent": _round2(share),
"occurrence_count": acc["occurrence_count"],
"top_exercises": ex_list,
@@ -215,27 +307,32 @@ def compute_skill_profile(
)
skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or ""))
- by_category: Dict[str, float] = defaultdict(float)
- for sk in skills_out:
- cat = (sk.get("category") or "").strip() or "—"
- by_category[cat] += sk["weight"]
- category_rows = []
- for cat, w in sorted(by_category.items(), key=lambda x: (-x[1], x[0])):
- share = (w / total_weight * 100.0) if total_weight > 0 else 0.0
- category_rows.append(
- {"category": cat, "weight": _round2(w), "share_percent": _round2(share)}
- )
+ _apply_reference_universal_percent(skills_out, reference_max_by_skill)
+
+ by_main_category = _build_by_main_category(skills_out)
+ for mc in by_main_category:
+ for cat in mc.get("categories") or []:
+ top = cat.get("top_skill")
+ if top and reference_max_by_skill:
+ sid = int(top["skill_id"])
+ ref = float(reference_max_by_skill.get(sid) or 0)
+ if ref > 0:
+ top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
unique_exercises = len(exercise_meta)
return {
"computed_at": datetime.now(timezone.utc).isoformat(),
- "scoring_version": "1.1",
+ "scoring_version": "1.2",
+ "score_unit": "weighted_minutes",
+ "score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)",
"total_weight": _round2(total_weight),
+ "total_score": _round2(total_weight),
"exercise_occurrence_count": total_occurrences,
"distinct_exercise_count": unique_exercises,
"exercises_with_skills_count": len(exercises_with_skills),
"skills": skills_out,
- "by_category": category_rows,
+ "by_main_category": by_main_category,
+ "has_reference_scale": bool(reference_max_by_skill),
}
@@ -250,10 +347,15 @@ def fetch_exercise_skills_bulk(
f"""
SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity,
es.development_contribution, es.required_level, es.target_level,
- s.name AS skill_name, s.category, s.focus_areas,
+ s.name AS skill_name, s.category,
+ sc.id AS category_id, sc.name AS category_name,
+ mc.id AS main_category_id, mc.name AS main_category_name,
+ s.focus_areas,
e.title AS exercise_title
FROM exercise_skills es
JOIN skills s ON s.id = es.skill_id
+ LEFT JOIN skill_categories sc ON sc.id = s.category_id
+ LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id)
JOIN exercises e ON e.id = es.exercise_id
WHERE es.exercise_id IN ({ph})
AND (s.status = 'active' OR s.status IS NULL)
@@ -346,21 +448,145 @@ def profile_for_occurrences(
occurrences: Sequence[ExerciseOccurrence],
*,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+ reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]:
eids = [o.exercise_id for o in occurrences]
skills_map = fetch_exercise_skills_bulk(cur, eids)
return compute_skill_profile(
- occurrences, skills_map, default_item_minutes=default_item_minutes
+ occurrences,
+ skills_map,
+ default_item_minutes=default_item_minutes,
+ reference_max_by_skill=reference_max_by_skill,
)
+def merge_skill_weights_into_max(
+ target: Dict[int, float],
+ profile: Dict[str, Any],
+) -> None:
+ for sk in profile.get("skills") or []:
+ sid = int(sk["skill_id"])
+ w = float(sk.get("weight") or 0)
+ if w > target.get(sid, 0.0):
+ target[sid] = w
+
+
+def compute_corpus_skill_max_weights(
+ cur,
+ *,
+ profile_id: int,
+ role: Optional[str],
+ effective_club_id: Optional[int],
+ limit_per_type: int = 50,
+) -> Dict[int, float]:
+ """
+ Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte.
+ Basis für universal_percent (Skala über alle Programme).
+ """
+ from tenant_context import library_content_visibility_sql
+
+ max_by_skill: Dict[int, float] = {}
+
+ def scan_frameworks():
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="fp",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT fp.id FROM training_framework_programs fp
+ WHERE ({vis_clause})
+ ORDER BY fp.updated_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (*vis_params, limit_per_type),
+ )
+ for row in cur.fetchall():
+ fid = int(row["id"])
+ cur.execute(
+ """
+ SELECT tu.id
+ FROM training_framework_slots s
+ INNER JOIN training_units tu ON tu.framework_slot_id = s.id
+ WHERE s.framework_program_id = %s
+ """,
+ (fid,),
+ )
+ occ: List[ExerciseOccurrence] = []
+ for u in cur.fetchall():
+ occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
+ if not occ:
+ continue
+ prof = profile_for_occurrences(cur, occ)
+ merge_skill_weights_into_max(max_by_skill, prof)
+
+ def scan_modules():
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="m",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT m.id FROM training_modules m
+ WHERE ({vis_clause})
+ ORDER BY m.updated_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (*vis_params, limit_per_type),
+ )
+ for row in cur.fetchall():
+ mid = int(row["id"])
+ occ = collect_module_exercise_occurrences(cur, mid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(cur, occ)
+ merge_skill_weights_into_max(max_by_skill, prof)
+
+ def scan_graphs():
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="g",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT g.id FROM exercise_progression_graphs g
+ WHERE ({vis_clause})
+ ORDER BY g.updated_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (*vis_params, limit_per_type),
+ )
+ for row in cur.fetchall():
+ gid = int(row["id"])
+ occ = collect_progression_graph_exercise_occurrences(cur, gid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(
+ cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ )
+ merge_skill_weights_into_max(max_by_skill, prof)
+
+ scan_frameworks()
+ scan_modules()
+ scan_graphs()
+ return max_by_skill
+
+
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
"""Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge)."""
wanted = {int(x) for x in skill_ids if x is not None}
if not wanted:
return {
"match_weight": 0.0,
+ "match_score": 0.0,
"match_percent": 0.0,
+ "artifact_focus_percent": 0.0,
"matched_skill_ids": [],
"matched_skills": [],
}
@@ -372,10 +598,12 @@ def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int])
if sid in wanted:
matched.append(sk)
match_weight += float(sk.get("weight") or 0)
- match_percent = (match_weight / total * 100.0) if total > 0 else 0.0
+ artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0
return {
"match_weight": _round2(match_weight),
- "match_percent": _round2(match_percent),
+ "match_score": _round2(match_weight),
+ "match_percent": _round2(artifact_focus),
+ "artifact_focus_percent": _round2(artifact_focus),
"matched_skill_ids": [int(m["skill_id"]) for m in matched],
"matched_skills": matched,
}
diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
index df3695c..4e0f0c7 100644
--- a/backend/tests/test_skill_scoring.py
+++ b/backend/tests/test_skill_scoring.py
@@ -40,6 +40,10 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 10,
"skill_name": "Distanz",
"category": "kihon",
+ "category_id": 1,
+ "category_name": "kihon",
+ "main_category_id": 100,
+ "main_category_name": "Technik",
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau",
@@ -49,6 +53,10 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 11,
"skill_name": "Balance",
"category": "kihon",
+ "category_id": 1,
+ "category_name": "kihon",
+ "main_category_id": 100,
+ "main_category_name": "Technik",
"intensity": "niedrig",
"required_level": "basis",
"target_level": "basis",
@@ -57,13 +65,40 @@ def test_compute_skill_profile_aggregates_weights():
],
}
profile = compute_skill_profile(occurrences, skills_map)
- assert profile["scoring_version"] == "1.1"
+ assert profile["scoring_version"] == "1.2"
assert profile["exercise_occurrence_count"] == 2
assert profile["distinct_exercise_count"] == 1
assert len(profile["skills"]) == 2
assert profile["skills"][0]["skill_id"] == 10
assert profile["total_weight"] > profile["skills"][1]["weight"]
assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1
+ assert len(profile["by_main_category"]) == 1
+ assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10
+
+
+def test_universal_percent_against_corpus_max():
+ occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)]
+ skills_map = {
+ 1: [
+ {
+ "skill_id": 10,
+ "skill_name": "Koordination",
+ "category_name": "Koordination",
+ "category_id": 2,
+ "main_category_id": 200,
+ "main_category_name": "Körper",
+ },
+ ],
+ }
+ profile = compute_skill_profile(
+ occurrences,
+ skills_map,
+ reference_max_by_skill={10: 100.0},
+ )
+ assert profile["has_reference_scale"] is True
+ assert profile["skills"][0]["universal_percent"] == 50.0
+ top = profile["by_main_category"][0]["categories"][0]["top_skill"]
+ assert top["universal_percent"] == 50.0
def test_match_score_for_skill_ids():
@@ -76,5 +111,7 @@ def test_match_score_for_skill_ids():
}
m = match_score_for_skill_ids(profile, [1])
assert m["match_weight"] == 40.0
+ assert m["match_score"] == 40.0
assert m["match_percent"] == 40.0
+ assert m["artifact_focus_percent"] == 40.0
assert m["matched_skill_ids"] == [1]
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9e693c5..f684c21 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2591,6 +2591,46 @@ html.modal-scroll-locked .app-main {
color: var(--text3);
margin-top: 3px;
}
+.skill-profile__by-category {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.skill-profile__main-cat {
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+}
+.skill-profile__main-cat-title {
+ margin: 0 0 8px;
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--text2);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.skill-profile__cat-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.skill-profile__cat-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.skill-profile__cat-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text3);
+}
+.skill-profile__cat-row {
+ list-style: none;
+}
.skill-profile__categories {
margin-top: 0.85rem;
}
@@ -2744,6 +2784,25 @@ html.modal-scroll-locked .app-main {
font-size: 0.85rem;
color: var(--text2);
}
+.skill-discovery__result-cats {
+ list-style: none;
+ margin: 0 0 8px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 0.8rem;
+ color: var(--text2);
+}
+.skill-discovery__result-cats li {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.skill-discovery__result-cat-name {
+ font-weight: 600;
+ color: var(--text3);
+}
.skill-discovery__result-link {
text-decoration: none;
}
diff --git a/frontend/src/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx
index dd09642..203fdc3 100644
--- a/frontend/src/components/skills/SkillDiscoveryPanel.jsx
+++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx
@@ -8,6 +8,12 @@ const ARTIFACT_LABELS = {
progression_graph: 'Regressionspfad',
}
+function formatScore(value) {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return '0'
+ return n % 1 === 0 ? String(n) : n.toFixed(1)
+}
+
/**
* Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
*/
@@ -60,8 +66,8 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
Planungs-Vorschläge
Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst — Shinkan schlägt passende
- Rahmenprogramme, Trainingsmodule und Regressionspfade aus der Bibliothek vor (gewichtet nach
- Übungs-Verknüpfungen).
+ Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
+ Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
@@ -122,35 +128,51 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
{result?.suggestions?.length > 0 ? (
- {result.suggestions.map((item) => (
-
-
-
- {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
-
-
- Passung {item.match?.match_percent ?? 0}%
-
-
-
- {item.artifact_title || `#${item.artifact_id}`}
-
- {item.match?.matched_skills?.length > 0 ? (
-
- {item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
-
- ) : null}
- {item.path ? (
-
- Öffnen
-
- ) : item.artifact_type === 'progression_graph' ? (
-
- Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten.
-
- ) : null}
-
- ))}
+ {result.suggestions.map((item) => {
+ const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
+ const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
+ const topByCat = item.skill_profile_summary?.top_by_category || []
+ return (
+
+
+
+ {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
+
+
+ Gewicht {formatScore(matchScore)}
+
+
+
+ {item.artifact_title || `#${item.artifact_id}`}
+
+ {item.match?.matched_skills?.length > 0 ? (
+
+ {item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
+ {focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
+
+ ) : null}
+ {topByCat.length > 0 ? (
+
+ {topByCat.slice(0, 4).map((row) => (
+
+ {row.category_name}
+ {row.skill_name}
+
+ ))}
+
+ ) : null}
+ {item.path ? (
+
+ Öffnen
+
+ ) : item.artifact_type === 'progression_graph' ? (
+
+ Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten.
+
+ ) : null}
+
+ )
+ })}
) : result && !loading ? (
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
index 1e90101..c268b06 100644
--- a/frontend/src/components/skills/SkillProfilePanel.jsx
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -1,25 +1,107 @@
import React, { useMemo, useState } from 'react'
-function SkillBar({ skill, maxShare }) {
- const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0
+function skillWeight(skill) {
+ return Number(skill?.weight ?? skill?.score ?? 0)
+}
+
+function formatWeight(value) {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return '0'
+ return n % 1 === 0 ? String(n) : n.toFixed(1)
+}
+
+function barFillPercent(skill, maxWeight, hasReferenceScale) {
+ if (hasReferenceScale && skill?.universal_percent != null) {
+ return Math.min(100, Number(skill.universal_percent))
+ }
+ const w = skillWeight(skill)
+ return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
+}
+
+function metricLabel(skill, hasReferenceScale) {
+ if (hasReferenceScale && skill?.universal_percent != null) {
+ return `${skill.universal_percent}% Bibliothek`
+ }
+ return formatWeight(skillWeight(skill))
+}
+
+function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
+ if (!skill) return null
+ const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return (
-
+
{skill.skill_name}
- {skill.share_percent}%
+
+ {metricLabel(skill, hasReferenceScale)}
+
+ {hasReferenceScale ? (
+
+ Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
+
+ ) : null}
)
}
+function CategoryGroupedProfile({ profile, ariaLabel }) {
+ const groups = profile?.by_main_category || []
+ const hasReferenceScale = Boolean(profile?.has_reference_scale)
+ const maxWeight = useMemo(() => {
+ let max = 1
+ for (const mc of groups) {
+ for (const cat of mc.categories || []) {
+ const w = skillWeight(cat.top_skill)
+ if (w > max) max = w
+ }
+ }
+ return max
+ }, [groups])
+
+ if (!groups.length) return null
+
+ return (
+
+ {groups.map((mc) => (
+
+ {mc.main_category_name}
+
+ {(mc.categories || []).map((cat) => (
+
+ {cat.category_name}
+
+
+ ))}
+
+
+ ))}
+
+ )
+}
+
+function topCategoryBadge(profile) {
+ const parts = []
+ for (const mc of profile?.by_main_category || []) {
+ for (const cat of mc.categories || []) {
+ const top = cat.top_skill
+ if (!top) continue
+ parts.push(`${cat.category_name}: ${top.skill_name}`)
+ if (parts.length >= 2) return parts.join(' · ')
+ }
+ }
+ return parts.join(' · ')
+}
+
/**
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
*/
@@ -29,17 +111,13 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
- hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis).',
+ hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.',
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
- const skills = profile?.skills || []
- const maxShare = useMemo(
- () => Math.max(...skills.map((s) => s.share_percent || 0), 1),
- [skills]
- )
+ const badge = useMemo(() => topCategoryBadge(profile), [profile])
if (loading) {
return (
@@ -61,6 +139,11 @@ export default function SkillProfilePanel({
!profile ||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
+ const categoryCount = (profile?.by_main_category || []).reduce(
+ (n, mc) => n + (mc.categories?.length || 0),
+ 0
+ )
+
return (
{title}
- {!noData && skills.length > 0 ? (
-
- Top: {skills[0].skill_name} ({skills[0].share_percent}%)
-
+ {!noData && badge ? (
+ {badge}
) : null}
{expanded ? '▾' : '▸'}
@@ -101,31 +182,20 @@ export default function SkillProfilePanel({
{profile.distinct_exercise_count} Übungen
- {skills.length} Fähigkeiten
+ {profile.skills?.length ?? 0} Fähigkeiten
- {profile.exercise_occurrence_count} Positionen
+ {categoryCount} Kategorien
+
+
+ {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht
-
- {skills.slice(0, 12).map((sk) => (
-
- ))}
-
-
- {profile.by_category?.length > 1 ? (
-
-
Nach Kategorie
-
- {profile.by_category.slice(0, 6).map((c) => (
-
- {c.category} {c.share_percent}%
-
- ))}
-
-
- ) : null}
+
>
)}
@@ -135,7 +205,7 @@ export default function SkillProfilePanel({
{slots.map((sl) => {
const open = slotOpenId === sl.slot_id
- const top = sl.profile?.skills?.[0]
+ const slotBadge = topCategoryBadge(sl.profile)
return (
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
- {top ? (
-
- {top.skill_name} {top.share_percent}%
-
+ {slotBadge ? (
+ {slotBadge}
) : (
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
)}
- {open && sl.profile?.skills?.length > 0 ? (
-
- {sl.profile.skills.slice(0, 6).map((sk) => (
- x.share_percent || 0),
- 1
- )}
- />
- ))}
-
+ {open && sl.profile?.by_main_category?.length > 0 ? (
+
) : null}
)
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 141e5c1..99f3d8d 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() {
{!isNew ? (
Date: Thu, 21 May 2026 09:05:13 +0200
Subject: [PATCH 05/10] Enhance Skill Scoring and Profile Features
- Updated the skill scoring specification to include club-specific metrics and improved aggregation methods for skill profiles.
- Introduced new API endpoints for batch skill profile summaries, allowing for efficient retrieval of compact skill data.
- Enhanced frontend components to display skill profiles with club comparisons, improving user interaction and visibility of skill strengths.
- Added filtering options for skills in the framework programs, enabling users to refine selections based on training weight relative to club maximums.
- Improved CSS styles for skill profile displays, ensuring a cohesive and user-friendly interface across the application.
---
.claude/docs/technical/SKILL_SCORING_SPEC.md | 4 +-
backend/routers/skill_profiles.py | 200 ++++++--
backend/skill_scoring.py | 435 ++++++++++++++----
backend/tenant_context.py | 27 ++
frontend/src/api/skillProfiles.js | 14 +
frontend/src/app.css | 101 ++++
.../planning/FrameworkProgramListCard.jsx | 33 +-
.../planning/FrameworkProgramsFilterBlock.jsx | 64 ++-
.../components/skills/SkillProfileCompact.jsx | 67 +++
.../skills/SkillProfileFullModal.jsx | 77 ++++
.../components/skills/SkillProfilePanel.jsx | 13 +-
.../TrainingFrameworkProgramsListPage.jsx | 58 ++-
.../src/pages/TrainingModulesListPage.jsx | 62 ++-
.../src/utils/frameworkProgramListHelpers.js | 55 ++-
frontend/src/utils/skillProfileListHelpers.js | 62 +++
15 files changed, 1131 insertions(+), 141 deletions(-)
create mode 100644 frontend/src/components/skills/SkillProfileCompact.jsx
create mode 100644 frontend/src/components/skills/SkillProfileFullModal.jsx
create mode 100644 frontend/src/utils/skillProfileListHelpers.js
diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
index eda832e..17ef945 100644
--- a/.claude/docs/technical/SKILL_SCORING_SPEC.md
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -58,7 +58,9 @@ Aggregation:
- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
-- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ)
+- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
+- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
+- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
index 6253cab..b640213 100644
--- a/backend/routers/skill_profiles.py
+++ b/backend/routers/skill_profiles.py
@@ -17,10 +17,13 @@ from skill_scoring import (
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
+ compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
match_score_for_skill_ids,
profile_for_occurrences,
+ reference_scale_meta,
+ top_categories_summary,
)
from routers.training_framework_programs import _framework_access
@@ -70,12 +73,9 @@ def framework_program_skill_profile(
)
slots_raw = [r2d(r) for r in cur.fetchall()]
- ref_max = compute_corpus_skill_max_weights(
- cur,
- profile_id=profile_id,
- role=role,
- effective_club_id=tenant.effective_club_id,
- )
+ corpus = _load_club_corpus(cur, tenant)
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
@@ -118,14 +118,22 @@ def framework_program_skill_profile(
if all_occurrences
else _empty_profile()
)
+ _enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id)
+ for slot in slot_profiles:
+ _enrich_profile_club_best(
+ slot.get("profile") or {},
+ ref_by_skill,
+ "framework_program",
+ framework_id,
+ )
return {
"artifact_type": "framework_program",
"artifact_id": framework_id,
"artifact_title": row.get("title"),
- "reference_scale": {
- "skills_in_corpus": len(ref_max),
- "description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": {
+ str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
"slots": slot_profiles,
@@ -142,24 +150,23 @@ def training_module_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
- ref_max = compute_corpus_skill_max_weights(
- cur,
- profile_id=profile_id,
- role=role,
- effective_club_id=tenant.effective_club_id,
- )
+ corpus = _load_club_corpus(cur, tenant)
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
if occurrences
else _empty_profile()
)
+ _enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id)
return {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
- "reference_scale": {
- "skills_in_corpus": len(ref_max),
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": {
+ str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
@@ -175,12 +182,9 @@ def progression_graph_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
- ref_max = compute_corpus_skill_max_weights(
- cur,
- profile_id=profile_id,
- role=role,
- effective_club_id=tenant.effective_club_id,
- )
+ corpus = _load_club_corpus(cur, tenant)
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = (
profile_for_occurrences(
@@ -192,17 +196,96 @@ def progression_graph_skill_profile(
if occurrences
else _empty_profile()
)
+ _enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id)
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
- "reference_scale": {
- "skills_in_corpus": len(ref_max),
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": {
+ str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
+@router.post("/skill-profiles/batch-summaries")
+def batch_skill_profile_summaries(
+ data: dict,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ """
+ Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL).
+ Body: { framework_program_ids?: number[], training_module_ids?: number[] }
+ """
+ fp_ids = _parse_id_list(data.get("framework_program_ids"))
+ mod_ids = _parse_id_list(data.get("training_module_ids"))
+ if not fp_ids and not mod_ids:
+ raise HTTPException(
+ status_code=400,
+ detail="framework_program_ids oder training_module_ids erforderlich",
+ )
+
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ summaries: Dict[str, Dict[str, Any]] = {}
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ corpus = compute_club_corpus_reference(
+ cur,
+ profile_id=tenant.profile_id,
+ effective_club_id=tenant.effective_club_id,
+ include_artifact_summaries=True,
+ )
+ ref_by_skill = corpus["ref_by_skill"]
+ all_summaries = corpus.get("artifact_summaries") or {}
+
+ if fp_ids:
+ allowed_fp = []
+ for fid in fp_ids:
+ try:
+ _framework_access(cur, fid, profile_id, role)
+ allowed_fp.append(fid)
+ except HTTPException:
+ pass
+ for fid in allowed_fp:
+ key = f"framework_program:{fid}"
+ if key in all_summaries:
+ summaries[key] = all_summaries[key]
+
+ if mod_ids:
+ allowed_mod = []
+ for mid in mod_ids:
+ try:
+ _module_access(cur, mid, profile_id, role)
+ allowed_mod.append(mid)
+ except HTTPException:
+ pass
+ for mid in allowed_mod:
+ key = f"training_module:{mid}"
+ if key in all_summaries:
+ summaries[key] = all_summaries[key]
+
+ skill_ids_seen: set[int] = set()
+ for summary in summaries.values():
+ for sk in summary.get("skills") or []:
+ if sk.get("skill_id") is not None:
+ skill_ids_seen.add(int(sk["skill_id"]))
+
+ club_best_subset = {
+ str(sid): ref_by_skill[sid]
+ for sid in skill_ids_seen
+ if sid in ref_by_skill
+ }
+
+ return {
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": club_best_subset,
+ "summaries": summaries,
+ }
+
+
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
@@ -278,7 +361,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
- "top_by_category": _top_categories_summary(prof),
+ "top_by_category": top_categories_summary(prof),
},
}
)
@@ -322,7 +405,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
- "top_by_category": _top_categories_summary(prof),
+ "top_by_category": top_categories_summary(prof),
},
}
)
@@ -368,7 +451,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
- "top_by_category": _top_categories_summary(prof),
+ "top_by_category": top_categories_summary(prof),
},
}
)
@@ -405,5 +488,66 @@ def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dic
return out
+def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
+ if not raw:
+ return []
+ if not isinstance(raw, list):
+ raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein")
+ out: List[int] = []
+ for item in raw:
+ try:
+ n = int(item)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None
+ if n > 0 and n not in out:
+ out.append(n)
+ if len(out) >= max_count:
+ break
+ return out
+
+
+def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
+ return compute_club_corpus_reference(
+ cur,
+ profile_id=tenant.profile_id,
+ effective_club_id=tenant.effective_club_id,
+ )
+
+
+def _enrich_profile_club_best(
+ profile: Dict[str, Any],
+ ref_by_skill: Dict[int, Dict[str, Any]],
+ artifact_type: Optional[str] = None,
+ artifact_id: Optional[int] = None,
+) -> None:
+ """Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze)."""
+ if not profile or not ref_by_skill:
+ return
+
+ def attach(sk: Optional[Dict[str, Any]]) -> None:
+ if not sk or sk.get("skill_id") is None:
+ return
+ sid = int(sk["skill_id"])
+ ref = ref_by_skill.get(sid)
+ if not ref:
+ return
+ if (
+ artifact_type
+ and artifact_id is not None
+ and ref.get("artifact_type") == artifact_type
+ and int(ref.get("artifact_id") or 0) == int(artifact_id)
+ ):
+ return
+ w = float(sk.get("weight") or 0)
+ if w < float(ref.get("weight") or 0) - 0.01:
+ sk["club_best"] = ref
+
+ for sk in profile.get("skills") or []:
+ attach(sk)
+ for mc in profile.get("by_main_category") or []:
+ for cat in mc.get("categories") or []:
+ attach(cat.get("top_skill"))
+
+
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 8340a21..77ef858 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -471,6 +471,337 @@ def merge_skill_weights_into_max(
target[sid] = w
+def merge_skill_weights_with_reference(
+ max_by_skill: Dict[int, float],
+ ref_by_skill: Dict[int, Dict[str, Any]],
+ profile: Dict[str, Any],
+ *,
+ artifact_type: str,
+ artifact_id: int,
+ artifact_title: Optional[str] = None,
+) -> None:
+ """Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt."""
+ for sk in profile.get("skills") or []:
+ sid = int(sk["skill_id"])
+ w = float(sk.get("weight") or 0)
+ if w <= 0:
+ continue
+ if w > max_by_skill.get(sid, 0.0):
+ max_by_skill[sid] = w
+ ref_by_skill[sid] = {
+ "artifact_type": artifact_type,
+ "artifact_id": int(artifact_id),
+ "artifact_title": (artifact_title or "").strip() or None,
+ "weight": _round2(w),
+ }
+
+
+def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
+ """Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery)."""
+ out: List[Dict[str, Any]] = []
+ for mc in profile.get("by_main_category") or []:
+ for cat in mc.get("categories") or []:
+ top = cat.get("top_skill")
+ if not top:
+ continue
+ out.append(
+ {
+ "main_category_name": mc.get("main_category_name"),
+ "category_name": cat.get("category_name"),
+ "skill_id": top.get("skill_id"),
+ "skill_name": top.get("skill_name"),
+ "score": top.get("score") or top.get("weight"),
+ "weight": top.get("weight"),
+ "universal_percent": top.get("universal_percent"),
+ }
+ )
+ if len(out) >= limit:
+ return out
+ return out
+
+
+def _apply_reference_to_profile(
+ profile: Dict[str, Any],
+ reference_max_by_skill: Optional[Dict[int, float]],
+) -> None:
+ _apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill)
+ profile["by_main_category"] = _build_by_main_category(profile.get("skills") or [])
+ for mc in profile.get("by_main_category") or []:
+ for cat in mc.get("categories") or []:
+ top = cat.get("top_skill")
+ if top and reference_max_by_skill:
+ sid = int(top["skill_id"])
+ ref = float(reference_max_by_skill.get(sid) or 0)
+ if ref > 0:
+ top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
+ profile["has_reference_scale"] = bool(reference_max_by_skill)
+
+
+def compact_profile_summary(
+ profile: Dict[str, Any],
+ ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
+ *,
+ skills_limit: int = 20,
+) -> Dict[str, Any]:
+ """Leichtgewichtiges Profil für Listen — ohne Übungsdetails."""
+ skills_out: List[Dict[str, Any]] = []
+ for s in profile.get("skills") or []:
+ sid = int(s["skill_id"])
+ w = float(s.get("weight") or 0)
+ entry: Dict[str, Any] = {
+ "skill_id": sid,
+ "skill_name": s.get("skill_name"),
+ "category_name": s.get("category_name") or s.get("category"),
+ "main_category_name": s.get("main_category_name"),
+ "weight": s.get("weight"),
+ "universal_percent": s.get("universal_percent"),
+ }
+ ref = (ref_by_skill or {}).get(sid)
+ if ref and w < float(ref.get("weight") or 0) - 0.01:
+ entry["club_best"] = ref
+ skills_out.append(entry)
+ if len(skills_out) >= skills_limit:
+ break
+ return {
+ "total_score": profile.get("total_score"),
+ "total_weight": profile.get("total_weight"),
+ "exercise_occurrence_count": profile.get("exercise_occurrence_count"),
+ "skills_count": len(profile.get("skills") or []),
+ "top_by_category": top_categories_summary(profile),
+ "skills": skills_out,
+ }
+
+
+def batch_framework_occurrences_by_id(
+ cur, framework_ids: Sequence[int]
+) -> Dict[int, List[ExerciseOccurrence]]:
+ ids = sorted({int(x) for x in framework_ids if x})
+ if not ids:
+ return {}
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(
+ f"""
+ SELECT s.framework_program_id,
+ COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label,
+ tusi.exercise_id,
+ tusi.planned_duration_min
+ FROM training_framework_slots s
+ INNER JOIN training_units tu ON tu.framework_slot_id = s.id
+ INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
+ INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
+ WHERE s.framework_program_id IN ({ph})
+ AND tusi.item_type = 'exercise'
+ AND tusi.exercise_id IS NOT NULL
+ ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index
+ """,
+ ids,
+ )
+ out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
+ for row in cur.fetchall():
+ fid = int(row["framework_program_id"])
+ out[fid].append(
+ ExerciseOccurrence(
+ exercise_id=int(row["exercise_id"]),
+ planned_duration_min=row.get("planned_duration_min"),
+ context_label=row.get("slot_label"),
+ )
+ )
+ return dict(out)
+
+
+def batch_module_occurrences_by_id(
+ cur, module_ids: Sequence[int]
+) -> Dict[int, List[ExerciseOccurrence]]:
+ ids = sorted({int(x) for x in module_ids if x})
+ if not ids:
+ return {}
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(
+ f"""
+ SELECT module_id, exercise_id, planned_duration_min
+ FROM training_module_items
+ WHERE module_id IN ({ph})
+ AND item_type = 'exercise'
+ AND exercise_id IS NOT NULL
+ ORDER BY module_id, order_index
+ """,
+ ids,
+ )
+ out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
+ for row in cur.fetchall():
+ mid = int(row["module_id"])
+ out[mid].append(
+ ExerciseOccurrence(
+ exercise_id=int(row["exercise_id"]),
+ planned_duration_min=row.get("planned_duration_min"),
+ )
+ )
+ return dict(out)
+
+
+def batch_compute_profiles(
+ occ_by_artifact: Dict[int, List[ExerciseOccurrence]],
+ skills_map: Dict[int, List[Dict[str, Any]]],
+ *,
+ reference_max_by_skill: Optional[Dict[int, float]] = None,
+ default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+) -> Dict[int, Dict[str, Any]]:
+ return {
+ aid: compute_skill_profile(
+ occ,
+ skills_map,
+ default_item_minutes=default_item_minutes,
+ reference_max_by_skill=reference_max_by_skill,
+ )
+ for aid, occ in occ_by_artifact.items()
+ }
+
+
+def compute_club_corpus_reference(
+ cur,
+ *,
+ profile_id: int,
+ effective_club_id: Optional[int],
+ include_artifact_summaries: bool = False,
+) -> Dict[str, Any]:
+ """
+ Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein).
+ Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen).
+ """
+ from tenant_context import club_library_visibility_sql
+
+ max_by_skill: Dict[int, float] = {}
+ ref_by_skill: Dict[int, Dict[str, Any]] = {}
+ artifact_count = 0
+ raw_profiles: Dict[str, Dict[str, Any]] = {}
+
+ if effective_club_id is None:
+ return {
+ "club_id": None,
+ "max_by_skill": max_by_skill,
+ "ref_by_skill": ref_by_skill,
+ "artifact_count": 0,
+ "artifact_summaries": {},
+ }
+
+ def ingest(artifact_type: str, artifact_id: int, title: Optional[str], prof: Dict[str, Any]) -> None:
+ nonlocal artifact_count
+ if not prof.get("skills"):
+ return
+ artifact_count += 1
+ merge_skill_weights_with_reference(
+ max_by_skill,
+ ref_by_skill,
+ prof,
+ artifact_type=artifact_type,
+ artifact_id=artifact_id,
+ artifact_title=title,
+ )
+ if include_artifact_summaries:
+ raw_profiles[f"{artifact_type}:{artifact_id}"] = prof
+
+ vis_clause, vis_params = club_library_visibility_sql(
+ alias="fp",
+ profile_id=profile_id,
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT fp.id, fp.title
+ FROM training_framework_programs fp
+ WHERE ({vis_clause})
+ ORDER BY fp.updated_at DESC NULLS LAST
+ """,
+ vis_params,
+ )
+ fw_rows = cur.fetchall()
+ if fw_rows:
+ fw_ids = [int(r["id"]) for r in fw_rows]
+ titles = {int(r["id"]): r.get("title") for r in fw_rows}
+ occ_map = batch_framework_occurrences_by_id(cur, fw_ids)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids)
+ profiles = batch_compute_profiles(occ_map, skills_map)
+ for fid, prof in profiles.items():
+ ingest("framework_program", fid, titles.get(fid), prof)
+
+ vis_clause, vis_params = club_library_visibility_sql(
+ alias="m",
+ profile_id=profile_id,
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT m.id, m.title
+ FROM training_modules m
+ WHERE ({vis_clause})
+ ORDER BY m.updated_at DESC NULLS LAST
+ """,
+ vis_params,
+ )
+ mod_rows = cur.fetchall()
+ if mod_rows:
+ mod_ids = [int(r["id"]) for r in mod_rows]
+ titles = {int(r["id"]): r.get("title") for r in mod_rows}
+ occ_map = batch_module_occurrences_by_id(cur, mod_ids)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids)
+ profiles = batch_compute_profiles(occ_map, skills_map)
+ for mid, prof in profiles.items():
+ ingest("training_module", mid, titles.get(mid), prof)
+
+ vis_clause, vis_params = club_library_visibility_sql(
+ alias="g",
+ profile_id=profile_id,
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT g.id, g.name
+ FROM exercise_progression_graphs g
+ WHERE ({vis_clause})
+ ORDER BY g.updated_at DESC NULLS LAST
+ """,
+ vis_params,
+ )
+ for row in cur.fetchall():
+ gid = int(row["id"])
+ occ = collect_progression_graph_exercise_occurrences(cur, gid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(
+ cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ )
+ ingest("progression_graph", gid, row.get("name"), prof)
+
+ artifact_summaries: Dict[str, Dict[str, Any]] = {}
+ if include_artifact_summaries and raw_profiles:
+ for key, prof in raw_profiles.items():
+ _apply_reference_to_profile(prof, max_by_skill)
+ artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
+
+ return {
+ "club_id": effective_club_id,
+ "max_by_skill": max_by_skill,
+ "ref_by_skill": ref_by_skill,
+ "artifact_count": artifact_count,
+ "artifact_summaries": artifact_summaries,
+ }
+
+
+def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "scope": "club",
+ "club_id": corpus.get("club_id"),
+ "skills_in_corpus": len(corpus.get("max_by_skill") or {}),
+ "artifacts_scanned": corpus.get("artifact_count") or 0,
+ "description": (
+ "universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit "
+ "(nur visibility=club im aktiven Verein)"
+ ),
+ }
+
+
def compute_corpus_skill_max_weights(
cur,
*,
@@ -480,102 +811,16 @@ def compute_corpus_skill_max_weights(
limit_per_type: int = 50,
) -> Dict[int, float]:
"""
- Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte.
- Basis für universal_percent (Skala über alle Programme).
+ Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference).
+ role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte).
"""
- from tenant_context import library_content_visibility_sql
-
- max_by_skill: Dict[int, float] = {}
-
- def scan_frameworks():
- vis_clause, vis_params = library_content_visibility_sql(
- alias="fp",
- profile_id=profile_id,
- role=role,
- effective_club_id=effective_club_id,
- )
- cur.execute(
- f"""
- SELECT fp.id FROM training_framework_programs fp
- WHERE ({vis_clause})
- ORDER BY fp.updated_at DESC NULLS LAST
- LIMIT %s
- """,
- (*vis_params, limit_per_type),
- )
- for row in cur.fetchall():
- fid = int(row["id"])
- cur.execute(
- """
- SELECT tu.id
- FROM training_framework_slots s
- INNER JOIN training_units tu ON tu.framework_slot_id = s.id
- WHERE s.framework_program_id = %s
- """,
- (fid,),
- )
- occ: List[ExerciseOccurrence] = []
- for u in cur.fetchall():
- occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
- if not occ:
- continue
- prof = profile_for_occurrences(cur, occ)
- merge_skill_weights_into_max(max_by_skill, prof)
-
- def scan_modules():
- vis_clause, vis_params = library_content_visibility_sql(
- alias="m",
- profile_id=profile_id,
- role=role,
- effective_club_id=effective_club_id,
- )
- cur.execute(
- f"""
- SELECT m.id FROM training_modules m
- WHERE ({vis_clause})
- ORDER BY m.updated_at DESC NULLS LAST
- LIMIT %s
- """,
- (*vis_params, limit_per_type),
- )
- for row in cur.fetchall():
- mid = int(row["id"])
- occ = collect_module_exercise_occurrences(cur, mid)
- if not occ:
- continue
- prof = profile_for_occurrences(cur, occ)
- merge_skill_weights_into_max(max_by_skill, prof)
-
- def scan_graphs():
- vis_clause, vis_params = library_content_visibility_sql(
- alias="g",
- profile_id=profile_id,
- role=role,
- effective_club_id=effective_club_id,
- )
- cur.execute(
- f"""
- SELECT g.id FROM exercise_progression_graphs g
- WHERE ({vis_clause})
- ORDER BY g.updated_at DESC NULLS LAST
- LIMIT %s
- """,
- (*vis_params, limit_per_type),
- )
- for row in cur.fetchall():
- gid = int(row["id"])
- occ = collect_progression_graph_exercise_occurrences(cur, gid)
- if not occ:
- continue
- prof = profile_for_occurrences(
- cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
- )
- merge_skill_weights_into_max(max_by_skill, prof)
-
- scan_frameworks()
- scan_modules()
- scan_graphs()
- return max_by_skill
+ del role, limit_per_type
+ corpus = compute_club_corpus_reference(
+ cur,
+ profile_id=profile_id,
+ effective_club_id=effective_club_id,
+ )
+ return corpus["max_by_skill"]
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
diff --git a/backend/tenant_context.py b/backend/tenant_context.py
index e1c9309..fe76393 100644
--- a/backend/tenant_context.py
+++ b/backend/tenant_context.py
@@ -107,6 +107,33 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
+def club_library_visibility_sql(
+ *,
+ alias: str,
+ profile_id: int,
+ effective_club_id: Optional[int],
+) -> tuple[str, List[Any]]:
+ """
+ Nur Inhalte des aktiven Vereins (visibility=club, club_id=active).
+ Für Skill-Vergleiche im Vereinskontext — ohne official/private anderer Mandanten.
+ """
+ if effective_club_id is None:
+ return "(1=0)", []
+ return (
+ f"""(
+ {alias}.visibility = 'club'
+ AND {alias}.club_id = %s
+ AND EXISTS (
+ SELECT 1 FROM club_members cm
+ WHERE cm.profile_id = %s
+ AND cm.club_id = {alias}.club_id
+ AND cm.status = 'active'
+ )
+ )""",
+ [effective_club_id, profile_id],
+ )
+
+
@dataclass
class TenantContext:
profile_id: int
diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js
index 7c20c3c..db67033 100644
--- a/frontend/src/api/skillProfiles.js
+++ b/frontend/src/api/skillProfiles.js
@@ -24,3 +24,17 @@ export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
if (opts.limit != null) params.set('limit', String(opts.limit))
return request(`/api/skill-discovery/suggestions?${params.toString()}`)
}
+
+/**
+ * Batch-Summaries für Listen (ein Vereins-Corpus-Scan).
+ * @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload
+ */
+export async function batchSkillProfileSummaries(payload = {}) {
+ return request('/api/skill-profiles/batch-summaries', {
+ method: 'POST',
+ body: JSON.stringify({
+ framework_program_ids: payload.frameworkProgramIds || [],
+ training_module_ids: payload.trainingModuleIds || [],
+ }),
+ })
+}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f684c21..f442a8b 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2690,6 +2690,107 @@ html.modal-scroll-locked .app-main {
font-weight: 500;
}
+.skill-profile-compact {
+ margin-top: 4px;
+}
+.skill-profile-compact__label {
+ display: block;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text3);
+ margin-bottom: 6px;
+}
+.skill-profile-compact__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.skill-profile-compact__item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+}
+.skill-profile-compact__name {
+ font-weight: 600;
+ font-size: 0.86rem;
+ color: var(--text1);
+}
+.skill-profile-compact__metric {
+ font-size: 0.78rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+}
+.skill-profile-compact__best {
+ margin: 0;
+ font-size: 0.72rem;
+ line-height: 1.35;
+}
+.fw-prog-card__section-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+.fw-prog-card__section--skills {
+ margin-top: 0.5rem;
+}
+.fw-import-skill-grid {
+ max-height: 180px;
+ overflow-y: auto;
+}
+.skill-profile-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 24px 12px;
+ overflow-y: auto;
+}
+.skill-profile-modal__backdrop {
+ position: fixed;
+ inset: 0;
+ border: none;
+ background: rgba(0, 0, 0, 0.45);
+ cursor: pointer;
+}
+.skill-profile-modal__panel {
+ position: relative;
+ z-index: 1;
+ width: min(42rem, 100%);
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+ padding: 1rem 1.1rem 1.25rem;
+ margin-top: 2vh;
+}
+.skill-profile-modal__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 0.75rem;
+}
+.skill-profile-modal__title {
+ margin: 0;
+ font-size: 1.05rem;
+}
+.skill-profile-modal__scale {
+ margin: 0.5rem 0 0;
+}
+
.skill-discovery {
padding: 1.15rem 1.25rem;
max-width: 52rem;
diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx
index 2ccd4ed..1d390d3 100644
--- a/frontend/src/components/planning/FrameworkProgramListCard.jsx
+++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx
@@ -1,5 +1,6 @@
import React from 'react'
import NavStateLink from '../NavStateLink'
+import SkillProfileCompact from '../skills/SkillProfileCompact'
import {
frameworkSessionDurationLabel,
splitFrameworkCommaAgg,
@@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) {
/**
* Einzelkarte für die Rahmenprogramm-Bibliothek.
*/
-export default function FrameworkProgramListCard({ row, returnContext, onDelete }) {
+export default function FrameworkProgramListCard({
+ row,
+ returnContext,
+ onDelete,
+ skillSummary = null,
+ skillSummaryLoading = false,
+ skillFilterIds = [],
+ skillDisplayLimit = 6,
+ onShowSkillProfile,
+}) {
const title = (row.title || '').trim() || `Rahmen #${row.id}`
const description = (row.description || '').trim()
const durationLabel = frameworkSessionDurationLabel(row)
@@ -112,6 +122,27 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete
) : null}
+
+
+
Fähigkeiten
+ {onShowSkillProfile ? (
+ onShowSkillProfile(row)}
+ >
+ Vollständiges Profil
+
+ ) : null}
+
+
+
+
filterFrameworkPrograms(programs, filters).length,
- [programs, filters]
+ () => filterFrameworkPrograms(programs, filters, skillSummaries).length,
+ [programs, filters, skillSummaries]
)
const totalCount = (programs || []).length
@@ -43,8 +45,9 @@ export default function FrameworkProgramsFilterBlock({
focusAreas: catalogFocusAreas,
trainingTypes: catalogTrainingTypes,
targetGroups: catalogTargetGroups,
+ skills: catalogSkills,
}),
- [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
+ [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills]
)
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
@@ -285,6 +288,61 @@ export default function FrameworkProgramsFilterBlock({
) : null}
+
+ {catalogSkills.length > 0 ? (
+
+
Fähigkeiten (Vereinsvergleich)
+
+ Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit — ohne
+ Punktewerte eingeben.
+
+
+ {catalogSkills.map((sk) => (
+
+ toggleId('skillIds', sk.id)}
+ disabled={disabled}
+ />
+ {sk.name}
+
+ ))}
+
+ {(filters.skillIds || []).length > 0 ? (
+
+
+ Mindest-Anteil am Vereins-Maximum
+
+ updateFilter({ skillMinClubPercent: Number(e.target.value) || 0 })
+ }
+ >
+ Kein Minimum (nur markieren)
+ mind. 25%
+ mind. 50%
+ mind. 75%
+
+
+
+ Sortierung
+ updateFilter({ skillSort: e.target.value })}
+ >
+ Bibliotheks-Reihenfolge
+ Stärkste gewählte Fähigkeit zuerst
+
+
+
+ ) : null}
+
+ ) : null}
{showHint ? (
diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx
new file mode 100644
index 0000000..7ecc740
--- /dev/null
+++ b/frontend/src/components/skills/SkillProfileCompact.jsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import {
+ artifactPath,
+ artifactTypeLabel,
+ compactSkillDisplayRows,
+ formatClubPercent,
+} from '../../utils/skillProfileListHelpers'
+
+function formatWeight(value) {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return '0'
+ return n % 1 === 0 ? String(n) : n.toFixed(1)
+}
+
+/**
+ * Kompakte Fähigkeiten-Zeile für Listen/Kacheln.
+ */
+export default function SkillProfileCompact({
+ summary,
+ skillIds = [],
+ loading = false,
+ emptyText = 'Noch keine Übungen mit Fähigkeiten',
+ displayLimit = 6,
+ showClubBest = true,
+}) {
+ if (loading) {
+ return
Fähigkeiten werden berechnet…
+ }
+
+ const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit })
+
+ if (!rows.length) {
+ return summary ? (
+ {emptyText}
+ ) : null
+ }
+
+ return (
+
+
Fähigkeiten
+
+ {rows.map((sk) => {
+ const best = sk.club_best
+ const path = showClubBest && best ? artifactPath(best) : null
+ return (
+
+
+ {sk.skill_name}
+
+
+ {formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein
+
+ {showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? (
+
+ Vereins-Top: {artifactTypeLabel(best.artifact_type)} „{best.artifact_title || best.artifact_id}“ (
+ {formatWeight(best.weight)})
+
+ ) : sk.universal_percent >= 100 ? (
+ Stärkste Vereins-Nutzung dieser Fähigkeit
+ ) : null}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx
new file mode 100644
index 0000000..907098a
--- /dev/null
+++ b/frontend/src/components/skills/SkillProfileFullModal.jsx
@@ -0,0 +1,77 @@
+import React, { useEffect, useState } from 'react'
+import api from '../../utils/api'
+import SkillProfilePanel from './SkillProfilePanel'
+
+/**
+ * Vollständiges Fähigkeiten-Profil in einem Modal (Listen-Kontext).
+ */
+export default function SkillProfileFullModal({
+ open,
+ onClose,
+ artifactType = 'framework_program',
+ artifactId,
+ title = 'Fähigkeiten-Profil',
+}) {
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [data, setData] = useState(null)
+
+ useEffect(() => {
+ if (!open || !artifactId) return undefined
+ let cancelled = false
+ setLoading(true)
+ setError('')
+ setData(null)
+ const load =
+ artifactType === 'training_module'
+ ? api.getTrainingModuleSkillProfile(artifactId)
+ : artifactType === 'progression_graph'
+ ? api.getProgressionGraphSkillProfile(artifactId)
+ : api.getFrameworkProgramSkillProfile(artifactId)
+ load
+ .then((res) => {
+ if (!cancelled) setData(res)
+ })
+ .catch((e) => {
+ if (!cancelled) setError(e.message || 'Profil konnte nicht geladen werden')
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [open, artifactId, artifactType])
+
+ if (!open) return null
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ Schließen
+
+
+
+ {data?.reference_scale?.scope === 'club' && !loading ? (
+
+ Vergleichsbasis: {data.reference_scale.artifacts_scanned ?? 0} Vereins-Artefakte (
+ {data.reference_scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
index c268b06..54c7708 100644
--- a/frontend/src/components/skills/SkillProfilePanel.jsx
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -20,7 +20,7 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
function metricLabel(skill, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
- return `${skill.universal_percent}% Bibliothek`
+ return `${skill.universal_percent}% vom Vereins-Maximum`
}
return formatWeight(skillWeight(skill))
}
@@ -43,7 +43,14 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
{hasReferenceScale ? (
- Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
+ Trainingsgewicht {formatWeight(skillWeight(skill))}
+ {skill.club_best ? (
+ <>
+ {' '}
+ · Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
+ {formatWeight(skill.club_best.weight)})
+ >
+ ) : null}
) : null}
@@ -111,7 +118,7 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
- hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.',
+ hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.',
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
index d504c20..048e2a6 100644
--- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
@@ -3,6 +3,7 @@ import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
+import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
@@ -22,12 +23,16 @@ export default function TrainingFrameworkProgramsListPage() {
const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
+ const [catalogSkills, setCatalogSkills] = useState([])
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
+ const [skillSummaries, setSkillSummaries] = useState({})
+ const [summariesLoading, setSummariesLoading] = useState(false)
+ const [profileModal, setProfileModal] = useState(null)
const filteredRows = useMemo(
- () => filterFrameworkPrograms(rows, filters),
- [rows, filters]
+ () => filterFrameworkPrograms(rows, filters, skillSummaries),
+ [rows, filters, skillSummaries]
)
const filterActive = hasActiveFrameworkImportFilters(filters)
@@ -35,22 +40,25 @@ export default function TrainingFrameworkProgramsListPage() {
setLoading(true)
setError('')
try {
- const [list, fa, tt, tg] = await Promise.all([
+ const [list, fa, tt, tg, skills] = await Promise.all([
api.listTrainingFrameworkPrograms(),
api.listFocusAreas({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
+ api.listSkills({ status: 'active' }),
])
setRows(Array.isArray(list) ? list : [])
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
+ setCatalogSkills(Array.isArray(skills) ? skills : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
setCatalogFocusAreas([])
setCatalogTrainingTypes([])
setCatalogTargetGroups([])
+ setCatalogSkills([])
} finally {
setLoading(false)
}
@@ -60,6 +68,29 @@ export default function TrainingFrameworkProgramsListPage() {
load()
}, [load, tenantClubDepKey])
+ useEffect(() => {
+ if (!rows.length) {
+ setSkillSummaries({})
+ return undefined
+ }
+ let cancelled = false
+ setSummariesLoading(true)
+ api
+ .batchSkillProfileSummaries({ frameworkProgramIds: rows.map((r) => r.id) })
+ .then((data) => {
+ if (!cancelled) setSkillSummaries(data?.summaries || {})
+ })
+ .catch(() => {
+ if (!cancelled) setSkillSummaries({})
+ })
+ .finally(() => {
+ if (!cancelled) setSummariesLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [rows, tenantClubDepKey])
+
async function handleDelete(id, title) {
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
try {
@@ -136,6 +167,8 @@ export default function TrainingFrameworkProgramsListPage() {
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
+ catalogSkills={catalogSkills}
+ skillSummaries={skillSummaries}
durationRadioName="fw-list-duration-mode"
className="fw-prog-filter-block--list"
/>
@@ -157,6 +190,17 @@ export default function TrainingFrameworkProgramsListPage() {
row={r}
returnContext={frameworkListReturn}
onDelete={handleDelete}
+ skillSummary={skillSummaries[`framework_program:${r.id}`]}
+ skillSummaryLoading={summariesLoading}
+ skillFilterIds={filters.skillIds || []}
+ skillDisplayLimit={filters.skillDisplayLimit || 10}
+ onShowSkillProfile={(row) =>
+ setProfileModal({
+ artifactType: 'framework_program',
+ artifactId: row.id,
+ title: (row.title || '').trim() || `Rahmen #${row.id}`,
+ })
+ }
/>
))}
@@ -164,6 +208,14 @@ export default function TrainingFrameworkProgramsListPage() {
)}
>
)}
+
+ setProfileModal(null)}
+ artifactType={profileModal?.artifactType}
+ artifactId={profileModal?.artifactId}
+ title={profileModal?.title}
+ />
)
}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
index ecea0db..c8c5754 100644
--- a/frontend/src/pages/TrainingModulesListPage.jsx
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
+import SkillProfileCompact from '../components/skills/SkillProfileCompact'
+import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
@@ -12,6 +14,9 @@ export default function TrainingModulesListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
+ const [skillSummaries, setSkillSummaries] = useState({})
+ const [summariesLoading, setSummariesLoading] = useState(false)
+ const [profileModal, setProfileModal] = useState(null)
const load = useCallback(async () => {
setLoading(true)
@@ -31,6 +36,29 @@ export default function TrainingModulesListPage() {
load()
}, [load, tenantClubDepKey])
+ useEffect(() => {
+ if (!rows.length) {
+ setSkillSummaries({})
+ return undefined
+ }
+ let cancelled = false
+ setSummariesLoading(true)
+ api
+ .batchSkillProfileSummaries({ trainingModuleIds: rows.map((r) => r.id) })
+ .then((data) => {
+ if (!cancelled) setSkillSummaries(data?.summaries || {})
+ })
+ .catch(() => {
+ if (!cancelled) setSkillSummaries({})
+ })
+ .finally(() => {
+ if (!cancelled) setSummariesLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [rows, tenantClubDepKey])
+
async function handleDelete(id, title) {
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
try {
@@ -58,8 +86,7 @@ export default function TrainingModulesListPage() {
Trainingsmodule
- Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als
- lokale Kopie (mit Herkunftsmarkierung).
+ Wiederverwendbare Übungsfolgen für die Trainingsplanung. Fähigkeiten werden im Vereinskontext verglichen.
-
- Sichtbarkeit: {r.visibility || '—'}
-
+
+
+
+
+ setProfileModal({
+ artifactType: 'training_module',
+ artifactId: r.id,
+ title: (r.title || '').trim() || `Modul #${r.id}`,
+ })
+ }
+ >
+ Fähigkeiten-Profil
+
)}
+
+ setProfileModal(null)}
+ artifactType={profileModal?.artifactType}
+ artifactId={profileModal?.artifactId}
+ title={profileModal?.title}
+ />
>
)
}
diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js
index 4621f04..d029cd2 100644
--- a/frontend/src/utils/frameworkProgramListHelpers.js
+++ b/frontend/src/utils/frameworkProgramListHelpers.js
@@ -1,4 +1,9 @@
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
+import {
+ frameworkSkillSummaryKey,
+ maxSelectedSkillClubPercent,
+ skillEntryFromSummary,
+} from './skillProfileListHelpers'
export function frameworkSessionDurationLabel(row) {
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
@@ -109,6 +114,10 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
durationRangeFrom: '',
durationRangeTo: '',
durationPresetMin: null,
+ skillIds: [],
+ skillSort: 'title',
+ skillMinClubPercent: 0,
+ skillDisplayLimit: 10,
}
export function hasActiveFrameworkImportFilters(filters = {}) {
@@ -122,6 +131,9 @@ export function hasActiveFrameworkImportFilters(filters = {}) {
if (String(f.durationRangeTo || '').trim() !== '') return true
}
if (f.durationMode === 'preset' && f.durationPresetMin != null) return true
+ if ((f.skillIds || []).length) return true
+ if (Number(f.skillMinClubPercent) > 0) return true
+ if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
return false
}
@@ -136,6 +148,17 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
const nameById = (list, id) => list?.find((x) => String(x.id) === String(id))?.name || id
+ if ((f.skillIds || []).length) {
+ const names = f.skillIds.map((id) => nameById(catalogs.skills, id))
+ parts.push(`Fähigkeiten: ${names.join(', ')}`)
+ }
+ if (Number(f.skillMinClubPercent) > 0) {
+ parts.push(`mind. ${f.skillMinClubPercent}% vom Vereins-Maximum`)
+ }
+ if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
+ parts.push('Sortierung: Fähigkeiten-Stärke')
+ }
+
if ((f.focusAreaIds || []).length) {
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
parts.push(`Fokus: ${names.join(', ')}`)
@@ -167,14 +190,16 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
/**
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
*/
-export function filterFrameworkPrograms(rows, filters = {}) {
+export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = null) {
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
const q = (f.query || '').trim().toLowerCase()
const focusIds = new Set((f.focusAreaIds || []).map(String))
const typeIds = new Set((f.trainingTypeIds || []).map(String))
const tgIds = new Set((f.targetGroupIds || []).map(String))
+ const skillIds = f.skillIds || []
+ const minClubPct = Number(f.skillMinClubPercent) || 0
- return (rows || []).filter((r) => {
+ let list = (rows || []).filter((r) => {
if (q) {
const blob = [
r.title,
@@ -212,6 +237,32 @@ export function filterFrameworkPrograms(rows, filters = {}) {
return true
})
+
+ if (skillIds.length && skillSummaries) {
+ list = list.filter((r) => {
+ const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
+ if (!summary) return minClubPct === 0
+ return skillIds.some((sid) => {
+ const sk = skillEntryFromSummary(summary, sid)
+ if (!sk) return false
+ const pct = sk.universal_percent
+ if (pct == null) return minClubPct === 0
+ return pct >= minClubPct
+ })
+ })
+ }
+
+ if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
+ list = [...list].sort((a, b) => {
+ const sa = skillSummaries[frameworkSkillSummaryKey(a.id)]
+ const sb = skillSummaries[frameworkSkillSummaryKey(b.id)]
+ const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
+ const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
+ return pb - pa
+ })
+ }
+
+ return list
}
export function frameworkProgramOptionLabel(row) {
diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js
new file mode 100644
index 0000000..63b226d
--- /dev/null
+++ b/frontend/src/utils/skillProfileListHelpers.js
@@ -0,0 +1,62 @@
+export function frameworkSkillSummaryKey(id) {
+ return `framework_program:${id}`
+}
+
+export function moduleSkillSummaryKey(id) {
+ return `training_module:${id}`
+}
+
+export function skillEntryFromSummary(summary, skillId) {
+ if (!summary?.skills) return null
+ return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null
+}
+
+export function maxSelectedSkillClubPercent(summary, skillIds = []) {
+ if (!summary || !skillIds.length) return null
+ let max = null
+ for (const id of skillIds) {
+ const sk = skillEntryFromSummary(summary, id)
+ if (!sk) continue
+ const pct = sk.universal_percent
+ if (pct == null) continue
+ if (max == null || pct > max) max = pct
+ }
+ return max
+}
+
+export function formatClubPercent(value) {
+ if (value == null || !Number.isFinite(Number(value))) return '—'
+ const n = Number(value)
+ return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
+}
+
+export function artifactTypeLabel(type) {
+ if (type === 'framework_program') return 'Rahmenprogramm'
+ if (type === 'training_module') return 'Modul'
+ if (type === 'progression_graph') return 'Regressionspfad'
+ return type || 'Artefakt'
+}
+
+export function artifactPath(ref) {
+ if (!ref) return null
+ if (ref.artifact_type === 'framework_program') {
+ return `/planning/framework-programs/${ref.artifact_id}`
+ }
+ if (ref.artifact_type === 'training_module') {
+ return `/planning/training-modules/${ref.artifact_id}`
+ }
+ return null
+}
+
+/** Zeilen für Kompakt-Anzeige: gewählte Fähigkeiten oder Top je Kategorie. */
+export function compactSkillDisplayRows(summary, { skillIds = [], limit = 6 } = {}) {
+ if (!summary) return []
+ if (skillIds.length) {
+ return skillIds
+ .map((id) => skillEntryFromSummary(summary, id))
+ .filter(Boolean)
+ .sort((a, b) => (b.universal_percent ?? 0) - (a.universal_percent ?? 0))
+ .slice(0, limit)
+ }
+ return (summary.top_by_category || []).slice(0, limit)
+}
--
2.43.0
From 9a0cf7f823fab895010db15616b78ea11f492898 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 09:33:20 +0200
Subject: [PATCH 06/10] Enhance Skill Scoring and Profile Functionality
- Introduced a new function to calculate club-specific skill percentages, ensuring values are capped at 100%.
- Updated skill profile calculations to include indicators for the best club performance per skill.
- Enhanced frontend components to display club best indicators and improved layout for skill profiles.
- Refactored CSS styles for skill profile components, ensuring a more cohesive and user-friendly interface.
- Updated tests to validate new functionality and ensure accurate representation of skill metrics.
---
backend/skill_scoring.py | 39 ++-
backend/tests/test_skill_scoring.py | 12 +-
frontend/src/app.css | 73 ++++--
.../planning/FrameworkProgramListCard.jsx | 4 +-
.../components/skills/SkillProfileCompact.jsx | 86 +++----
.../skills/SkillProfileFullModal.jsx | 2 +
.../components/skills/SkillProfilePanel.jsx | 242 +++++++++++-------
.../src/pages/TrainingModulesListPage.jsx | 4 +-
frontend/src/utils/skillProfileListHelpers.js | 39 ++-
9 files changed, 312 insertions(+), 189 deletions(-)
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 77ef858..9efb3e2 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -179,22 +179,44 @@ def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str,
return result
+def _club_universal_percent(
+ weight: float,
+ corpus_ref: float,
+) -> tuple[Optional[float], bool]:
+ """
+ Anteil am Vereins-Maximum (max. 100 %).
+ effective_ref = max(Korpus-Max, eigenes Gewicht) — verhindert Werte >100 %,
+ wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official).
+ """
+ w = float(weight or 0)
+ ref = float(corpus_ref or 0)
+ if w <= 0:
+ return None, False
+ effective_ref = max(ref, w)
+ pct = min(100.0, w / effective_ref * 100.0)
+ is_best = ref <= 0 or w >= ref - 0.01
+ return _round2(pct), is_best
+
+
def _apply_reference_universal_percent(
skills_out: List[Dict[str, Any]],
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> None:
"""
- Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte).
+ Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %).
"""
if not reference_max_by_skill:
for sk in skills_out:
sk["universal_percent"] = None
+ sk["is_club_best_for_skill"] = False
return
for sk in skills_out:
sid = int(sk["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
w = float(sk.get("weight") or 0)
- sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None
+ pct, is_best = _club_universal_percent(w, ref)
+ sk["universal_percent"] = pct
+ sk["is_club_best_for_skill"] = is_best
def compute_skill_profile(
@@ -316,8 +338,9 @@ def compute_skill_profile(
if top and reference_max_by_skill:
sid = int(top["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
- if ref > 0:
- top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
+ pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
+ top["universal_percent"] = pct
+ top["is_club_best_for_skill"] = is_best
unique_exercises = len(exercise_meta)
return {
@@ -513,6 +536,7 @@ def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict
"score": top.get("score") or top.get("weight"),
"weight": top.get("weight"),
"universal_percent": top.get("universal_percent"),
+ "is_club_best_for_skill": top.get("is_club_best_for_skill"),
}
)
if len(out) >= limit:
@@ -532,8 +556,9 @@ def _apply_reference_to_profile(
if top and reference_max_by_skill:
sid = int(top["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
- if ref > 0:
- top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
+ pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
+ top["universal_percent"] = pct
+ top["is_club_best_for_skill"] = is_best
profile["has_reference_scale"] = bool(reference_max_by_skill)
@@ -559,6 +584,8 @@ def compact_profile_summary(
ref = (ref_by_skill or {}).get(sid)
if ref and w < float(ref.get("weight") or 0) - 0.01:
entry["club_best"] = ref
+ if s.get("is_club_best_for_skill"):
+ entry["is_club_best_for_skill"] = True
skills_out.append(entry)
if len(skills_out) >= skills_limit:
break
diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
index 4e0f0c7..dbe4531 100644
--- a/backend/tests/test_skill_scoring.py
+++ b/backend/tests/test_skill_scoring.py
@@ -3,6 +3,7 @@ from skill_scoring import (
ExerciseOccurrence,
compute_skill_profile,
match_score_for_skill_ids,
+ _club_universal_percent,
_level_range_multiplier,
_skill_link_multiplier,
)
@@ -97,8 +98,15 @@ def test_universal_percent_against_corpus_max():
)
assert profile["has_reference_scale"] is True
assert profile["skills"][0]["universal_percent"] == 50.0
- top = profile["by_main_category"][0]["categories"][0]["top_skill"]
- assert top["universal_percent"] == 50.0
+ assert profile["skills"][0]["is_club_best_for_skill"] is False
+
+
+def test_club_universal_percent_capped_at_100():
+ pct, is_best = _club_universal_percent(150.0, 100.0)
+ assert pct == 100.0
+ assert is_best is True
+ pct2, _ = _club_universal_percent(72.6, 100.0)
+ assert pct2 == 72.6
def test_match_score_for_skill_ids():
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f442a8b..1c90601 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2693,46 +2693,73 @@ html.modal-scroll-locked .app-main {
.skill-profile-compact {
margin-top: 4px;
}
-.skill-profile-compact__label {
- display: block;
- font-size: 0.72rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text3);
- margin-bottom: 6px;
-}
-.skill-profile-compact__list {
+.skill-kpi-grid {
list-style: none;
margin: 0;
padding: 0;
display: flex;
- flex-direction: column;
+ flex-wrap: wrap;
gap: 6px;
}
-.skill-profile-compact__item {
+.skill-kpi-grid--loading,
+.skill-kpi-grid--empty {
+ margin: 0;
+}
+.skill-kpi-tile {
display: flex;
flex-direction: column;
- gap: 2px;
- padding: 6px 8px;
+ gap: 1px;
+ min-width: 5.5rem;
+ max-width: 8.5rem;
+ padding: 5px 8px;
border-radius: 8px;
- background: var(--surface2);
border: 1px solid var(--border);
+ background: var(--surface2);
+ flex: 0 1 auto;
}
-.skill-profile-compact__name {
+.skill-kpi-tile--highlight {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+.skill-kpi-tile--best {
+ box-shadow: inset 0 0 0 1px var(--accent-dark);
+}
+.skill-kpi-tile__cat {
+ font-size: 0.62rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.skill-kpi-tile__name {
+ font-size: 0.76rem;
font-weight: 600;
- font-size: 0.86rem;
color: var(--text1);
+ line-height: 1.2;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
-.skill-profile-compact__metric {
- font-size: 0.78rem;
+.skill-kpi-tile__pct {
+ font-size: 0.72rem;
font-weight: 700;
color: var(--accent-dark);
+ margin-top: 2px;
}
-.skill-profile-compact__best {
- margin: 0;
- font-size: 0.72rem;
- line-height: 1.35;
+.skill-profile__name-cat {
+ font-weight: 500;
+ color: var(--text3);
+}
+.skill-profile--embedded .skill-profile__body {
+ padding: 0;
+ border-top: none;
+}
+.skill-profile--embedded .skill-profile__hint {
+ margin-top: 0;
}
.fw-prog-card__section-head {
display: flex;
diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx
index 1d390d3..7743774 100644
--- a/frontend/src/components/planning/FrameworkProgramListCard.jsx
+++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx
@@ -131,15 +131,15 @@ export default function FrameworkProgramListCard({
className="btn btn-secondary btn-small fw-prog-card__skills-btn"
onClick={() => onShowSkillProfile(row)}
>
- Vollständiges Profil
+ Alle anzeigen
) : null}
diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx
index 7ecc740..0aaa7e2 100644
--- a/frontend/src/components/skills/SkillProfileCompact.jsx
+++ b/frontend/src/components/skills/SkillProfileCompact.jsx
@@ -1,67 +1,55 @@
import React from 'react'
-import {
- artifactPath,
- artifactTypeLabel,
- compactSkillDisplayRows,
- formatClubPercent,
-} from '../../utils/skillProfileListHelpers'
-
-function formatWeight(value) {
- const n = Number(value)
- if (!Number.isFinite(n)) return '0'
- return n % 1 === 0 ? String(n) : n.toFixed(1)
-}
+import { formatClubPercent, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers'
/**
- * Kompakte Fähigkeiten-Zeile für Listen/Kacheln.
+ * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
*/
export default function SkillProfileCompact({
summary,
- skillIds = [],
loading = false,
- emptyText = 'Noch keine Übungen mit Fähigkeiten',
- displayLimit = 6,
- showClubBest = true,
+ emptyText = 'Keine Fähigkeiten',
+ displayLimit = 8,
+ highlightSkillIds = [],
}) {
if (loading) {
- return Fähigkeiten werden berechnet…
+ return (
+
+ …
+
+ )
}
- const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit })
+ const rows = kpiRowsFromSummary(summary, { limit: displayLimit })
+ const highlight = new Set((highlightSkillIds || []).map(String))
if (!rows.length) {
- return summary ? (
- {emptyText}
- ) : null
+ return summary ? {emptyText}
: null
}
return (
-
-
Fähigkeiten
-
- {rows.map((sk) => {
- const best = sk.club_best
- const path = showClubBest && best ? artifactPath(best) : null
- return (
-
-
- {sk.skill_name}
-
-
- {formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein
-
- {showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? (
-
- Vereins-Top: {artifactTypeLabel(best.artifact_type)} „{best.artifact_title || best.artifact_id}“ (
- {formatWeight(best.weight)})
-
- ) : sk.universal_percent >= 100 ? (
- Stärkste Vereins-Nutzung dieser Fähigkeit
- ) : null}
-
- )
- })}
-
-
+
+ {rows.map((row) => {
+ const highlighted = highlight.has(String(row.skill_id))
+ const isBest = row.is_club_best_for_skill || row.universal_percent >= 100
+ return (
+
+ {row.category_name}
+ {row.skill_name}
+
+ {isBest ? '★ ' : ''}
+ {formatClubPercent(row.universal_percent)}
+
+
+ )
+ })}
+
)
}
diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx
index 907098a..cf1d61a 100644
--- a/frontend/src/components/skills/SkillProfileFullModal.jsx
+++ b/frontend/src/components/skills/SkillProfileFullModal.jsx
@@ -63,6 +63,8 @@ export default function SkillProfileFullModal({
loading={loading}
error={error}
title="Vollständiges Profil"
+ displayMode="full"
+ embedded
defaultExpanded
/>
{data?.reference_scale?.scope === 'club' && !loading ? (
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
index 54c7708..cf87dea 100644
--- a/frontend/src/components/skills/SkillProfilePanel.jsx
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -10,6 +10,12 @@ function formatWeight(value) {
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
+function formatClubPercent(value) {
+ if (value == null || !Number.isFinite(Number(value))) return '—'
+ const n = Math.min(100, Number(value))
+ return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
+}
+
function barFillPercent(skill, maxWeight, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return Math.min(100, Number(skill.universal_percent))
@@ -20,18 +26,22 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
function metricLabel(skill, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
- return `${skill.universal_percent}% vom Vereins-Maximum`
+ const best = skill.is_club_best_for_skill ? ' ★' : ''
+ return `${formatClubPercent(skill.universal_percent)} Verein${best}`
}
return formatWeight(skillWeight(skill))
}
-function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
+function SkillRow({ skill, maxWeight, hasReferenceScale }) {
if (!skill) return null
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return (
-
+
+ {skill.category_name ? (
+ {skill.category_name} ·
+ ) : null}
{skill.skill_name}
@@ -43,13 +53,15 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
{hasReferenceScale ? (
- Trainingsgewicht {formatWeight(skillWeight(skill))}
+ Gewicht {formatWeight(skillWeight(skill))}
{skill.club_best ? (
<>
{' '}
· Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
{formatWeight(skill.club_best.weight)})
>
+ ) : skill.is_club_best_for_skill ? (
+ ' · Stärkste Vereins-Nutzung'
) : null}
) : null}
@@ -57,6 +69,11 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
)
}
+function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
+ if (!skill) return null
+ return
+}
+
function CategoryGroupedProfile({ profile, ariaLabel }) {
const groups = profile?.by_main_category || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
@@ -82,11 +99,13 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
{(mc.categories || []).map((cat) => (
{cat.category_name}
-
+
+
+
))}
@@ -96,6 +115,30 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
)
}
+function FullSkillsProfile({ profile, ariaLabel }) {
+ const skills = profile?.skills || []
+ const hasReferenceScale = Boolean(profile?.has_reference_scale)
+ const maxWeight = useMemo(
+ () => Math.max(...skills.map((s) => skillWeight(s)), 1),
+ [skills]
+ )
+
+ if (!skills.length) return null
+
+ return (
+
+ {skills.map((sk) => (
+
+ ))}
+
+ )
+}
+
function topCategoryBadge(profile) {
const parts = []
for (const mc of profile?.by_main_category || []) {
@@ -111,6 +154,7 @@ function topCategoryBadge(profile) {
/**
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
+ * displayMode: 'summary' = Top je Kategorie (Editor), 'full' = alle Fähigkeiten (Modal).
*/
export default function SkillProfilePanel({
profile,
@@ -118,17 +162,25 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
- hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.',
+ hint = '',
defaultExpanded = true,
+ displayMode = 'summary',
+ embedded = false,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
+ const defaultHint =
+ displayMode === 'full'
+ ? 'Alle verknüpften Fähigkeiten nach Trainingsgewicht. Vergleich zum Vereins-Maximum je Fähigkeit (max. 100 %).'
+ : 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum.'
+
+ const hintText = hint || defaultHint
const badge = useMemo(() => topCategoryBadge(profile), [profile])
if (loading) {
return (
-
+
Fähigkeiten-Profil wird berechnet…
)
@@ -136,7 +188,7 @@ export default function SkillProfilePanel({
if (error) {
return (
-
+
)
@@ -151,6 +203,92 @@ export default function SkillProfilePanel({
0
)
+ const body = (
+
+
{hintText}
+
+ {noData ? (
+
+ Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe
+ Fähigkeiten in der Übungsbearbeitung.
+
+ ) : profile.exercises_with_skills_count === 0 ? (
+
+ {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
+ Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
+
+ ) : (
+ <>
+
+
+ {profile.distinct_exercise_count} Übungen
+
+
+ {profile.skills?.length ?? 0} Fähigkeiten
+
+ {displayMode === 'summary' ? (
+
+ {categoryCount} Kategorien
+
+ ) : null}
+
+ {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht
+
+
+
+ {displayMode === 'full' ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {displayMode === 'summary' && slots && slots.length > 0 ? (
+
+
Pro Session
+
+ {slots.map((sl) => {
+ const open = slotOpenId === sl.slot_id
+ const slotBadge = topCategoryBadge(sl.profile)
+ return (
+
+ setSlotOpenId(open ? null : sl.slot_id)}
+ aria-expanded={open}
+ >
+
+ {(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
+
+ {slotBadge ? (
+ {slotBadge}
+ ) : (
+
+ {sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
+
+ )}
+
+ {open && sl.profile?.by_main_category?.length > 0 ? (
+
+ ) : null}
+
+ )
+ })}
+
+
+ ) : null}
+
+ )
+
+ if (embedded) {
+ return
{body}
+ }
+
return (
-
- {expanded ? (
-
-
{hint}
-
- {noData ? (
-
- Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe
- Fähigkeiten in der Übungsbearbeitung.
-
- ) : profile.exercises_with_skills_count === 0 ? (
-
- {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
- Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
-
- ) : (
- <>
-
-
- {profile.distinct_exercise_count} Übungen
-
-
- {profile.skills?.length ?? 0} Fähigkeiten
-
-
- {categoryCount} Kategorien
-
-
- {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht
-
-
-
-
- >
- )}
-
- {slots && slots.length > 0 ? (
-
-
Pro Session
-
- {slots.map((sl) => {
- const open = slotOpenId === sl.slot_id
- const slotBadge = topCategoryBadge(sl.profile)
- return (
-
- setSlotOpenId(open ? null : sl.slot_id)}
- aria-expanded={open}
- >
-
- {(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
-
- {slotBadge ? (
- {slotBadge}
- ) : (
-
- {sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
-
- )}
-
- {open && sl.profile?.by_main_category?.length > 0 ? (
-
- ) : null}
-
- )
- })}
-
-
- ) : null}
-
- ) : null}
+ {expanded ? body : null}
)
}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
index c8c5754..03422d4 100644
--- a/frontend/src/pages/TrainingModulesListPage.jsx
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -141,11 +141,11 @@ export default function TrainingModulesListPage() {
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
-
diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js
index 63b226d..1fdf71c 100644
--- a/frontend/src/utils/skillProfileListHelpers.js
+++ b/frontend/src/utils/skillProfileListHelpers.js
@@ -26,10 +26,34 @@ export function maxSelectedSkillClubPercent(summary, skillIds = []) {
export function formatClubPercent(value) {
if (value == null || !Number.isFinite(Number(value))) return '—'
- const n = Number(value)
+ const n = Math.min(100, Number(value))
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
}
+/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
+export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
+ if (!summary) return []
+ let rows = (summary.top_by_category || []).map((row) => ({
+ skill_id: row.skill_id,
+ skill_name: row.skill_name,
+ category_name: row.category_name,
+ main_category_name: row.main_category_name,
+ weight: row.weight ?? row.score,
+ universal_percent: row.universal_percent,
+ is_club_best_for_skill: row.is_club_best_for_skill,
+ }))
+ if (skillIds.length) {
+ const wanted = new Set(skillIds.map(String))
+ rows = rows.filter((row) => wanted.has(String(row.skill_id)))
+ }
+ return rows.slice(0, limit)
+}
+
+/** @deprecated Nutze kpiRowsFromSummary für Listen */
+export function compactSkillDisplayRows(summary, opts = {}) {
+ return kpiRowsFromSummary(summary, opts)
+}
+
export function artifactTypeLabel(type) {
if (type === 'framework_program') return 'Rahmenprogramm'
if (type === 'training_module') return 'Modul'
@@ -47,16 +71,3 @@ export function artifactPath(ref) {
}
return null
}
-
-/** Zeilen für Kompakt-Anzeige: gewählte Fähigkeiten oder Top je Kategorie. */
-export function compactSkillDisplayRows(summary, { skillIds = [], limit = 6 } = {}) {
- if (!summary) return []
- if (skillIds.length) {
- return skillIds
- .map((id) => skillEntryFromSummary(summary, id))
- .filter(Boolean)
- .sort((a, b) => (b.universal_percent ?? 0) - (a.universal_percent ?? 0))
- .slice(0, limit)
- }
- return (summary.top_by_category || []).slice(0, limit)
-}
--
2.43.0
From 34966b9e846b16777eff79202c470e7d2e72fa13 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 09:42:13 +0200
Subject: [PATCH 07/10] Update Skill Profile Summary and Frontend Components
- Modified the `compact_profile_summary` function to allow for dynamic skill and category limits, enhancing flexibility in profile data retrieval.
- Updated frontend components to display skill weights and scores more effectively, improving user interaction with skill metrics.
- Adjusted CSS styles for skill KPI tiles to better differentiate between score and percentage displays, ensuring a clearer visual representation.
- Refactored utility functions to streamline skill summary handling, enhancing overall code maintainability and performance.
---
backend/routers/skill_profiles.py | 122 ++++++++++++++++--
backend/skill_scoring.py | 10 +-
frontend/src/app.css | 11 +-
.../components/skills/SkillProfileCompact.jsx | 7 +-
.../components/skills/SkillProfilePanel.jsx | 19 ++-
.../src/utils/frameworkProgramListHelpers.js | 14 +-
frontend/src/utils/skillProfileListHelpers.js | 21 ++-
7 files changed, 165 insertions(+), 39 deletions(-)
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
index b640213..3467ceb 100644
--- a/backend/routers/skill_profiles.py
+++ b/backend/routers/skill_profiles.py
@@ -14,12 +14,17 @@ from tenant_context import TenantContext, get_tenant_context, library_content_vi
from skill_scoring import (
GRAPH_DEFAULT_ITEM_MINUTES,
ExerciseOccurrence,
+ batch_compute_profiles,
+ batch_framework_occurrences_by_id,
+ batch_module_occurrences_by_id,
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
+ compact_profile_summary,
compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
+ fetch_exercise_skills_bulk,
match_score_for_skill_ids,
profile_for_occurrences,
reference_scale_meta,
@@ -239,33 +244,31 @@ def batch_skill_profile_summaries(
include_artifact_summaries=True,
)
ref_by_skill = corpus["ref_by_skill"]
- all_summaries = corpus.get("artifact_summaries") or {}
+ allowed_fp: List[int] = []
if fp_ids:
- allowed_fp = []
for fid in fp_ids:
try:
_framework_access(cur, fid, profile_id, role)
allowed_fp.append(fid)
except HTTPException:
pass
- for fid in allowed_fp:
- key = f"framework_program:{fid}"
- if key in all_summaries:
- summaries[key] = all_summaries[key]
+ allowed_mod: List[int] = []
if mod_ids:
- allowed_mod = []
for mid in mod_ids:
try:
_module_access(cur, mid, profile_id, role)
allowed_mod.append(mid)
except HTTPException:
pass
- for mid in allowed_mod:
- key = f"training_module:{mid}"
- if key in all_summaries:
- summaries[key] = all_summaries[key]
+
+ summaries = _merge_batch_summaries(
+ cur,
+ corpus=corpus,
+ allowed_fp=allowed_fp,
+ allowed_mod=allowed_mod,
+ )
skill_ids_seen: set[int] = set()
for summary in summaries.values():
@@ -551,3 +554,100 @@ def _enrich_profile_club_best(
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
+
+
+def _summarize_framework_program(
+ cur,
+ framework_id: int,
+ ref_max: Dict[int, float],
+ ref_by_skill: Dict[int, Dict[str, Any]],
+) -> Dict[str, Any]:
+ cur.execute(
+ """
+ SELECT tu.id
+ FROM training_framework_slots s
+ INNER JOIN training_units tu ON tu.framework_slot_id = s.id
+ WHERE s.framework_program_id = %s
+ """,
+ (int(framework_id),),
+ )
+ occ: List[ExerciseOccurrence] = []
+ for u in cur.fetchall():
+ occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
+ prof = (
+ profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
+ if occ
+ else _empty_profile()
+ )
+ _enrich_profile_club_best(prof, ref_by_skill, "framework_program", int(framework_id))
+ return compact_profile_summary(prof, ref_by_skill)
+
+
+def _summarize_training_module(
+ cur,
+ module_id: int,
+ ref_max: Dict[int, float],
+ ref_by_skill: Dict[int, Dict[str, Any]],
+) -> Dict[str, Any]:
+ occ = collect_module_exercise_occurrences(cur, int(module_id))
+ prof = (
+ profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
+ if occ
+ else _empty_profile()
+ )
+ _enrich_profile_club_best(prof, ref_by_skill, "training_module", int(module_id))
+ return compact_profile_summary(prof, ref_by_skill)
+
+
+def _merge_batch_summaries(
+ cur,
+ *,
+ corpus: Dict[str, Any],
+ allowed_fp: List[int],
+ allowed_mod: List[int],
+) -> Dict[str, Dict[str, Any]]:
+ """Summaries für angeforderte IDs — auch official/private (nicht nur Vereins-Corpus-Cache)."""
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
+ cached = corpus.get("artifact_summaries") or {}
+ out: Dict[str, Dict[str, Any]] = {}
+
+ missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in cached]
+ if missing_fp:
+ occ_map = batch_framework_occurrences_by_id(cur, missing_fp)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
+ profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max)
+ for fid in missing_fp:
+ key = f"framework_program:{fid}"
+ prof = profiles.get(fid) or _empty_profile()
+ _enrich_profile_club_best(prof, ref_by_skill, "framework_program", fid)
+ out[key] = compact_profile_summary(prof, ref_by_skill)
+
+ missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in cached]
+ if missing_mod:
+ occ_map = batch_module_occurrences_by_id(cur, missing_mod)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
+ profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max)
+ for mid in missing_mod:
+ key = f"training_module:{mid}"
+ prof = profiles.get(mid) or _empty_profile()
+ _enrich_profile_club_best(prof, ref_by_skill, "training_module", mid)
+ out[key] = compact_profile_summary(prof, ref_by_skill)
+
+ for fid in allowed_fp:
+ key = f"framework_program:{fid}"
+ if key in cached:
+ out[key] = cached[key]
+ elif key not in out:
+ out[key] = _summarize_framework_program(cur, fid, ref_max, ref_by_skill)
+
+ for mid in allowed_mod:
+ key = f"training_module:{mid}"
+ if key in cached:
+ out[key] = cached[key]
+ elif key not in out:
+ out[key] = _summarize_training_module(cur, mid, ref_max, ref_by_skill)
+
+ return out
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 9efb3e2..6bf5ce0 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -566,9 +566,10 @@ def compact_profile_summary(
profile: Dict[str, Any],
ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
*,
- skills_limit: int = 20,
+ skills_limit: int = 0,
+ category_limit: int = 48,
) -> Dict[str, Any]:
- """Leichtgewichtiges Profil für Listen — ohne Übungsdetails."""
+ """Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten."""
skills_out: List[Dict[str, Any]] = []
for s in profile.get("skills") or []:
sid = int(s["skill_id"])
@@ -579,6 +580,7 @@ def compact_profile_summary(
"category_name": s.get("category_name") or s.get("category"),
"main_category_name": s.get("main_category_name"),
"weight": s.get("weight"),
+ "score": s.get("score") or s.get("weight"),
"universal_percent": s.get("universal_percent"),
}
ref = (ref_by_skill or {}).get(sid)
@@ -587,14 +589,14 @@ def compact_profile_summary(
if s.get("is_club_best_for_skill"):
entry["is_club_best_for_skill"] = True
skills_out.append(entry)
- if len(skills_out) >= skills_limit:
+ if skills_limit > 0 and len(skills_out) >= skills_limit:
break
return {
"total_score": profile.get("total_score"),
"total_weight": profile.get("total_weight"),
"exercise_occurrence_count": profile.get("exercise_occurrence_count"),
"skills_count": len(profile.get("skills") or []),
- "top_by_category": top_categories_summary(profile),
+ "top_by_category": top_categories_summary(profile, limit=category_limit),
"skills": skills_out,
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 1c90601..343fbee 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2744,12 +2744,17 @@ html.modal-scroll-locked .app-main {
-webkit-box-orient: vertical;
overflow: hidden;
}
-.skill-kpi-tile__pct {
- font-size: 0.72rem;
+.skill-kpi-tile__score {
+ font-size: 0.78rem;
font-weight: 700;
- color: var(--accent-dark);
+ color: var(--text1);
margin-top: 2px;
}
+.skill-kpi-tile__pct {
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: var(--accent-dark);
+}
.skill-profile__name-cat {
font-weight: 500;
color: var(--text3);
diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx
index 0aaa7e2..0905752 100644
--- a/frontend/src/components/skills/SkillProfileCompact.jsx
+++ b/frontend/src/components/skills/SkillProfileCompact.jsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { formatClubPercent, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers'
+import { formatClubPercent, formatSkillWeight, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers'
/**
* Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
@@ -8,7 +8,7 @@ export default function SkillProfileCompact({
summary,
loading = false,
emptyText = 'Keine Fähigkeiten',
- displayLimit = 8,
+ displayLimit = 24,
highlightSkillIds = [],
}) {
if (loading) {
@@ -39,10 +39,11 @@ export default function SkillProfileCompact({
(highlighted ? ' skill-kpi-tile--highlight' : '') +
(isBest ? ' skill-kpi-tile--best' : '')
}
- title={`${row.category_name}: ${row.skill_name}`}
+ title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} vom Vereins-Maximum`}
>
{row.category_name}
{row.skill_name}
+ {formatSkillWeight(row.weight ?? row.score)}
{isBest ? '★ ' : ''}
{formatClubPercent(row.universal_percent)}
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
index cf87dea..8a3b45c 100644
--- a/frontend/src/components/skills/SkillProfilePanel.jsx
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -244,7 +244,7 @@ export default function SkillProfilePanel({
>
)}
- {displayMode === 'summary' && slots && slots.length > 0 ? (
+ {slots && slots.length > 0 ? (
Pro Session
@@ -270,11 +270,18 @@ export default function SkillProfilePanel({
)}
- {open && sl.profile?.by_main_category?.length > 0 ? (
-
+ {open && sl.profile?.skills?.length > 0 ? (
+ displayMode === 'full' ? (
+
+ ) : (
+
+ )
) : null}
)
diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js
index d029cd2..985f7e5 100644
--- a/frontend/src/utils/frameworkProgramListHelpers.js
+++ b/frontend/src/utils/frameworkProgramListHelpers.js
@@ -2,7 +2,7 @@ import { formatDurationDisplay, formatSessionDurationRange } from './trainingDur
import {
frameworkSkillSummaryKey,
maxSelectedSkillClubPercent,
- skillEntryFromSummary,
+ summaryHasSkill,
} from './skillProfileListHelpers'
export function frameworkSessionDurationLabel(row) {
@@ -117,7 +117,7 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
skillIds: [],
skillSort: 'title',
skillMinClubPercent: 0,
- skillDisplayLimit: 10,
+ skillDisplayLimit: 24,
}
export function hasActiveFrameworkImportFilters(filters = {}) {
@@ -241,14 +241,8 @@ export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = nul
if (skillIds.length && skillSummaries) {
list = list.filter((r) => {
const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
- if (!summary) return minClubPct === 0
- return skillIds.some((sid) => {
- const sk = skillEntryFromSummary(summary, sid)
- if (!sk) return false
- const pct = sk.universal_percent
- if (pct == null) return minClubPct === 0
- return pct >= minClubPct
- })
+ if (!summary) return false
+ return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
})
}
diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js
index 1fdf71c..dfd2e63 100644
--- a/frontend/src/utils/skillProfileListHelpers.js
+++ b/frontend/src/utils/skillProfileListHelpers.js
@@ -7,8 +7,19 @@ export function moduleSkillSummaryKey(id) {
}
export function skillEntryFromSummary(summary, skillId) {
- if (!summary?.skills) return null
- return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null
+ if (!summary) return null
+ const sid = String(skillId)
+ const fromSkills = (summary.skills || []).find((s) => String(s.skill_id) === sid)
+ if (fromSkills) return fromSkills
+ return (summary.top_by_category || []).find((s) => String(s.skill_id) === sid) || null
+}
+
+export function summaryHasSkill(summary, skillId, minClubPct = 0) {
+ const sk = skillEntryFromSummary(summary, skillId)
+ if (!sk || !(Number(sk.weight) > 0)) return false
+ const pct = sk.universal_percent
+ if (pct == null) return minClubPct === 0
+ return pct >= minClubPct
}
export function maxSelectedSkillClubPercent(summary, skillIds = []) {
@@ -30,6 +41,12 @@ export function formatClubPercent(value) {
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
}
+export function formatSkillWeight(value) {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return '—'
+ return n % 1 === 0 ? String(n) : n.toFixed(1)
+}
+
/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
if (!summary) return []
--
2.43.0
From 2de4c0b7c9bd9315e6fc09b1263b7053d29ee517 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 10:17:22 +0200
Subject: [PATCH 08/10] Refactor Skill Scoring Functions and Enhance Corpus
Handling
- Introduced new helper functions for managing artifact type corpus, improving code organization and readability.
- Updated the `compute_club_corpus_reference` function to utilize the new corpus handling methods, enhancing clarity and maintainability.
- Refactored skill profile functions to leverage the new corpus structure, ensuring consistent data retrieval across different artifact types.
- Improved the handling of visibility clauses for library content, streamlining database queries for skill profiles.
- Enhanced the batch skill profile summary function to aggregate reference data by artifact type, improving performance and accuracy.
---
backend/routers/skill_profiles.py | 116 ++++---
backend/skill_scoring.py | 314 ++++++++++++------
.../ExerciseProgressionGraphPanel.jsx | 1 +
.../planning/FrameworkProgramListCard.jsx | 1 +
.../planning/FrameworkProgramsFilterBlock.jsx | 8 +-
.../components/skills/SkillProfileCompact.jsx | 13 +-
.../skills/SkillProfileFullModal.jsx | 12 +-
.../components/skills/SkillProfilePanel.jsx | 60 ++--
.../TrainingFrameworkProgramEditPage.jsx | 2 +-
frontend/src/pages/TrainingModuleEditPage.jsx | 1 +
.../src/pages/TrainingModulesListPage.jsx | 5 +-
.../src/utils/frameworkProgramListHelpers.js | 2 +-
frontend/src/utils/skillProfileListHelpers.js | 20 ++
13 files changed, 375 insertions(+), 180 deletions(-)
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
index 3467ceb..890ef6a 100644
--- a/backend/routers/skill_profiles.py
+++ b/backend/routers/skill_profiles.py
@@ -21,9 +21,11 @@ from skill_scoring import (
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
compact_profile_summary,
+ compute_planning_corpus_by_type,
compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
+ corpus_for_artifact_type,
fetch_exercise_skills_bulk,
match_score_for_skill_ids,
profile_for_occurrences,
@@ -78,9 +80,10 @@ def framework_program_skill_profile(
)
slots_raw = [r2d(r) for r in cur.fetchall()]
- corpus = _load_club_corpus(cur, tenant)
- ref_max = corpus["max_by_skill"]
- ref_by_skill = corpus["ref_by_skill"]
+ bundle = _load_planning_corpus(cur, tenant)
+ tc = corpus_for_artifact_type(bundle, "framework_program")
+ ref_max = tc["max_by_skill"]
+ ref_by_skill = tc["ref_by_skill"]
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
@@ -136,7 +139,9 @@ def framework_program_skill_profile(
"artifact_type": "framework_program",
"artifact_id": framework_id,
"artifact_title": row.get("title"),
- "reference_scale": reference_scale_meta(corpus),
+ "reference_scale": reference_scale_meta(
+ tc, "framework_program", effective_club_id=tenant.effective_club_id
+ ),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
@@ -155,9 +160,10 @@ def training_module_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
- corpus = _load_club_corpus(cur, tenant)
- ref_max = corpus["max_by_skill"]
- ref_by_skill = corpus["ref_by_skill"]
+ bundle = _load_planning_corpus(cur, tenant)
+ tc = corpus_for_artifact_type(bundle, "training_module")
+ ref_max = tc["max_by_skill"]
+ ref_by_skill = tc["ref_by_skill"]
occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
@@ -169,7 +175,9 @@ def training_module_skill_profile(
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
- "reference_scale": reference_scale_meta(corpus),
+ "reference_scale": reference_scale_meta(
+ tc, "training_module", effective_club_id=tenant.effective_club_id
+ ),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
@@ -187,9 +195,10 @@ def progression_graph_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
- corpus = _load_club_corpus(cur, tenant)
- ref_max = corpus["max_by_skill"]
- ref_by_skill = corpus["ref_by_skill"]
+ bundle = _load_planning_corpus(cur, tenant)
+ tc = corpus_for_artifact_type(bundle, "progression_graph")
+ ref_max = tc["max_by_skill"]
+ ref_by_skill = tc["ref_by_skill"]
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = (
profile_for_occurrences(
@@ -206,7 +215,9 @@ def progression_graph_skill_profile(
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
- "reference_scale": reference_scale_meta(corpus),
+ "reference_scale": reference_scale_meta(
+ tc, "progression_graph", effective_club_id=tenant.effective_club_id
+ ),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
@@ -237,13 +248,13 @@ def batch_skill_profile_summaries(
with get_db() as conn:
cur = get_cursor(conn)
- corpus = compute_club_corpus_reference(
+ bundle = compute_planning_corpus_by_type(
cur,
profile_id=tenant.profile_id,
+ role=role,
effective_club_id=tenant.effective_club_id,
include_artifact_summaries=True,
)
- ref_by_skill = corpus["ref_by_skill"]
allowed_fp: List[int] = []
if fp_ids:
@@ -265,10 +276,13 @@ def batch_skill_profile_summaries(
summaries = _merge_batch_summaries(
cur,
- corpus=corpus,
+ bundle=bundle,
allowed_fp=allowed_fp,
allowed_mod=allowed_mod,
)
+ ref_by_skill = {}
+ for t in ("framework_program", "training_module", "progression_graph"):
+ ref_by_skill.update(corpus_for_artifact_type(bundle, t).get("ref_by_skill") or {})
skill_ids_seen: set[int] = set()
for summary in summaries.values():
@@ -283,7 +297,14 @@ def batch_skill_profile_summaries(
}
return {
- "reference_scale": reference_scale_meta(corpus),
+ "reference_scale_by_type": {
+ t: reference_scale_meta(
+ corpus_for_artifact_type(bundle, t),
+ t,
+ effective_club_id=tenant.effective_club_id,
+ )
+ for t in ("framework_program", "training_module", "progression_graph")
+ },
"club_best_by_skill": club_best_subset,
"summaries": summaries,
}
@@ -313,6 +334,10 @@ def skill_discovery_suggestions(
with get_db() as conn:
cur = get_cursor(conn)
+ planning_bundle = _load_planning_corpus(cur, tenant)
+ fw_ref = corpus_for_artifact_type(planning_bundle, "framework_program")["max_by_skill"]
+ mod_ref = corpus_for_artifact_type(planning_bundle, "training_module")["max_by_skill"]
+ graph_ref = corpus_for_artifact_type(planning_bundle, "progression_graph")["max_by_skill"]
if "framework_program" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
@@ -351,7 +376,7 @@ def skill_discovery_suggestions(
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
if not occ:
continue
- prof = profile_for_occurrences(cur, occ)
+ prof = profile_for_occurrences(cur, occ, reference_max_by_skill=fw_ref)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
@@ -395,7 +420,7 @@ def skill_discovery_suggestions(
occ = collect_module_exercise_occurrences(cur, mid)
if not occ:
continue
- prof = profile_for_occurrences(cur, occ)
+ prof = profile_for_occurrences(cur, occ, reference_max_by_skill=mod_ref)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
@@ -440,7 +465,8 @@ def skill_discovery_suggestions(
if not occ:
continue
prof = profile_for_occurrences(
- cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
+ reference_max_by_skill=graph_ref,
)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
@@ -509,10 +535,11 @@ def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
return out
-def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
- return compute_club_corpus_reference(
+def _load_planning_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
+ return compute_planning_corpus_by_type(
cur,
profile_id=tenant.profile_id,
+ role=tenant.global_role,
effective_club_id=tenant.effective_club_id,
)
@@ -602,52 +629,61 @@ def _summarize_training_module(
def _merge_batch_summaries(
cur,
*,
- corpus: Dict[str, Any],
+ bundle: Dict[str, Any],
allowed_fp: List[int],
allowed_mod: List[int],
) -> Dict[str, Dict[str, Any]]:
- """Summaries für angeforderte IDs — auch official/private (nicht nur Vereins-Corpus-Cache)."""
- ref_max = corpus["max_by_skill"]
- ref_by_skill = corpus["ref_by_skill"]
- cached = corpus.get("artifact_summaries") or {}
+ """Summaries für angeforderte IDs — Referenz je Planungs-Kontext (Typ getrennt)."""
+ fw_tc = corpus_for_artifact_type(bundle, "framework_program")
+ mod_tc = corpus_for_artifact_type(bundle, "training_module")
+ fw_cached = fw_tc.get("artifact_summaries") or {}
+ mod_cached = mod_tc.get("artifact_summaries") or {}
out: Dict[str, Dict[str, Any]] = {}
- missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in cached]
+ fw_ref_max = fw_tc["max_by_skill"]
+ fw_ref_by = fw_tc["ref_by_skill"]
+ missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in fw_cached]
if missing_fp:
occ_map = batch_framework_occurrences_by_id(cur, missing_fp)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
- profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max)
+ profiles = batch_compute_profiles(
+ occ_map, skills_map, reference_max_by_skill=fw_ref_max
+ )
for fid in missing_fp:
key = f"framework_program:{fid}"
prof = profiles.get(fid) or _empty_profile()
- _enrich_profile_club_best(prof, ref_by_skill, "framework_program", fid)
- out[key] = compact_profile_summary(prof, ref_by_skill)
+ _enrich_profile_club_best(prof, fw_ref_by, "framework_program", fid)
+ out[key] = compact_profile_summary(prof, fw_ref_by)
- missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in cached]
+ mod_ref_max = mod_tc["max_by_skill"]
+ mod_ref_by = mod_tc["ref_by_skill"]
+ missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in mod_cached]
if missing_mod:
occ_map = batch_module_occurrences_by_id(cur, missing_mod)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
- profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max)
+ profiles = batch_compute_profiles(
+ occ_map, skills_map, reference_max_by_skill=mod_ref_max
+ )
for mid in missing_mod:
key = f"training_module:{mid}"
prof = profiles.get(mid) or _empty_profile()
- _enrich_profile_club_best(prof, ref_by_skill, "training_module", mid)
- out[key] = compact_profile_summary(prof, ref_by_skill)
+ _enrich_profile_club_best(prof, mod_ref_by, "training_module", mid)
+ out[key] = compact_profile_summary(prof, mod_ref_by)
for fid in allowed_fp:
key = f"framework_program:{fid}"
- if key in cached:
- out[key] = cached[key]
+ if key in fw_cached:
+ out[key] = fw_cached[key]
elif key not in out:
- out[key] = _summarize_framework_program(cur, fid, ref_max, ref_by_skill)
+ out[key] = _summarize_framework_program(cur, fid, fw_ref_max, fw_ref_by)
for mid in allowed_mod:
key = f"training_module:{mid}"
- if key in cached:
- out[key] = cached[key]
+ if key in mod_cached:
+ out[key] = mod_cached[key]
elif key not in out:
- out[key] = _summarize_training_module(cur, mid, ref_max, ref_by_skill)
+ out[key] = _summarize_training_module(cur, mid, mod_ref_max, mod_ref_by)
return out
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 6bf5ce0..972b55f 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -686,34 +686,41 @@ def batch_compute_profiles(
}
-def compute_club_corpus_reference(
+def _empty_type_corpus() -> Dict[str, Any]:
+ return {
+ "max_by_skill": {},
+ "ref_by_skill": {},
+ "artifact_count": 0,
+ "artifact_summaries": {},
+ }
+
+
+def corpus_for_artifact_type(
+ bundle: Dict[str, Any],
+ artifact_type: str,
+) -> Dict[str, Any]:
+ by_type = bundle.get("by_type") or {}
+ return by_type.get(artifact_type) or _empty_type_corpus()
+
+
+def _scan_artifact_type_corpus(
cur,
*,
+ artifact_type: str,
profile_id: int,
+ role: Optional[str],
effective_club_id: Optional[int],
- include_artifact_summaries: bool = False,
+ include_artifact_summaries: bool,
) -> Dict[str, Any]:
- """
- Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein).
- Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen).
- """
- from tenant_context import club_library_visibility_sql
+ """Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek)."""
+ from tenant_context import library_content_visibility_sql
max_by_skill: Dict[int, float] = {}
ref_by_skill: Dict[int, Dict[str, Any]] = {}
artifact_count = 0
raw_profiles: Dict[str, Dict[str, Any]] = {}
- if effective_club_id is None:
- return {
- "club_id": None,
- "max_by_skill": max_by_skill,
- "ref_by_skill": ref_by_skill,
- "artifact_count": 0,
- "artifact_summaries": {},
- }
-
- def ingest(artifact_type: str, artifact_id: int, title: Optional[str], prof: Dict[str, Any]) -> None:
+ def ingest(aid: int, title: Optional[str], prof: Dict[str, Any]) -> None:
nonlocal artifact_count
if not prof.get("skills"):
return
@@ -723,85 +730,91 @@ def compute_club_corpus_reference(
ref_by_skill,
prof,
artifact_type=artifact_type,
- artifact_id=artifact_id,
+ artifact_id=aid,
artifact_title=title,
)
if include_artifact_summaries:
- raw_profiles[f"{artifact_type}:{artifact_id}"] = prof
+ raw_profiles[f"{artifact_type}:{aid}"] = prof
- vis_clause, vis_params = club_library_visibility_sql(
- alias="fp",
- profile_id=profile_id,
- effective_club_id=effective_club_id,
- )
- cur.execute(
- f"""
- SELECT fp.id, fp.title
- FROM training_framework_programs fp
- WHERE ({vis_clause})
- ORDER BY fp.updated_at DESC NULLS LAST
- """,
- vis_params,
- )
- fw_rows = cur.fetchall()
- if fw_rows:
- fw_ids = [int(r["id"]) for r in fw_rows]
- titles = {int(r["id"]): r.get("title") for r in fw_rows}
- occ_map = batch_framework_occurrences_by_id(cur, fw_ids)
- all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
- skills_map = fetch_exercise_skills_bulk(cur, all_eids)
- profiles = batch_compute_profiles(occ_map, skills_map)
- for fid, prof in profiles.items():
- ingest("framework_program", fid, titles.get(fid), prof)
-
- vis_clause, vis_params = club_library_visibility_sql(
- alias="m",
- profile_id=profile_id,
- effective_club_id=effective_club_id,
- )
- cur.execute(
- f"""
- SELECT m.id, m.title
- FROM training_modules m
- WHERE ({vis_clause})
- ORDER BY m.updated_at DESC NULLS LAST
- """,
- vis_params,
- )
- mod_rows = cur.fetchall()
- if mod_rows:
- mod_ids = [int(r["id"]) for r in mod_rows]
- titles = {int(r["id"]): r.get("title") for r in mod_rows}
- occ_map = batch_module_occurrences_by_id(cur, mod_ids)
- all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
- skills_map = fetch_exercise_skills_bulk(cur, all_eids)
- profiles = batch_compute_profiles(occ_map, skills_map)
- for mid, prof in profiles.items():
- ingest("training_module", mid, titles.get(mid), prof)
-
- vis_clause, vis_params = club_library_visibility_sql(
- alias="g",
- profile_id=profile_id,
- effective_club_id=effective_club_id,
- )
- cur.execute(
- f"""
- SELECT g.id, g.name
- FROM exercise_progression_graphs g
- WHERE ({vis_clause})
- ORDER BY g.updated_at DESC NULLS LAST
- """,
- vis_params,
- )
- for row in cur.fetchall():
- gid = int(row["id"])
- occ = collect_progression_graph_exercise_occurrences(cur, gid)
- if not occ:
- continue
- prof = profile_for_occurrences(
- cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ if artifact_type == "framework_program":
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="fp",
+ profile_id=profile_id,
+ role=role or "",
+ effective_club_id=effective_club_id,
)
- ingest("progression_graph", gid, row.get("name"), prof)
+ cur.execute(
+ f"""
+ SELECT fp.id, fp.title
+ FROM training_framework_programs fp
+ WHERE ({vis_clause})
+ ORDER BY fp.updated_at DESC NULLS LAST
+ """,
+ vis_params,
+ )
+ rows = cur.fetchall()
+ if rows:
+ ids = [int(r["id"]) for r in rows]
+ titles = {int(r["id"]): r.get("title") for r in rows}
+ occ_map = batch_framework_occurrences_by_id(cur, ids)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
+ profiles = batch_compute_profiles(occ_map, skills_map)
+ for aid, prof in profiles.items():
+ ingest(aid, titles.get(aid), prof)
+
+ elif artifact_type == "training_module":
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="m",
+ profile_id=profile_id,
+ role=role or "",
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT m.id, m.title
+ FROM training_modules m
+ WHERE ({vis_clause})
+ ORDER BY m.updated_at DESC NULLS LAST
+ """,
+ vis_params,
+ )
+ rows = cur.fetchall()
+ if rows:
+ ids = [int(r["id"]) for r in rows]
+ titles = {int(r["id"]): r.get("title") for r in rows}
+ occ_map = batch_module_occurrences_by_id(cur, ids)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
+ profiles = batch_compute_profiles(occ_map, skills_map)
+ for aid, prof in profiles.items():
+ ingest(aid, titles.get(aid), prof)
+
+ elif artifact_type == "progression_graph":
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="g",
+ profile_id=profile_id,
+ role=role or "",
+ effective_club_id=effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT g.id, g.name
+ FROM exercise_progression_graphs g
+ WHERE ({vis_clause})
+ ORDER BY g.updated_at DESC NULLS LAST
+ """,
+ vis_params,
+ )
+ for row in cur.fetchall():
+ gid = int(row["id"])
+ occ = collect_progression_graph_exercise_occurrences(cur, gid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(
+ cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
+ )
+ ingest(gid, row.get("name"), prof)
artifact_summaries: Dict[str, Dict[str, Any]] = {}
if include_artifact_summaries and raw_profiles:
@@ -810,7 +823,6 @@ def compute_club_corpus_reference(
artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
return {
- "club_id": effective_club_id,
"max_by_skill": max_by_skill,
"ref_by_skill": ref_by_skill,
"artifact_count": artifact_count,
@@ -818,15 +830,106 @@ def compute_club_corpus_reference(
}
-def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]:
+def compute_planning_corpus_by_type(
+ cur,
+ *,
+ profile_id: int,
+ role: Optional[str],
+ effective_club_id: Optional[int],
+ include_artifact_summaries: bool = False,
+) -> Dict[str, Any]:
+ """
+ Referenz je Fähigkeit getrennt nach Planungs-Kontext:
+ Rahmenprogramme, Trainingsmodule und Regressionspfade jeweils für sich,
+ jeweils über die sichtbare Bibliothek (library_content_visibility_sql).
+ """
+ by_type = {
+ "framework_program": _scan_artifact_type_corpus(
+ cur,
+ artifact_type="framework_program",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ include_artifact_summaries=include_artifact_summaries,
+ ),
+ "training_module": _scan_artifact_type_corpus(
+ cur,
+ artifact_type="training_module",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ include_artifact_summaries=include_artifact_summaries,
+ ),
+ "progression_graph": _scan_artifact_type_corpus(
+ cur,
+ artifact_type="progression_graph",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ include_artifact_summaries=include_artifact_summaries,
+ ),
+ }
return {
- "scope": "club",
- "club_id": corpus.get("club_id"),
- "skills_in_corpus": len(corpus.get("max_by_skill") or {}),
- "artifacts_scanned": corpus.get("artifact_count") or 0,
+ "effective_club_id": effective_club_id,
+ "by_type": by_type,
+ }
+
+
+def compute_club_corpus_reference(
+ cur,
+ *,
+ profile_id: int,
+ effective_club_id: Optional[int],
+ include_artifact_summaries: bool = False,
+ role: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Legacy-Hülle — merged summaries über alle Typen (vermeiden für neue Aufrufer)."""
+ bundle = compute_planning_corpus_by_type(
+ cur,
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ include_artifact_summaries=include_artifact_summaries,
+ )
+ tc = corpus_for_artifact_type(bundle, "framework_program")
+ merged_summaries: Dict[str, Dict[str, Any]] = {}
+ for t in ("framework_program", "training_module", "progression_graph"):
+ merged_summaries.update((bundle["by_type"][t].get("artifact_summaries") or {}))
+ return {
+ "club_id": effective_club_id,
+ "max_by_skill": tc["max_by_skill"],
+ "ref_by_skill": tc["ref_by_skill"],
+ "artifact_count": sum(
+ (bundle["by_type"][t].get("artifact_count") or 0)
+ for t in bundle["by_type"]
+ ),
+ "artifact_summaries": merged_summaries,
+ }
+
+
+_ARTIFACT_TYPE_LABELS = {
+ "framework_program": "Rahmenprogrammen",
+ "training_module": "Trainingsmodulen",
+ "progression_graph": "Regressionspfaden",
+}
+
+
+def reference_scale_meta(
+ type_corpus: Dict[str, Any],
+ artifact_type: str,
+ *,
+ effective_club_id: Optional[int] = None,
+) -> Dict[str, Any]:
+ label = _ARTIFACT_TYPE_LABELS.get(artifact_type, artifact_type)
+ return {
+ "scope": "planning_peer",
+ "artifact_type": artifact_type,
+ "effective_club_id": effective_club_id,
+ "skills_in_corpus": len(type_corpus.get("max_by_skill") or {}),
+ "artifacts_scanned": type_corpus.get("artifact_count") or 0,
"description": (
- "universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit "
- "(nur visibility=club im aktiven Verein)"
+ f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit "
+ f"(nicht gemischt mit anderen Planungs-Artefakttypen)"
),
}
@@ -838,18 +941,17 @@ def compute_corpus_skill_max_weights(
role: Optional[str],
effective_club_id: Optional[int],
limit_per_type: int = 50,
+ artifact_type: str = "framework_program",
) -> Dict[int, float]:
- """
- Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference).
- role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte).
- """
- del role, limit_per_type
- corpus = compute_club_corpus_reference(
+ """Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle)."""
+ del limit_per_type
+ bundle = compute_planning_corpus_by_type(
cur,
profile_id=profile_id,
+ role=role,
effective_club_id=effective_club_id,
)
- return corpus["max_by_skill"]
+ return corpus_for_artifact_type(bundle, artifact_type)["max_by_skill"]
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index 51c1cf8..3dceb6c 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -689,6 +689,7 @@ export default function ExerciseProgressionGraphPanel({
loading={skillProfileLoading}
error={skillProfileError}
defaultExpanded
+ artifactType="progression_graph"
/>
Sequenz / Reihe anlegen
diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx
index 7743774..1fbf549 100644
--- a/frontend/src/components/planning/FrameworkProgramListCard.jsx
+++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx
@@ -137,6 +137,7 @@ export default function FrameworkProgramListCard({
0 ? (
-
Fähigkeiten (Vereinsvergleich)
+
Fähigkeiten (Rahmenprogramme)
- Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit — ohne
- Punktewerte eingeben.
+ Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit — nur
+ unter Rahmenprogrammen, nicht gegen Module.
{catalogSkills.map((sk) => (
@@ -312,7 +312,7 @@ export default function FrameworkProgramsFilterBlock({
{(filters.skillIds || []).length > 0 ? (
-
Mindest-Anteil am Vereins-Maximum
+
Mindest-Anteil am Rahmenprogramm-Maximum
{emptyText} : null
@@ -39,14 +46,14 @@ export default function SkillProfileCompact({
(highlighted ? ' skill-kpi-tile--highlight' : '') +
(isBest ? ' skill-kpi-tile--best' : '')
}
- title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} vom Vereins-Maximum`}
+ title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} unter ${peerLabel}`}
>
{row.category_name}
{row.skill_name}
{formatSkillWeight(row.weight ?? row.score)}
{isBest ? '★ ' : ''}
- {formatClubPercent(row.universal_percent)}
+ {formatClubPercent(row.universal_percent)} {peerLabel}
)
diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx
index cf1d61a..ce306b2 100644
--- a/frontend/src/components/skills/SkillProfileFullModal.jsx
+++ b/frontend/src/components/skills/SkillProfileFullModal.jsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import api from '../../utils/api'
+import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
import SkillProfilePanel from './SkillProfilePanel'
/**
@@ -45,6 +46,9 @@ export default function SkillProfileFullModal({
if (!open) return null
+ const peerCountLabel = peerCorpusCountLabel(artifactType)
+ const scale = data?.reference_scale
+
return (
@@ -66,11 +70,13 @@ export default function SkillProfileFullModal({
displayMode="full"
embedded
defaultExpanded
+ artifactType={artifactType}
/>
- {data?.reference_scale?.scope === 'club' && !loading ? (
+ {scale && !loading ? (
- Vergleichsbasis: {data.reference_scale.artifacts_scanned ?? 0} Vereins-Artefakte (
- {data.reference_scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
+ Vergleichsbasis: {scale.artifacts_scanned ?? 0} sichtbare {peerCountLabel} (
+ {scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
+ {scale.description ? ` ${scale.description}` : null}
) : null}
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
index 8a3b45c..48067b2 100644
--- a/frontend/src/components/skills/SkillProfilePanel.jsx
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -1,4 +1,8 @@
import React, { useMemo, useState } from 'react'
+import {
+ formatClubPercent,
+ peerPercentSuffix,
+} from '../../utils/skillProfileListHelpers'
function skillWeight(skill) {
return Number(skill?.weight ?? skill?.score ?? 0)
@@ -10,12 +14,6 @@ function formatWeight(value) {
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
-function formatClubPercent(value) {
- if (value == null || !Number.isFinite(Number(value))) return '—'
- const n = Math.min(100, Number(value))
- return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
-}
-
function barFillPercent(skill, maxWeight, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return Math.min(100, Number(skill.universal_percent))
@@ -24,15 +22,15 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
}
-function metricLabel(skill, hasReferenceScale) {
+function metricLabel(skill, hasReferenceScale, peerLabel) {
if (hasReferenceScale && skill?.universal_percent != null) {
const best = skill.is_club_best_for_skill ? ' ★' : ''
- return `${formatClubPercent(skill.universal_percent)} Verein${best}`
+ return `${formatClubPercent(skill.universal_percent)} ${peerLabel}${best}`
}
return formatWeight(skillWeight(skill))
}
-function SkillRow({ skill, maxWeight, hasReferenceScale }) {
+function SkillRow({ skill, maxWeight, hasReferenceScale, peerLabel }) {
if (!skill) return null
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return (
@@ -45,7 +43,7 @@ function SkillRow({ skill, maxWeight, hasReferenceScale }) {
{skill.skill_name}
- {metricLabel(skill, hasReferenceScale)}
+ {metricLabel(skill, hasReferenceScale, peerLabel)}
@@ -57,11 +55,11 @@ function SkillRow({ skill, maxWeight, hasReferenceScale }) {
{skill.club_best ? (
<>
{' '}
- · Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
+ · Top unter {peerLabel}: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
{formatWeight(skill.club_best.weight)})
>
) : skill.is_club_best_for_skill ? (
- ' · Stärkste Vereins-Nutzung'
+ ' · Stärkster unter ' + peerLabel
) : null}
) : null}
@@ -69,12 +67,19 @@ function SkillRow({ skill, maxWeight, hasReferenceScale }) {
)
}
-function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
+function CategoryTopSkill({ skill, maxWeight, hasReferenceScale, peerLabel }) {
if (!skill) return null
- return
+ return (
+
+ )
}
-function CategoryGroupedProfile({ profile, ariaLabel }) {
+function CategoryGroupedProfile({ profile, ariaLabel, peerLabel }) {
const groups = profile?.by_main_category || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo(() => {
@@ -104,6 +109,7 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
skill={cat.top_skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
+ peerLabel={peerLabel}
/>
@@ -115,7 +121,7 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
)
}
-function FullSkillsProfile({ profile, ariaLabel }) {
+function FullSkillsProfile({ profile, ariaLabel, peerLabel }) {
const skills = profile?.skills || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo(
@@ -133,6 +139,7 @@ function FullSkillsProfile({ profile, ariaLabel }) {
skill={sk}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
+ peerLabel={peerLabel}
/>
))}
@@ -166,14 +173,17 @@ export default function SkillProfilePanel({
defaultExpanded = true,
displayMode = 'summary',
embedded = false,
+ artifactType = 'framework_program',
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
+ const peerLabel = peerPercentSuffix(artifactType)
+
const defaultHint =
displayMode === 'full'
- ? 'Alle verknüpften Fähigkeiten nach Trainingsgewicht. Vergleich zum Vereins-Maximum je Fähigkeit (max. 100 %).'
- : 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum.'
+ ? `Alle verknüpften Fähigkeiten nach Trainingsgewicht. Prozent = Anteil am stärksten sichtbaren Eintrag unter ${peerLabel} je Fähigkeit.`
+ : `Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Prozent vergleicht nur unter ${peerLabel}, nicht mit anderen Planungs-Artefakttypen.`
const hintText = hint || defaultHint
const badge = useMemo(() => topCategoryBadge(profile), [profile])
@@ -237,9 +247,17 @@ export default function SkillProfilePanel({
{displayMode === 'full' ? (
-
+
) : (
-
+
)}
>
)}
@@ -275,11 +293,13 @@ export default function SkillProfilePanel({
) : (
)
) : null}
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 99f3d8d..dee3d3c 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -975,11 +975,11 @@ export default function TrainingFrameworkProgramEditPage() {
{!isNew ? (
) : null}
diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx
index 49289d7..73cd0aa 100644
--- a/frontend/src/pages/TrainingModuleEditPage.jsx
+++ b/frontend/src/pages/TrainingModuleEditPage.jsx
@@ -429,6 +429,7 @@ export default function TrainingModuleEditPage() {
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
+ artifactType="training_module"
/>
) : null}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
index 03422d4..a5b5e60 100644
--- a/frontend/src/pages/TrainingModulesListPage.jsx
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -86,7 +86,7 @@ export default function TrainingModulesListPage() {
Trainingsmodule
- Wiederverwendbare Übungsfolgen für die Trainingsplanung. Fähigkeiten werden im Vereinskontext verglichen.
+ Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter sichtbaren Modulen.
diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js
index 985f7e5..6ecea4f 100644
--- a/frontend/src/utils/frameworkProgramListHelpers.js
+++ b/frontend/src/utils/frameworkProgramListHelpers.js
@@ -153,7 +153,7 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
parts.push(`Fähigkeiten: ${names.join(', ')}`)
}
if (Number(f.skillMinClubPercent) > 0) {
- parts.push(`mind. ${f.skillMinClubPercent}% vom Vereins-Maximum`)
+ parts.push(`mind. ${f.skillMinClubPercent}% vom Rahmenprogramm-Maximum`)
}
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
parts.push('Sortierung: Fähigkeiten-Stärke')
diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js
index dfd2e63..d193c94 100644
--- a/frontend/src/utils/skillProfileListHelpers.js
+++ b/frontend/src/utils/skillProfileListHelpers.js
@@ -47,6 +47,26 @@ export function formatSkillWeight(value) {
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
+const PEER_LABELS = {
+ framework_program: 'Rahmenpr.',
+ training_module: 'Module',
+ progression_graph: 'Pfade',
+}
+
+const PEER_COUNT_LABELS = {
+ framework_program: 'Rahmenprogramme',
+ training_module: 'Module',
+ progression_graph: 'Regressionspfade',
+}
+
+export function peerPercentSuffix(artifactType = 'training_module') {
+ return PEER_LABELS[artifactType] || 'Peers'
+}
+
+export function peerCorpusCountLabel(artifactType = 'training_module') {
+ return PEER_COUNT_LABELS[artifactType] || 'Planungs-Artefakte'
+}
+
/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
if (!summary) return []
--
2.43.0
From 2d187447bbd62f5fc01280d767d0d7c14aa2f8e0 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 10:23:05 +0200
Subject: [PATCH 09/10] Enhance Framework Programs Filtering and UI Components
- Updated the FrameworkProgramsFilterBlock to include a search input and filter modal, improving user interaction and accessibility.
- Refactored CSS styles for filter components to ensure consistent layout and spacing.
- Removed deprecated panel open state management, streamlining the component logic.
- Integrated new filtering capabilities in the TrainingPlanningFrameworkImportModal and TrainingModulesListPage, enhancing the overall filtering experience.
- Improved the display of active filters and results count, providing clearer feedback to users.
---
frontend/src/app.css | 12 +-
.../planning/FrameworkProgramsFilterBlock.jsx | 430 +++++-------------
.../planning/PlanningArtifactFilterModal.jsx | 275 +++++++++++
.../planning/PlanningSkillFilterSection.jsx | 69 +++
.../planning/TrainingModulesFilterBlock.jsx | 148 ++++++
.../TrainingPlanningFrameworkImportModal.jsx | 7 +-
.../TrainingFrameworkProgramsListPage.jsx | 5 +-
.../src/pages/TrainingModulesListPage.jsx | 190 +++++---
.../src/utils/planningArtifactFilterChips.js | 137 ++++++
.../src/utils/trainingModuleListHelpers.js | 57 +++
10 files changed, 945 insertions(+), 385 deletions(-)
create mode 100644 frontend/src/components/planning/PlanningArtifactFilterModal.jsx
create mode 100644 frontend/src/components/planning/PlanningSkillFilterSection.jsx
create mode 100644 frontend/src/components/planning/TrainingModulesFilterBlock.jsx
create mode 100644 frontend/src/utils/planningArtifactFilterChips.js
create mode 100644 frontend/src/utils/trainingModuleListHelpers.js
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 343fbee..bd4581c 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5854,12 +5854,16 @@ html.modal-scroll-locked .app-main {
border-color: var(--border2);
}
-/* Rahmenprogramm-Filter auf Übersichtsseite */
-.fw-prog-filter-block--list {
+/* Rahmenprogramm-/Modul-Filter auf Übersichtsseite */
+.fw-prog-filter-block--list,
+.planning-list-filter-bar {
margin-bottom: 1rem;
}
-.fw-prog-filter-block--list .fw-import-filter-panel {
- margin-top: 0.75rem;
+.planning-list-filter-bar__search {
+ margin-bottom: 0.75rem;
+}
+.planning-list-filter-bar .fw-import-results-bar {
+ margin-top: 0;
}
/* —— Rahmenprogramm-Bibliothek (Liste) —— */
diff --git a/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx
index 983abab..4e77016 100644
--- a/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx
+++ b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx
@@ -1,22 +1,20 @@
-import React, { useMemo } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
import {
collectDistinctSessionDurationsMinutes,
EMPTY_FRAMEWORK_IMPORT_FILTERS,
filterFrameworkPrograms,
hasActiveFrameworkImportFilters,
- summarizeFrameworkImportFilters,
} from '../../utils/frameworkProgramListHelpers'
-import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
+import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips'
+import PlanningArtifactFilterModal from './PlanningArtifactFilterModal'
/**
- * Gemeinsamer Filter für Rahmenprogramm-Liste und Import-Dialog.
+ * Filter-Leiste für Rahmenprogramm-Liste und Import-Dialog (UX wie Übungsliste).
*/
export default function FrameworkProgramsFilterBlock({
programs = [],
filters,
onFiltersChange,
- panelOpen = true,
- onPanelOpenChange,
catalogFocusAreas = [],
catalogTrainingTypes = [],
catalogTargetGroups = [],
@@ -26,11 +24,11 @@ export default function FrameworkProgramsFilterBlock({
durationRadioName = 'fw-duration-mode',
showHint = true,
className = '',
+ searchPlaceholder = 'Suche (Titel, Ziele, Katalog) …',
+ filterModalTitle = 'Rahmenprogramme filtern',
+ resultLabel = 'Rahmenprogramm',
}) {
- const distinctDurations = useMemo(
- () => collectDistinctSessionDurationsMinutes(programs),
- [programs]
- )
+ const [filterModalOpen, setFilterModalOpen] = useState(false)
const matchCount = useMemo(
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
@@ -39,319 +37,143 @@ export default function FrameworkProgramsFilterBlock({
const totalCount = (programs || []).length
const filterActive = hasActiveFrameworkImportFilters(filters)
- const filterSummaryParts = useMemo(
- () =>
- summarizeFrameworkImportFilters(filters, {
- focusAreas: catalogFocusAreas,
- trainingTypes: catalogTrainingTypes,
- targetGroups: catalogTargetGroups,
- skills: catalogSkills,
- }),
- [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills]
- )
- const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
+ const filterChips = useMemo(
+ () =>
+ buildPlanningArtifactFilterChips({
+ filters,
+ setFilters: onFiltersChange,
+ catalogs: {
+ focusAreas: catalogFocusAreas,
+ trainingTypes: catalogTrainingTypes,
+ targetGroups: catalogTargetGroups,
+ skills: catalogSkills,
+ },
+ artifactType: 'framework_program',
+ emptyFilters: EMPTY_FRAMEWORK_IMPORT_FILTERS,
+ }),
+ [
+ filters,
+ onFiltersChange,
+ catalogFocusAreas,
+ catalogTrainingTypes,
+ catalogTargetGroups,
+ catalogSkills,
+ ]
+ )
const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
- const toggleId = (key, id) => {
- const s = String(id)
- onFiltersChange((prev) => {
- const cur = prev[key] || []
- const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
- return { ...prev, [key]: next }
- })
- }
+ useEffect(() => {
+ if (!filterModalOpen) return undefined
+ const onKey = (e) => {
+ if (e.key === 'Escape') setFilterModalOpen(false)
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [filterModalOpen])
- const togglePanel = () => {
- if (onPanelOpenChange) onPanelOpenChange(!panelOpen)
- }
+ const resultPlural = totalCount === 1 ? resultLabel : `${resultLabel}e`
return (
-
+
+
+
Suche
+
onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
+ placeholder={searchPlaceholder}
+ disabled={disabled}
+ enterKeyHint="search"
+ />
+
+
+ setFilterModalOpen(true)}
+ >
+ Filter
+ {filterChips.length > 0 ? (
+
+ {filterChips.length}
+
+ ) : null}
+
+ {filterChips.length > 0 ? (
+
+ Alle entfernen
+
+ ) : null}
+
+
+ {filterChips.length > 0 ? (
+
+ {filterChips.map((c) => (
+ c.onRemove()}
+ >
+ {c.label}
+
+ ×
+
+
+ ))}
+
+ ) : null}
+ {showHint ? (
+
+ Fachliche Filter über „Filter“ — zwischen Feldern UND. Fähigkeiten vergleichen nur unter
+ Rahmenprogrammen.
+
+ ) : null}
+
+
{matchCount}
{' '}
- von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
+ von {totalCount} {resultPlural}
{matchCount === 0 && totalCount > 0 ? (
— kein Treffer
) : null}
-
- {filterActive ? (
-
- Filter aktiv
-
- ) : null}
- {filterActive ? (
-
- Filter zurücksetzen
-
- ) : null}
- {onPanelOpenChange ? (
-
- {panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
-
- ) : null}
-
+ {filterActive ? (
+
+ Filter aktiv
+
+ ) : null}
- {!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
-
- {filterSummaryParts.map((part) => (
-
- {part}
-
- ))}
-
- ) : null}
-
- {panelOpen ? (
-
-
-
- Suche (Titel, Ziele, Katalog)
- updateFilter({ query: e.target.value })}
- placeholder="z. B. Gürtel, Koordination …"
- disabled={disabled}
- />
-
-
-
- Ziel-Session-Dauer
-
- {[
- { id: 'any', label: 'Alle' },
- { id: 'range', label: 'Zeitspanne' },
- { id: 'preset', label: 'Vorhandene Zeiten' },
- ].map((opt) => (
-
-
- updateFilter({
- durationMode: opt.id,
- ...(opt.id === 'any'
- ? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
- : {}),
- })
- }
- />
- {opt.label}
-
- ))}
-
-
- {filters.durationMode === 'range' ? (
-
- ) : null}
-
- {filters.durationMode === 'preset' ? (
- distinctDurations.length === 0 ? (
-
- In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
- Dauer pro Session im Rahmenprogramm fest.
-
- ) : (
-
- {distinctDurations.map((min) => {
- const on = filters.durationPresetMin === min
- return (
-
- updateFilter({
- durationMode: 'preset',
- durationPresetMin: on ? null : min,
- durationRangeFrom: '',
- durationRangeTo: '',
- })
- }
- >
- {formatDurationDisplay(min)}
-
- )
- })}
-
- )
- ) : null}
-
-
- {catalogFocusAreas.length > 0 ? (
-
-
Fokusbereich
-
- {catalogFocusAreas.map((fa) => (
-
- toggleId('focusAreaIds', fa.id)}
- disabled={disabled}
- />
- {fa.name}
-
- ))}
-
-
- ) : null}
-
- {catalogTrainingTypes.length > 0 ? (
-
-
Trainingsart
-
- {catalogTrainingTypes.map((t) => (
-
- toggleId('trainingTypeIds', t.id)}
- disabled={disabled}
- />
- {t.name}
-
- ))}
-
-
- ) : null}
-
- {catalogTargetGroups.length > 0 ? (
-
-
Zielgruppe
-
- {catalogTargetGroups.map((tg) => (
-
- toggleId('targetGroupIds', tg.id)}
- disabled={disabled}
- />
- {tg.name}
-
- ))}
-
-
- ) : null}
-
- {catalogSkills.length > 0 ? (
-
-
Fähigkeiten (Rahmenprogramme)
-
- Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit — nur
- unter Rahmenprogrammen, nicht gegen Module.
-
-
- {catalogSkills.map((sk) => (
-
- toggleId('skillIds', sk.id)}
- disabled={disabled}
- />
- {sk.name}
-
- ))}
-
- {(filters.skillIds || []).length > 0 ? (
-
-
- Mindest-Anteil am Rahmenprogramm-Maximum
-
- updateFilter({ skillMinClubPercent: Number(e.target.value) || 0 })
- }
- >
- Kein Minimum (nur markieren)
- mind. 25%
- mind. 50%
- mind. 75%
-
-
-
- Sortierung
- updateFilter({ skillSort: e.target.value })}
- >
- Bibliotheks-Reihenfolge
- Stärkste gewählte Fähigkeit zuerst
-
-
-
- ) : null}
-
- ) : null}
-
- {showHint ? (
-
- Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
- Programme mit hinterlegter Session-Dauer berücksichtigt.
-
- ) : null}
-
- ) : null}
+
setFilterModalOpen(false)}
+ filters={filters}
+ onFiltersChange={onFiltersChange}
+ artifactType="framework_program"
+ programs={programs}
+ catalogFocusAreas={catalogFocusAreas}
+ catalogTrainingTypes={catalogTrainingTypes}
+ catalogTargetGroups={catalogTargetGroups}
+ catalogSkills={catalogSkills}
+ durationRadioName={durationRadioName}
+ onResetAll={clearFilters}
+ disabled={disabled}
+ title={filterModalTitle}
+ showCatalogFilters
+ showDurationFilters
+ />
)
}
diff --git a/frontend/src/components/planning/PlanningArtifactFilterModal.jsx b/frontend/src/components/planning/PlanningArtifactFilterModal.jsx
new file mode 100644
index 0000000..d2eadc5
--- /dev/null
+++ b/frontend/src/components/planning/PlanningArtifactFilterModal.jsx
@@ -0,0 +1,275 @@
+import React, { useMemo } from 'react'
+import { collectDistinctSessionDurationsMinutes } from '../../utils/frameworkProgramListHelpers'
+import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
+import PlanningSkillFilterSection from './PlanningSkillFilterSection'
+
+/**
+ * Filter-Modal für Rahmenprogramme / Trainingsmodule (UX wie ExerciseListFilterModal).
+ */
+export default function PlanningArtifactFilterModal({
+ open,
+ onClose,
+ filters,
+ onFiltersChange,
+ artifactType = 'framework_program',
+ programs = [],
+ catalogFocusAreas = [],
+ catalogTrainingTypes = [],
+ catalogTargetGroups = [],
+ catalogSkills = [],
+ durationRadioName = 'planning-duration-mode',
+ onResetAll,
+ disabled = false,
+ showCatalogFilters = true,
+ showDurationFilters = true,
+ title = 'Filtern',
+}) {
+ const distinctDurations = useMemo(
+ () => (showDurationFilters ? collectDistinctSessionDurationsMinutes(programs) : []),
+ [programs, showDurationFilters]
+ )
+
+ if (!open) return null
+
+ const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
+
+ const toggleId = (key, id) => {
+ const s = String(id)
+ onFiltersChange((prev) => {
+ const cur = prev[key] || []
+ const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
+ return { ...prev, [key]: next }
+ })
+ }
+
+ const artifactLabel =
+ artifactType === 'training_module'
+ ? 'Trainingsmodule'
+ : artifactType === 'framework_program'
+ ? 'Rahmenprogramme'
+ : 'Einträge'
+
+ return (
+
{
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+ {title}
+
+
+ Schließen
+
+
+
+
+ Zwischen den Bereichen gilt UND . Mehrere Katalog-Werte innerhalb eines Feldes
+ bedeuten ODER . Fähigkeiten filtern nach Trainingsgewicht — nur unter sichtbaren{' '}
+ {artifactLabel}.
+
+
+ {showCatalogFilters ? (
+
+ Katalog
+
+ {catalogFocusAreas.length > 0 ? (
+
+
Fokusbereich
+
+ {catalogFocusAreas.map((fa) => (
+
+ toggleId('focusAreaIds', fa.id)}
+ disabled={disabled}
+ />
+ {fa.name}
+
+ ))}
+
+
+ ) : null}
+
+ {catalogTrainingTypes.length > 0 ? (
+
+
Trainingsart
+
+ {catalogTrainingTypes.map((t) => (
+
+ toggleId('trainingTypeIds', t.id)}
+ disabled={disabled}
+ />
+ {t.name}
+
+ ))}
+
+
+ ) : null}
+
+ {catalogTargetGroups.length > 0 ? (
+
+
Zielgruppe
+
+ {catalogTargetGroups.map((tg) => (
+
+ toggleId('targetGroupIds', tg.id)}
+ disabled={disabled}
+ />
+ {tg.name}
+
+ ))}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {showDurationFilters ? (
+
+ Session-Dauer
+
+
+ {[
+ { id: 'any', label: 'Alle' },
+ { id: 'range', label: 'Zeitspanne' },
+ { id: 'preset', label: 'Vorhandene Zeiten' },
+ ].map((opt) => (
+
+
+ updateFilter({
+ durationMode: opt.id,
+ ...(opt.id === 'any'
+ ? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
+ : {}),
+ })
+ }
+ />
+ {opt.label}
+
+ ))}
+
+
+ {filters.durationMode === 'range' ? (
+
+ ) : null}
+
+ {filters.durationMode === 'preset' ? (
+ distinctDurations.length === 0 ? (
+
+ Noch keine Session-Dauern hinterlegt.
+
+ ) : (
+
+ {distinctDurations.map((min) => {
+ const on = filters.durationPresetMin === min
+ return (
+
+ updateFilter({
+ durationMode: 'preset',
+ durationPresetMin: on ? null : min,
+ durationRangeFrom: '',
+ durationRangeTo: '',
+ })
+ }
+ >
+ {formatDurationDisplay(min)}
+
+ )
+ })}
+
+ )
+ ) : null}
+
+
+ ) : null}
+
+ {catalogSkills.length > 0 ? (
+
+ Fähigkeit und Trainingsgewicht
+ updateFilter({ skillIds: v })}
+ skillMinClubPercent={filters.skillMinClubPercent ?? 0}
+ onSkillMinClubPercentChange={(v) => updateFilter({ skillMinClubPercent: v })}
+ skillSort={filters.skillSort || 'title'}
+ onSkillSortChange={(v) => updateFilter({ skillSort: v })}
+ skillsCatalog={catalogSkills}
+ disabled={disabled}
+ />
+
+ ) : null}
+
+
+
+ Alle Filter zurücksetzen
+
+
+ Fertig
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/planning/PlanningSkillFilterSection.jsx b/frontend/src/components/planning/PlanningSkillFilterSection.jsx
new file mode 100644
index 0000000..16fb796
--- /dev/null
+++ b/frontend/src/components/planning/PlanningSkillFilterSection.jsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
+import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
+
+/**
+ * Fähigkeiten-Filter für Planungsartefakte (Rahmenprogramme, Module).
+ * Semantik wie Übungsliste (SkillTreeMultiSelect), aber mit Peer-Prozent statt Stufen.
+ */
+export default function PlanningSkillFilterSection({
+ artifactType = 'framework_program',
+ skillIds = [],
+ onSkillIdsChange,
+ skillMinClubPercent = 0,
+ onSkillMinClubPercentChange,
+ skillSort = 'title',
+ onSkillSortChange,
+ skillsCatalog = [],
+ disabled = false,
+}) {
+ const peerLabel = peerCorpusCountLabel(artifactType)
+ const peerMaxLabel =
+ artifactType === 'framework_program' ? 'Rahmenprogramm-Maximum' : `${peerLabel}-Maximum`
+
+ return (
+
+
Fähigkeit
+
+
+ Trainingsgewicht relativ zum stärksten sichtbaren Eintrag unter {peerLabel} — nicht gemischt mit
+ anderen Planungs-Artefakttypen.
+
+ {(skillIds || []).length > 0 ? (
+
+
+ Mindest-Anteil am {peerMaxLabel}
+ onSkillMinClubPercentChange(Number(e.target.value) || 0)}
+ >
+ Kein Minimum (nur markieren)
+ mind. 25%
+ mind. 50%
+ mind. 75%
+
+
+
+ Sortierung
+ onSkillSortChange(e.target.value)}
+ >
+ Bibliotheks-Reihenfolge
+ Stärkste gewählte Fähigkeit zuerst
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/components/planning/TrainingModulesFilterBlock.jsx b/frontend/src/components/planning/TrainingModulesFilterBlock.jsx
new file mode 100644
index 0000000..d5a9daa
--- /dev/null
+++ b/frontend/src/components/planning/TrainingModulesFilterBlock.jsx
@@ -0,0 +1,148 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import {
+ EMPTY_TRAINING_MODULE_FILTERS,
+ filterTrainingModules,
+ hasActiveTrainingModuleFilters,
+} from '../../utils/trainingModuleListHelpers'
+import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips'
+import PlanningArtifactFilterModal from './PlanningArtifactFilterModal'
+
+/**
+ * Filter-Leiste für Trainingsmodule (UX wie Übungsliste / Rahmenprogramme).
+ */
+export default function TrainingModulesFilterBlock({
+ modules = [],
+ filters,
+ onFiltersChange,
+ catalogSkills = [],
+ skillSummaries = null,
+ disabled = false,
+ className = '',
+}) {
+ const [filterModalOpen, setFilterModalOpen] = useState(false)
+
+ const matchCount = useMemo(
+ () => filterTrainingModules(modules, filters, skillSummaries).length,
+ [modules, filters, skillSummaries]
+ )
+
+ const totalCount = (modules || []).length
+ const filterActive = hasActiveTrainingModuleFilters(filters)
+
+ const filterChips = useMemo(
+ () =>
+ buildPlanningArtifactFilterChips({
+ filters,
+ setFilters: onFiltersChange,
+ catalogs: { skills: catalogSkills },
+ artifactType: 'training_module',
+ emptyFilters: EMPTY_TRAINING_MODULE_FILTERS,
+ }),
+ [filters, onFiltersChange, catalogSkills]
+ )
+
+ const clearFilters = () => onFiltersChange({ ...EMPTY_TRAINING_MODULE_FILTERS })
+
+ useEffect(() => {
+ if (!filterModalOpen) return undefined
+ const onKey = (e) => {
+ if (e.key === 'Escape') setFilterModalOpen(false)
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [filterModalOpen])
+
+ return (
+
+
+
Suche
+
onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
+ placeholder="Titel oder Kurzbeschreibung …"
+ disabled={disabled}
+ enterKeyHint="search"
+ />
+
+
+ setFilterModalOpen(true)}
+ >
+ Filter
+ {filterChips.length > 0 ? (
+
+ {filterChips.length}
+
+ ) : null}
+
+ {filterChips.length > 0 ? (
+
+ Alle entfernen
+
+ ) : null}
+
+
+ {filterChips.length > 0 ? (
+
+ {filterChips.map((c) => (
+ c.onRemove()}
+ >
+ {c.label}
+
+ ×
+
+
+ ))}
+
+ ) : null}
+
+ Fachliche Filter über „Filter“. Fähigkeiten vergleichen nur unter sichtbaren Modulen.
+
+
+
+
+
+ {matchCount}
+
+ {' '}
+ von {totalCount} Modul{totalCount === 1 ? '' : 'en'}
+
+ {matchCount === 0 && totalCount > 0 ? (
+ — kein Treffer
+ ) : null}
+
+ {filterActive ? (
+
+ Filter aktiv
+
+ ) : null}
+
+
+
setFilterModalOpen(false)}
+ filters={filters}
+ onFiltersChange={onFiltersChange}
+ artifactType="training_module"
+ catalogSkills={catalogSkills}
+ onResetAll={clearFilters}
+ disabled={disabled}
+ title="Trainingsmodule filtern"
+ showCatalogFilters={false}
+ showDurationFilters={false}
+ />
+
+ )
+}
diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx
index cbadef2..1eaa060 100644
--- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx
+++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx
@@ -35,12 +35,10 @@ export default function TrainingPlanningFrameworkImportModal({
onClose,
}) {
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
- const [filterPanelOpen, setFilterPanelOpen] = useState(true)
useEffect(() => {
if (!open) {
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
- setFilterPanelOpen(true)
}
}, [open])
@@ -80,13 +78,14 @@ export default function TrainingPlanningFrameworkImportModal({
programs={frameworkProgramsList}
filters={filters}
onFiltersChange={setFilters}
- panelOpen={filterPanelOpen}
- onPanelOpenChange={setFilterPanelOpen}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
disabled={fwImportSubmitting}
durationRadioName="fw-duration-mode"
+ showHint={false}
+ searchPlaceholder="Rahmenprogramm suchen …"
+ filterModalTitle="Rahmenprogramme filtern"
/>
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
index 048e2a6..34e5e04 100644
--- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
@@ -25,7 +25,6 @@ export default function TrainingFrameworkProgramsListPage() {
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
const [catalogSkills, setCatalogSkills] = useState([])
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
- const [filterPanelOpen, setFilterPanelOpen] = useState(true)
const [skillSummaries, setSkillSummaries] = useState({})
const [summariesLoading, setSummariesLoading] = useState(false)
const [profileModal, setProfileModal] = useState(null)
@@ -45,7 +44,7 @@ export default function TrainingFrameworkProgramsListPage() {
api.listFocusAreas({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
- api.listSkills({ status: 'active' }),
+ api.listSkillsCatalog({ status: 'active' }),
])
setRows(Array.isArray(list) ? list : [])
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
@@ -162,8 +161,6 @@ export default function TrainingFrameworkProgramsListPage() {
programs={rows}
filters={filters}
onFiltersChange={setFilters}
- panelOpen={filterPanelOpen}
- onPanelOpenChange={setFilterPanelOpen}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
index a5b5e60..4f0492e 100644
--- a/frontend/src/pages/TrainingModulesListPage.jsx
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -1,11 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
+import TrainingModulesFilterBlock from '../components/planning/TrainingModulesFilterBlock'
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
+import {
+ EMPTY_TRAINING_MODULE_FILTERS,
+ filterTrainingModules,
+ hasActiveTrainingModuleFilters,
+} from '../utils/trainingModuleListHelpers'
export default function TrainingModulesListPage() {
const { user } = useAuth()
@@ -14,19 +20,32 @@ export default function TrainingModulesListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
+ const [catalogSkills, setCatalogSkills] = useState([])
+ const [filters, setFilters] = useState(() => ({ ...EMPTY_TRAINING_MODULE_FILTERS }))
const [skillSummaries, setSkillSummaries] = useState({})
const [summariesLoading, setSummariesLoading] = useState(false)
const [profileModal, setProfileModal] = useState(null)
+ const filteredRows = useMemo(
+ () => filterTrainingModules(rows, filters, skillSummaries),
+ [rows, filters, skillSummaries]
+ )
+ const filterActive = hasActiveTrainingModuleFilters(filters)
+
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
- const list = await api.listTrainingModules()
+ const [list, skills] = await Promise.all([
+ api.listTrainingModules(),
+ api.listSkillsCatalog({ status: 'active' }),
+ ])
setRows(Array.isArray(list) ? list : [])
+ setCatalogSkills(Array.isArray(skills) ? skills : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
+ setCatalogSkills([])
} finally {
setLoading(false)
}
@@ -86,7 +105,8 @@ export default function TrainingModulesListPage() {
Trainingsmodule
- Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter sichtbaren Modulen.
+ Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter
+ sichtbaren Modulen.
Noch keine Module angelegt.
) : (
-
- {rows.map((r) => (
-
-
-
-
+
+
+ {filteredRows.length === 0 ? (
+
+
Kein Treffer
+
+ {filterActive
+ ? 'Kein Modul passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
+ : 'Keine Einträge.'}
+
+
+ ) : (
+
+ {filteredRows.map((r) => (
+
+
- {(r.title || '').trim() || `Modul #${r.id}`}
-
-
- {(r.summary || '').trim() || '—'}{' '}
-
- ({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
-
-
-
-
+
+
+ {(r.title || '').trim() || `Modul #${r.id}`}
+
+
+ {(r.summary || '').trim() || '—'}{' '}
+
+ ({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
+
+
+
+
+
+
+
+
+ setProfileModal({
+ artifactType: 'training_module',
+ artifactId: r.id,
+ title: (r.title || '').trim() || `Modul #${r.id}`,
+ })
+ }
+ >
+ Fähigkeiten-Profil
+
+
+ Bearbeiten
+
+ handleDelete(r.id, r.title)}>
+ Löschen
+
+
-
-
-
- setProfileModal({
- artifactType: 'training_module',
- artifactId: r.id,
- title: (r.title || '').trim() || `Modul #${r.id}`,
- })
- }
- >
- Fähigkeiten-Profil
-
-
- Bearbeiten
-
- handleDelete(r.id, r.title)}>
- Löschen
-
-
-
-
- ))}
-
+
+ ))}
+
+ )}
+ >
)}
String(x.id) === String(id))?.name || id
+}
+
+function peerPercentLabel(artifactType) {
+ const label = peerCorpusCountLabel(artifactType)
+ return label === 'Rahmenprogramme' ? 'Rahmenprogramm-Maximum' : `${label}-Maximum`
+}
+
+/**
+ * Entfernbare Filter-Chips (UX wie Übungsliste).
+ */
+export function buildPlanningArtifactFilterChips({
+ filters,
+ setFilters,
+ catalogs = {},
+ artifactType = 'framework_program',
+ emptyFilters = null,
+}) {
+ const base =
+ emptyFilters ||
+ (artifactType === 'training_module' ? EMPTY_TRAINING_MODULE_FILTERS : EMPTY_FRAMEWORK_IMPORT_FILTERS)
+ const f = { ...base, ...filters }
+ const chips = []
+ const peerMaxLabel = peerPercentLabel(artifactType)
+
+ const q = (f.query || '').trim()
+ if (q) {
+ chips.push({
+ key: 'query',
+ label: `Suche: „${q}"`,
+ onRemove: () => setFilters((prev) => ({ ...prev, query: '' })),
+ })
+ }
+
+ ;(f.skillIds || []).forEach((id) => {
+ chips.push({
+ key: `skill-${id}`,
+ label: `Fähigkeit: ${nameById(catalogs.skills, id)}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ skillIds: (prev.skillIds || []).filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ if (Number(f.skillMinClubPercent) > 0) {
+ chips.push({
+ key: 'skill-min-pct',
+ label: `mind. ${f.skillMinClubPercent}% vom ${peerMaxLabel}`,
+ onRemove: () => setFilters((prev) => ({ ...prev, skillMinClubPercent: 0 })),
+ })
+ }
+
+ if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
+ chips.push({
+ key: 'skill-sort',
+ label: 'Sortierung: Fähigkeiten-Stärke',
+ onRemove: () => setFilters((prev) => ({ ...prev, skillSort: 'title' })),
+ })
+ }
+
+ ;(f.focusAreaIds || []).forEach((id) => {
+ chips.push({
+ key: `focus-${id}`,
+ label: `Fokus: ${nameById(catalogs.focusAreas, id)}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ focusAreaIds: (prev.focusAreaIds || []).filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ ;(f.trainingTypeIds || []).forEach((id) => {
+ chips.push({
+ key: `type-${id}`,
+ label: `Trainingsart: ${nameById(catalogs.trainingTypes, id)}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ trainingTypeIds: (prev.trainingTypeIds || []).filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ ;(f.targetGroupIds || []).forEach((id) => {
+ chips.push({
+ key: `tg-${id}`,
+ label: `Zielgruppe: ${nameById(catalogs.targetGroups, id)}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ targetGroupIds: (prev.targetGroupIds || []).filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ if (f.durationMode === 'range') {
+ const a = String(f.durationRangeFrom || '').trim()
+ const b = String(f.durationRangeTo || '').trim()
+ if (a || b) {
+ const fromLbl = a ? formatDurationDisplay(Number(a), { empty: a }) : '—'
+ const toLbl = b ? formatDurationDisplay(Number(b), { empty: b }) : '—'
+ chips.push({
+ key: 'duration-range',
+ label: `Dauer: ${fromLbl} – ${toLbl}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ durationMode: 'any',
+ durationRangeFrom: '',
+ durationRangeTo: '',
+ })),
+ })
+ }
+ } else if (f.durationMode === 'preset' && f.durationPresetMin != null) {
+ chips.push({
+ key: 'duration-preset',
+ label: `Dauer: ${formatDurationDisplay(f.durationPresetMin)}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ durationMode: 'any',
+ durationPresetMin: null,
+ })),
+ })
+ }
+
+ return chips
+}
diff --git a/frontend/src/utils/trainingModuleListHelpers.js b/frontend/src/utils/trainingModuleListHelpers.js
new file mode 100644
index 0000000..68a054a
--- /dev/null
+++ b/frontend/src/utils/trainingModuleListHelpers.js
@@ -0,0 +1,57 @@
+import {
+ maxSelectedSkillClubPercent,
+ moduleSkillSummaryKey,
+ summaryHasSkill,
+} from './skillProfileListHelpers'
+
+export const EMPTY_TRAINING_MODULE_FILTERS = {
+ query: '',
+ skillIds: [],
+ skillSort: 'title',
+ skillMinClubPercent: 0,
+ skillDisplayLimit: 12,
+}
+
+export function hasActiveTrainingModuleFilters(filters = {}) {
+ const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters }
+ if ((f.query || '').trim()) return true
+ if ((f.skillIds || []).length) return true
+ if (Number(f.skillMinClubPercent) > 0) return true
+ if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
+ return false
+}
+
+export function filterTrainingModules(rows, filters = {}, skillSummaries = null) {
+ const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters }
+ const q = (f.query || '').trim().toLowerCase()
+ const skillIds = f.skillIds || []
+ const minClubPct = Number(f.skillMinClubPercent) || 0
+
+ let list = (rows || []).filter((r) => {
+ if (q) {
+ const blob = [r.title, r.summary].filter(Boolean).join(' ').toLowerCase()
+ if (!blob.includes(q)) return false
+ }
+ return true
+ })
+
+ if (skillIds.length && skillSummaries) {
+ list = list.filter((r) => {
+ const summary = skillSummaries[moduleSkillSummaryKey(r.id)]
+ if (!summary) return false
+ return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
+ })
+ }
+
+ if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
+ list = [...list].sort((a, b) => {
+ const sa = skillSummaries[moduleSkillSummaryKey(a.id)]
+ const sb = skillSummaries[moduleSkillSummaryKey(b.id)]
+ const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
+ const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
+ return pb - pa
+ })
+ }
+
+ return list
+}
--
2.43.0
From a7a428745f46fa35abb4c5ee81b02ecd6b8d802d Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 10:31:21 +0200
Subject: [PATCH 10/10] Enhance SkillTreeMultiSelect Component and CSS Styles
- Updated the SkillTreeMultiSelect component to support dynamic positioning and improved accessibility through the use of portals.
- Refactored the dropdown panel rendering logic to enhance user experience when selecting skills.
- Added CSS styles for the exercise filter modal to improve layout and responsiveness.
- Introduced new styles for the skill tree multiselect panel, ensuring better visual integration and usability.
---
frontend/src/app.css | 24 +++
.../src/components/SkillTreeMultiSelect.jsx | 141 ++++++++++++------
2 files changed, 123 insertions(+), 42 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index bd4581c..9feb71f 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -4086,6 +4086,12 @@ html.modal-scroll-locked .app-main {
.exercise-filter-modal.admin-modal-sheet {
max-width: min(920px, calc(100dvw - 16px));
+ max-height: min(92vh, 920px);
+}
+@media (min-width: 640px) {
+ .exercise-filter-modal.admin-modal-sheet {
+ max-height: min(90vh, 920px);
+ }
}
.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll {
flex: 1;
@@ -6418,6 +6424,24 @@ html.modal-scroll-locked .app-main {
max-height: min(360px, 55vh);
overflow: auto;
}
+.skill-tree-multiselect__panel--portal {
+ position: fixed;
+ right: auto;
+ top: auto;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+}
+.skill-tree-multiselect__panel--portal .multiselect-combo__list {
+ position: static;
+ left: auto;
+ right: auto;
+ top: auto;
+ margin: 0;
+ max-height: none;
+ box-shadow: none;
+ border: none;
+ border-radius: 0;
+}
.skill-tree {
list-style: none;
margin: 0;
diff --git a/frontend/src/components/SkillTreeMultiSelect.jsx b/frontend/src/components/SkillTreeMultiSelect.jsx
index 98dc4c0..8e0187a 100644
--- a/frontend/src/components/SkillTreeMultiSelect.jsx
+++ b/frontend/src/components/SkillTreeMultiSelect.jsx
@@ -1,7 +1,12 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
import SkillTreePickerPanel from './SkillTreePickerPanel'
+const PANEL_MAX_HEIGHT = 360
+const PANEL_MIN_HEIGHT = 140
+const PANEL_Z_INDEX = 1100
+
function normId(id) {
return String(id)
}
@@ -17,11 +22,15 @@ export default function SkillTreeMultiSelect({
browseLabel = '▼ Katalog',
emptyHint = 'Keine Treffer',
className = '',
+ usePortal = true,
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [browseTree, setBrowseTree] = useState(false)
+ const [panelStyle, setPanelStyle] = useState(null)
const rootRef = useRef(null)
+ const fieldRef = useRef(null)
+ const panelRef = useRef(null)
const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
@@ -56,17 +65,99 @@ export default function SkillTreeMultiSelect({
useEffect(() => {
const onDoc = (e) => {
- if (!rootRef.current?.contains(e.target)) {
- setOpen(false)
- setBrowseTree(false)
- }
+ const t = e.target
+ if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
+ setOpen(false)
+ setBrowseTree(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
+ const updatePanelPosition = useCallback(() => {
+ const anchor = fieldRef.current
+ if (!anchor) return
+ const rect = anchor.getBoundingClientRect()
+ const gap = 4
+ const margin = 12
+ const spaceBelow = window.innerHeight - rect.bottom - gap - margin
+ const spaceAbove = rect.top - gap - margin
+ const openUp = spaceBelow < PANEL_MIN_HEIGHT && spaceAbove > spaceBelow
+ const available = Math.max(0, openUp ? spaceAbove : spaceBelow)
+ const maxHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, available))
+ const top = openUp ? Math.max(margin, rect.top - gap - maxHeight) : rect.bottom + gap
+
+ setPanelStyle({
+ position: 'fixed',
+ left: rect.left,
+ width: rect.width,
+ top,
+ maxHeight,
+ zIndex: PANEL_Z_INDEX,
+ })
+ }, [])
+
+ useLayoutEffect(() => {
+ if (!open || !usePortal) {
+ setPanelStyle(null)
+ return undefined
+ }
+ updatePanelPosition()
+ window.addEventListener('resize', updatePanelPosition)
+ window.addEventListener('scroll', updatePanelPosition, true)
+ return () => {
+ window.removeEventListener('resize', updatePanelPosition)
+ window.removeEventListener('scroll', updatePanelPosition, true)
+ }
+ }, [open, usePortal, updatePanelPosition, query, browseTree, value.length])
+
const showTree = browseTree || !query.trim()
+ const panelContent = showTree ? (
+ addId(id)}
+ pickMode="multi"
+ />
+ ) : (
+
+ {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === 0 ? (
+ {emptyHint}
+ ) : (
+ leaves
+ .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
+ .map((l) => (
+
+ e.preventDefault()}
+ onClick={() => addId(l.id)}
+ >
+ {l.label}
+ {l.pathLabel}
+
+
+ ))
+ )}
+
+ )
+
+ const dropdownPanel =
+ open && (usePortal ? panelStyle : true) ? (
+
+ {panelContent}
+
+ ) : null
+
return (
@@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({
))}
-
+
- {open ? (
-
- {showTree ? (
-
addId(id)}
- pickMode="multi"
- />
- ) : (
-
- {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
- 0 ? (
- {emptyHint}
- ) : (
- leaves
- .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
- .map((l) => (
-
- e.preventDefault()}
- onClick={() => addId(l.id)}
- >
- {l.label}
- {l.pathLabel}
-
-
- ))
- )}
-
- )}
-
- ) : null}
+ {!usePortal && dropdownPanel}
+ {usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
)
}
--
2.43.0