diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
new file mode 100644
index 0000000..17ef945
--- /dev/null
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -0,0 +1,96 @@
+# Gewichtetes Fähigkeiten-Scoring (Phase 3)
+
+**Stand:** 2026-05-20
+**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
+**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
+
+## 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.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 × 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:
+
+- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
+- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
+- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
+- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
+- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
+- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
+
+Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
+
+## 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..890ef6a
--- /dev/null
+++ b/backend/routers/skill_profiles.py
@@ -0,0 +1,689 @@
+"""
+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,
+ 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_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,
+ reference_scale_meta,
+ top_categories_summary,
+)
+
+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()]
+
+ 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]] = []
+
+ 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, reference_max_by_skill=ref_max)
+ 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, reference_max_by_skill=ref_max)
+ 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": 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()
+ },
+ "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)
+ 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)
+ 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": 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()
+ },
+ "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)
+ 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(
+ cur,
+ occurrences,
+ default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
+ reference_max_by_skill=ref_max,
+ )
+ 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": 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()
+ },
+ "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)
+ 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,
+ )
+
+ allowed_fp: List[int] = []
+ if fp_ids:
+ for fid in fp_ids:
+ try:
+ _framework_access(cur, fid, profile_id, role)
+ allowed_fp.append(fid)
+ except HTTPException:
+ pass
+
+ allowed_mod: List[int] = []
+ if mod_ids:
+ for mid in mod_ids:
+ try:
+ _module_access(cur, mid, profile_id, role)
+ allowed_mod.append(mid)
+ except HTTPException:
+ pass
+
+ summaries = _merge_batch_summaries(
+ cur,
+ 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():
+ 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_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,
+ }
+
+
+@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)
+ 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(
+ 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, reference_max_by_skill=fw_ref)
+ 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_score": prof.get("total_score"),
+ "top_by_category": top_categories_summary(prof),
+ },
+ }
+ )
+
+ 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, reference_max_by_skill=mod_ref)
+ 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_score": prof.get("total_score"),
+ "top_by_category": top_categories_summary(prof),
+ },
+ }
+ )
+
+ 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,
+ reference_max_by_skill=graph_ref,
+ )
+ 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_score": prof.get("total_score"),
+ "top_by_category": top_categories_summary(prof),
+ },
+ }
+ )
+
+ results.sort(
+ key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
+ )
+ return {
+ "skill_ids": wanted,
+ "types": sorted(type_set),
+ "suggestions": results[:limit],
+ }
+
+
+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 _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_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,
+ )
+
+
+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([], {})
+
+
+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,
+ *,
+ bundle: Dict[str, Any],
+ allowed_fp: List[int],
+ allowed_mod: List[int],
+) -> Dict[str, Dict[str, Any]]:
+ """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]] = {}
+
+ 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=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, fw_ref_by, "framework_program", fid)
+ out[key] = compact_profile_summary(prof, fw_ref_by)
+
+ 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=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, 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 fw_cached:
+ out[key] = fw_cached[key]
+ elif key not in out:
+ 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 mod_cached:
+ out[key] = mod_cached[key]
+ elif key not in out:
+ 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
new file mode 100644
index 0000000..972b55f
--- /dev/null
+++ b/backend/skill_scoring.py
@@ -0,0 +1,985 @@
+"""
+Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
+
+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
+
+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,
+}
+
+# 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
+ 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(
+ *,
+ intensity: Optional[str] = None,
+ required_level: Optional[str] = None,
+ target_level: Optional[str] = None,
+) -> float:
+ mult = 1.0
+ if intensity:
+ key = str(intensity).strip().lower()
+ mult *= _INTENSITY_MULT.get(key, 1.0)
+ mult *= _level_range_multiplier(required_level, target_level)
+ return mult
+
+
+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 _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:
+ """
+ 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)
+ pct, is_best = _club_universal_percent(w, ref)
+ sk["universal_percent"] = pct
+ sk["is_club_best_for_skill"] = is_best
+
+
+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.
+ """
+ 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(
+ intensity=link.get("intensity"),
+ required_level=link.get("required_level"),
+ target_level=link.get("target_level"),
+ )
+ 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_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,
+ "exercises": {},
+ }
+ acc = skill_acc[sid]
+ acc["weight"] += contribution
+ acc["occurrence_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_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,
+ }
+ )
+ skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or ""))
+
+ _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)
+ 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 {
+ "computed_at": datetime.now(timezone.utc).isoformat(),
+ "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_main_category": by_main_category,
+ "has_reference_scale": bool(reference_max_by_skill),
+ }
+
+
+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,
+ 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)
+ ORDER BY es.exercise_id, s.name, 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,
+ 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,
+ 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 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"),
+ "is_club_best_for_skill": top.get("is_club_best_for_skill"),
+ }
+ )
+ 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)
+ 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)
+
+
+def compact_profile_summary(
+ profile: Dict[str, Any],
+ ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
+ *,
+ skills_limit: int = 0,
+ category_limit: int = 48,
+) -> Dict[str, Any]:
+ """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"])
+ 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"),
+ "score": s.get("score") or 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
+ if s.get("is_club_best_for_skill"):
+ entry["is_club_best_for_skill"] = True
+ skills_out.append(entry)
+ 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, limit=category_limit),
+ "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 _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,
+) -> Dict[str, Any]:
+ """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]] = {}
+
+ def ingest(aid: 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=aid,
+ artifact_title=title,
+ )
+ if include_artifact_summaries:
+ raw_profiles[f"{artifact_type}:{aid}"] = prof
+
+ 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,
+ )
+ 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:
+ 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 {
+ "max_by_skill": max_by_skill,
+ "ref_by_skill": ref_by_skill,
+ "artifact_count": artifact_count,
+ "artifact_summaries": artifact_summaries,
+ }
+
+
+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 {
+ "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": (
+ f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit "
+ f"(nicht gemischt mit anderen Planungs-Artefakttypen)"
+ ),
+ }
+
+
+def compute_corpus_skill_max_weights(
+ cur,
+ *,
+ profile_id: int,
+ role: Optional[str],
+ effective_club_id: Optional[int],
+ limit_per_type: int = 50,
+ artifact_type: str = "framework_program",
+) -> Dict[int, float]:
+ """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_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]:
+ """Ü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": [],
+ }
+ 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)
+ artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0
+ return {
+ "match_weight": _round2(match_weight),
+ "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/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/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
new file mode 100644
index 0000000..dbe4531
--- /dev/null
+++ b/backend/tests/test_skill_scoring.py
@@ -0,0 +1,125 @@
+"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3)."""
+from skill_scoring import (
+ ExerciseOccurrence,
+ compute_skill_profile,
+ match_score_for_skill_ids,
+ _club_universal_percent,
+ _level_range_multiplier,
+ _skill_link_multiplier,
+)
+
+
+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():
+ 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",
+ "category_id": 1,
+ "category_name": "kihon",
+ "main_category_id": 100,
+ "main_category_name": "Technik",
+ "intensity": "hoch",
+ "required_level": "grundlagen",
+ "target_level": "aufbau",
+ "exercise_title": "Übung A",
+ },
+ {
+ "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",
+ "exercise_title": "Übung A",
+ },
+ ],
+ }
+ profile = compute_skill_profile(occurrences, skills_map)
+ 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
+ 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():
+ 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_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/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..db67033
--- /dev/null
+++ b/frontend/src/api/skillProfiles.js
@@ -0,0 +1,40 @@
+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()}`)
+}
+
+/**
+ * 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 958b06a..9feb71f 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2475,6 +2475,474 @@ 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__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;
+}
+.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-profile-compact {
+ margin-top: 4px;
+}
+.skill-kpi-grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.skill-kpi-grid--loading,
+.skill-kpi-grid--empty {
+ margin: 0;
+}
+.skill-kpi-tile {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 5.5rem;
+ max-width: 8.5rem;
+ padding: 5px 8px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ flex: 0 1 auto;
+}
+.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;
+ color: var(--text1);
+ line-height: 1.2;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.skill-kpi-tile__score {
+ font-size: 0.78rem;
+ font-weight: 700;
+ 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);
+}
+.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;
+ 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;
+}
+.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-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;
+}
+.skill-discovery__no-hit {
+ margin: 0;
+}
+
/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */
.page-form-editor__body .framework-edit {
min-width: 0;
@@ -3618,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;
@@ -5386,12 +5860,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) —— */
@@ -5946,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/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index dfd8661..3dceb6c 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,15 @@ export default function ExerciseProgressionGraphPanel({
{selectedGraphId && uiTab === 'overview' && (
<>
+
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 ? (
+
+ {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === 0 ? (
+
+ )
+
+ const dropdownPanel =
+ open && (usePortal ? panelStyle : true) ? (
+
- {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
- 0 ? (
-
- )}
- Fähigkeiten
+ {onShowSkillProfile ? (
+
+ ) : null}
+