From 78c6c5152017a89262c273cd55941446e94c7740 Mon Sep 17 00:00:00 2001
From: Lars
Date: Thu, 21 May 2026 09:05:13 +0200
Subject: [PATCH] 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 ? (
+
+ ) : null}
+
+
+
+
-
- Sichtbarkeit: {r.visibility || '—'}
-
+
+
+
+
)}
+
+ 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)
+}