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} +
+ +
+