From 732b322c5236928e6c696a41947e19ddd66d2771 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 16:42:25 +0200 Subject: [PATCH 01/10] Implement Phase 3 Features for Skill Profiles and Discovery - Updated the framework program documentation to reflect the completion of Phase 3 v1.0, including new skill scoring and API enhancements. - Added new API endpoints for skill profile retrieval and suggestions, improving the ability to aggregate and display skills based on training data. - Introduced new UI components for skill profiles and discovery in the frontend, enhancing user interaction with training frameworks and skills. - Updated version information to 0.8.151, reflecting the addition of skill profiles and related features. --- .claude/docs/technical/SKILL_SCORING_SPEC.md | 65 ++++ ...EWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md | 23 +- backend/main.py | 3 +- backend/routers/skill_profiles.py | 354 ++++++++++++++++++ backend/skill_scoring.py | 345 +++++++++++++++++ backend/tests/test_skill_scoring.py | 60 +++ backend/version.py | 12 +- frontend/src/api/skillProfiles.js | 26 ++ frontend/src/app.css | 276 ++++++++++++++ .../ExerciseProgressionGraphPanel.jsx | 38 ++ .../components/skills/SkillDiscoveryPanel.jsx | 163 ++++++++ .../components/skills/SkillProfilePanel.jsx | 189 ++++++++++ frontend/src/pages/SkillsPage.jsx | 4 + .../TrainingFrameworkProgramEditPage.jsx | 43 +++ frontend/src/pages/TrainingModuleEditPage.jsx | 40 ++ frontend/src/utils/api.js | 2 + 16 files changed, 1626 insertions(+), 17 deletions(-) create mode 100644 .claude/docs/technical/SKILL_SCORING_SPEC.md create mode 100644 backend/routers/skill_profiles.py create mode 100644 backend/skill_scoring.py create mode 100644 backend/tests/test_skill_scoring.py create mode 100644 frontend/src/api/skillProfiles.js create mode 100644 frontend/src/components/skills/SkillDiscoveryPanel.jsx create mode 100644 frontend/src/components/skills/SkillProfilePanel.jsx diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md new file mode 100644 index 0000000..7a138ba --- /dev/null +++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md @@ -0,0 +1,65 @@ +# Gewichtetes Fähigkeiten-Scoring (Phase 3) + +**Stand:** 2026-05-20 +**Status:** Variante A (regelbasiert) umgesetzt — v1.0 +**Modul:** `backend/skill_scoring.py`, Router `skill_profiles` + +## Ziel + +Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken. + +## Datenquellen + +| Artefakt | Übungen aus | +|----------|-------------| +| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` | +| Rahmenprogramm (pro Slot) | Blueprint einer Session | +| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) | +| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) | + +Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`). + +## Gewichtungsformel (v1.0) + +Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt): + +1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min). +2. Pro verknüpfte Fähigkeit der Übung: + - `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor` + - Link-Faktor = 1.0 × (1.5 wenn `is_primary`) × Intensität (`niedrig` 0.85, `mittel` 1.0, `hoch` 1.2) × Entwicklungsbeitrag (`low` 0.9, `medium` 1.0, `high` 1.15) + +Aggregation: + +- Summe pro `skill_id` → `weight` +- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil) + +## API + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` | +| GET | `/api/training-modules/{id}/skill-profile` | `overall` | +| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` | +| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` | + +Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`). + +## UI + +- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session +- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“ +- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“ +- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche + +Profil wird nach **Speichern** neu geladen (`skillProfileTick`). + +## Grenzen / später + +- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index +- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring) +- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0 +- Trainings**einheiten** (Kalender) optional als nächste Erweiterung + +## Tests + +- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score diff --git a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md index d9d8663..312f74a 100644 --- a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md +++ b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md @@ -1,7 +1,7 @@ # Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap) **Stand:** 2026-05-20 -**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen +**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen ## Phase 1 (umgesetzt) @@ -29,23 +29,16 @@ **API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek. -## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch) +## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0) -### Ziel +**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md` -Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben). +- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph +- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge +- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite +- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche -### Variante A — Regelbasiert (ohne KI) - -1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln. -2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit). -3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten. -4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail. - -**Vorteil:** reproduzierbar, offline, Governance-konform. -**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“. - -### Variante B — KI-Zusammenfassung (OpenRouter, optional) +### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen) 1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen. 2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`). diff --git a/backend/main.py b/backend/main.py index 4fa97c7..d15db0b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports app.include_router(auth.router) app.include_router(profiles.router) @@ -208,6 +208,7 @@ app.include_router(media_assets.router) app.include_router(media_assets.admin_rights_router) app.include_router(media_assets.admin_legal_hold_router) app.include_router(skills.router) +app.include_router(skill_profiles.router) app.include_router(training_planning.router) app.include_router(dashboard.router) app.include_router(training_modules.router) diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py new file mode 100644 index 0000000..49474cd --- /dev/null +++ b/backend/routers/skill_profiles.py @@ -0,0 +1,354 @@ +""" +Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte. + +GET …/skill-profile — gewichtetes Profil aus verknüpften Übungen. +GET /api/skill-discovery/suggestions — Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql + +from skill_scoring import ( + GRAPH_DEFAULT_ITEM_MINUTES, + ExerciseOccurrence, + collect_module_exercise_occurrences, + collect_progression_graph_exercise_occurrences, + collect_unit_exercise_occurrences, + compute_skill_profile, + match_score_for_skill_ids, + profile_for_occurrences, +) + +from routers.training_framework_programs import _framework_access +from routers.training_modules import _module_access +from routers.exercise_progression_graphs import _require_graph_read + +router = APIRouter(prefix="/api", tags=["skill_profiles"]) + + +def _parse_skill_ids_param(raw: Optional[str]) -> List[int]: + if not raw or not str(raw).strip(): + return [] + out: List[int] = [] + for part in str(raw).split(","): + part = part.strip() + if not part: + continue + try: + n = int(part) + except ValueError: + raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None + if n > 0 and n not in out: + out.append(n) + return out + + +@router.get("/training-framework-programs/{framework_id}/skill-profile") +def framework_program_skill_profile( + framework_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _framework_access(cur, framework_id, profile_id, role) + cur.execute( + """ + SELECT s.id, s.sort_order, s.title, + tu.id AS blueprint_unit_id + FROM training_framework_slots s + LEFT JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + ORDER BY s.sort_order + """, + (framework_id,), + ) + slots_raw = [r2d(r) for r in cur.fetchall()] + + all_occurrences: List[ExerciseOccurrence] = [] + slot_profiles: List[Dict[str, Any]] = [] + + for slot in slots_raw: + uid = slot.get("blueprint_unit_id") + slot_occ: List[ExerciseOccurrence] = [] + slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}" + if uid: + raw_occ = collect_unit_exercise_occurrences(cur, int(uid)) + slot_occ = [ + ExerciseOccurrence( + exercise_id=o.exercise_id, + planned_duration_min=o.planned_duration_min, + context_label=slot_label, + ) + for o in raw_occ + ] + all_occurrences.extend(slot_occ) + else: + slot_occ = [] + slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile() + slot_profiles.append( + { + "slot_id": slot["id"], + "slot_title": slot.get("title"), + "sort_order": slot.get("sort_order"), + "blueprint_training_unit_id": uid, + "exercise_occurrence_count": len(slot_occ), + "profile": slot_profile, + } + ) + + overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile() + + return { + "artifact_type": "framework_program", + "artifact_id": framework_id, + "artifact_title": row.get("title"), + "overall": overall, + "slots": slot_profiles, + } + + +@router.get("/training-modules/{module_id}/skill-profile") +def training_module_skill_profile( + module_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _module_access(cur, module_id, profile_id, role) + occurrences = collect_module_exercise_occurrences(cur, module_id) + overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile() + return { + "artifact_type": "training_module", + "artifact_id": module_id, + "artifact_title": row.get("title"), + "overall": overall, + } + + +@router.get("/exercise-progression-graphs/{graph_id}/skill-profile") +def progression_graph_skill_profile( + graph_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _require_graph_read(cur, graph_id, profile_id, role) + occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) + overall = profile_for_occurrences( + cur, occurrences, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + ) if occurrences else _empty_profile() + return { + "artifact_type": "progression_graph", + "artifact_id": graph_id, + "artifact_title": row.get("name"), + "overall": overall, + } + + +def _empty_profile() -> Dict[str, Any]: + return compute_skill_profile([], {}) + + +@router.get("/skill-discovery/suggestions") +def skill_discovery_suggestions( + skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"), + types: Optional[str] = Query( + default="framework_program,training_module,progression_graph", + description="Artefakttypen, komma-getrennt", + ), + limit: int = Query(default=20, ge=1, le=50), + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt. + """ + wanted = _parse_skill_ids_param(skill_ids) + if not wanted: + raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)") + + type_set = {t.strip() for t in (types or "").split(",") if t.strip()} + profile_id = tenant.profile_id + role = tenant.global_role + results: List[Dict[str, Any]] = [] + + with get_db() as conn: + cur = get_cursor(conn) + + if "framework_program" in type_set: + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT fp.id, fp.title + FROM training_framework_programs fp + WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST + LIMIT 80 + """, + vis_params, + ) + for fp_row in cur.fetchall(): + fid = int(fp_row["id"]) + try: + _framework_access(cur, fid, profile_id, role) + except HTTPException: + continue + cur.execute( + """ + SELECT tu.id + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + """, + (fid,), + ) + occ: List[ExerciseOccurrence] = [] + for u in cur.fetchall(): + occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) + if not occ: + continue + prof = profile_for_occurrences(cur, occ) + match = match_score_for_skill_ids(prof, wanted) + if match["match_weight"] <= 0: + continue + results.append( + { + "artifact_type": "framework_program", + "artifact_id": fid, + "artifact_title": fp_row["title"], + "path": f"/planning/framework-programs/{fid}", + "match": match, + "skill_profile_summary": { + "total_weight": prof.get("total_weight"), + "top_skills": [ + {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]} + for s in (prof.get("skills") or [])[:5] + ], + }, + } + ) + + if "training_module" in type_set: + vis_clause, vis_params = library_content_visibility_sql( + alias="m", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT m.id, m.title + FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST + LIMIT 80 + """, + vis_params, + ) + for m_row in cur.fetchall(): + mid = int(m_row["id"]) + try: + _module_access(cur, mid, profile_id, role) + except HTTPException: + continue + occ = collect_module_exercise_occurrences(cur, mid) + if not occ: + continue + prof = profile_for_occurrences(cur, occ) + match = match_score_for_skill_ids(prof, wanted) + if match["match_weight"] <= 0: + continue + results.append( + { + "artifact_type": "training_module", + "artifact_id": mid, + "artifact_title": m_row["title"], + "path": f"/planning/training-modules/{mid}", + "match": match, + "skill_profile_summary": { + "total_weight": prof.get("total_weight"), + "top_skills": [ + {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]} + for s in (prof.get("skills") or [])[:5] + ], + }, + } + ) + + if "progression_graph" in type_set: + vis_clause, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT g.id, g.name + FROM exercise_progression_graphs g + WHERE ({vis_clause}) + ORDER BY g.updated_at DESC NULLS LAST + LIMIT 80 + """, + vis_params, + ) + for g_row in cur.fetchall(): + gid = int(g_row["id"]) + try: + _require_graph_read(cur, gid, profile_id, role) + except HTTPException: + continue + occ = collect_progression_graph_exercise_occurrences(cur, gid) + if not occ: + continue + prof = profile_for_occurrences( + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + ) + match = match_score_for_skill_ids(prof, wanted) + if match["match_weight"] <= 0: + continue + results.append( + { + "artifact_type": "progression_graph", + "artifact_id": gid, + "artifact_title": g_row["name"], + "path": None, + "match": match, + "skill_profile_summary": { + "total_weight": prof.get("total_weight"), + "top_skills": [ + {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]} + for s in (prof.get("skills") or [])[:5] + ], + }, + } + ) + + results.sort( + key=lambda x: ( + -float(x.get("match", {}).get("match_weight") or 0), + -(float(x.get("match", {}).get("match_percent") or 0)), + ) + ) + return { + "skill_ids": wanted, + "types": sorted(type_set), + "suggestions": results[:limit], + } + + +def _empty_profile() -> Dict[str, Any]: + return compute_skill_profile([], {}) diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py new file mode 100644 index 0000000..fe18c34 --- /dev/null +++ b/backend/skill_scoring.py @@ -0,0 +1,345 @@ +""" +Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert). + +Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul, +Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit, +Intensität und Entwicklungsbeitrag. +""" +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +DEFAULT_ITEM_MINUTES = 8 +GRAPH_DEFAULT_ITEM_MINUTES = 10 + +_INTENSITY_MULT = { + "niedrig": 0.85, + "low": 0.85, + "mittel": 1.0, + "medium": 1.0, + "hoch": 1.2, + "high": 1.2, +} + +_DEV_CONTRIB_MULT = { + "low": 0.9, + "niedrig": 0.9, + "medium": 1.0, + "mittel": 1.0, + "high": 1.15, + "hoch": 1.15, +} + + +@dataclass(frozen=True) +class ExerciseOccurrence: + exercise_id: int + planned_duration_min: Optional[int] = None + """Optional label for UI (e.g. slot title).""" + context_label: Optional[str] = None + + +def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUTES) -> float: + if planned is not None: + try: + m = int(planned) + if m > 0: + return float(m) + except (TypeError, ValueError): + pass + return float(default) + + +def _skill_link_multiplier( + *, + is_primary: bool = False, + intensity: Optional[str] = None, + development_contribution: Optional[str] = None, +) -> float: + mult = 1.0 + if is_primary: + mult *= 1.5 + if intensity: + key = str(intensity).strip().lower() + mult *= _INTENSITY_MULT.get(key, 1.0) + if development_contribution: + key = str(development_contribution).strip().lower() + mult *= _DEV_CONTRIB_MULT.get(key, 1.0) + return mult + + +def _round2(val: float) -> float: + return round(val, 2) + + +def compute_skill_profile( + occurrences: Sequence[ExerciseOccurrence], + skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]], + *, + default_item_minutes: int = DEFAULT_ITEM_MINUTES, +) -> Dict[str, Any]: + """ + Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills. + """ + exercise_meta: Dict[int, Dict[str, Any]] = defaultdict( + lambda: {"occurrence_count": 0, "minutes": 0.0, "context_labels": []} + ) + total_occurrences = 0 + for occ in occurrences or []: + eid = int(occ.exercise_id) + mins = _item_base_minutes(occ.planned_duration_min, default_item_minutes) + exercise_meta[eid]["occurrence_count"] += 1 + exercise_meta[eid]["minutes"] += mins + total_occurrences += 1 + if occ.context_label and occ.context_label not in exercise_meta[eid]["context_labels"]: + exercise_meta[eid]["context_labels"].append(occ.context_label) + + skill_acc: Dict[int, Dict[str, Any]] = {} + total_weight = 0.0 + exercises_with_skills: set[int] = set() + + for eid, meta in exercise_meta.items(): + links = skill_rows_by_exercise.get(eid) or [] + if not links: + continue + exercises_with_skills.add(eid) + occ_count = meta["occurrence_count"] + minutes_per_occ = meta["minutes"] / occ_count if occ_count else float(default_item_minutes) + + for link in links: + sid = link.get("skill_id") + if sid is None: + continue + sid = int(sid) + link_mult = _skill_link_multiplier( + is_primary=bool(link.get("is_primary")), + intensity=link.get("intensity"), + development_contribution=link.get("development_contribution"), + ) + contribution = minutes_per_occ * occ_count * link_mult + if contribution <= 0: + continue + + if sid not in skill_acc: + skill_acc[sid] = { + "skill_id": sid, + "skill_name": link.get("skill_name") or f"Fähigkeit #{sid}", + "category": link.get("category"), + "focus_areas": link.get("focus_areas"), + "weight": 0.0, + "occurrence_count": 0, + "primary_link_count": 0, + "exercises": {}, + } + acc = skill_acc[sid] + acc["weight"] += contribution + acc["occurrence_count"] += occ_count + if link.get("is_primary"): + acc["primary_link_count"] += occ_count + ex_key = str(eid) + if ex_key not in acc["exercises"]: + acc["exercises"][ex_key] = { + "exercise_id": eid, + "title": link.get("exercise_title") or f"Übung #{eid}", + "weight": 0.0, + "occurrence_count": occ_count, + } + acc["exercises"][ex_key]["weight"] += contribution + total_weight += contribution + + skills_out: List[Dict[str, Any]] = [] + for sid, acc in skill_acc.items(): + share = (acc["weight"] / total_weight * 100.0) if total_weight > 0 else 0.0 + ex_list = sorted( + acc["exercises"].values(), + key=lambda x: (-x["weight"], x.get("title") or ""), + )[:8] + for ex in ex_list: + ex["weight"] = _round2(ex["weight"]) + if total_weight > 0: + ex["share_percent"] = _round2(ex["weight"] / total_weight * 100.0) + else: + ex["share_percent"] = 0.0 + skills_out.append( + { + "skill_id": sid, + "skill_name": acc["skill_name"], + "category": acc.get("category"), + "focus_areas": acc.get("focus_areas"), + "weight": _round2(acc["weight"]), + "share_percent": _round2(share), + "occurrence_count": acc["occurrence_count"], + "primary_link_count": acc["primary_link_count"], + "top_exercises": ex_list, + } + ) + skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or "")) + + by_category: Dict[str, float] = defaultdict(float) + for sk in skills_out: + cat = (sk.get("category") or "").strip() or "—" + by_category[cat] += sk["weight"] + category_rows = [] + for cat, w in sorted(by_category.items(), key=lambda x: (-x[1], x[0])): + share = (w / total_weight * 100.0) if total_weight > 0 else 0.0 + category_rows.append( + {"category": cat, "weight": _round2(w), "share_percent": _round2(share)} + ) + + unique_exercises = len(exercise_meta) + return { + "computed_at": datetime.now(timezone.utc).isoformat(), + "scoring_version": "1.0", + "total_weight": _round2(total_weight), + "exercise_occurrence_count": total_occurrences, + "distinct_exercise_count": unique_exercises, + "exercises_with_skills_count": len(exercises_with_skills), + "skills": skills_out, + "by_category": category_rows, + } + + +def fetch_exercise_skills_bulk( + cur, exercise_ids: Iterable[int] +) -> Dict[int, List[Dict[str, Any]]]: + ids = sorted({int(x) for x in exercise_ids if x}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity, + es.development_contribution, es.required_level, es.target_level, + s.name AS skill_name, s.category, s.focus_areas, + e.title AS exercise_title + FROM exercise_skills es + JOIN skills s ON s.id = es.skill_id + JOIN exercises e ON e.id = es.exercise_id + WHERE es.exercise_id IN ({ph}) + AND (s.status = 'active' OR s.status IS NULL) + ORDER BY es.exercise_id, es.is_primary DESC, es.skill_id + """, + ids, + ) + out: Dict[int, List[Dict[str, Any]]] = defaultdict(list) + for row in cur.fetchall(): + d = dict(row) + eid = int(d["exercise_id"]) + fa = d.get("focus_areas") + if fa is not None and not isinstance(fa, list): + try: + import json + + fa = json.loads(fa) if isinstance(fa, str) else fa + except Exception: + fa = [] + d["focus_areas"] = fa if isinstance(fa, list) else [] + out[eid].append(d) + return dict(out) + + +def collect_unit_exercise_occurrences(cur, unit_id: int) -> List[ExerciseOccurrence]: + cur.execute( + """ + SELECT tusi.exercise_id, tusi.planned_duration_min + FROM training_unit_section_items tusi + INNER JOIN training_unit_sections tus ON tus.id = tusi.section_id + WHERE tus.training_unit_id = %s + AND tusi.item_type = 'exercise' + AND tusi.exercise_id IS NOT NULL + ORDER BY tus.order_index, tusi.order_index + """, + (int(unit_id),), + ) + return [ + ExerciseOccurrence( + exercise_id=int(r["exercise_id"]), + planned_duration_min=r.get("planned_duration_min"), + ) + for r in cur.fetchall() + ] + + +def collect_module_exercise_occurrences(cur, module_id: int) -> List[ExerciseOccurrence]: + cur.execute( + """ + SELECT exercise_id, planned_duration_min + FROM training_module_items + WHERE module_id = %s + AND item_type = 'exercise' + AND exercise_id IS NOT NULL + ORDER BY order_index + """, + (int(module_id),), + ) + return [ + ExerciseOccurrence( + exercise_id=int(r["exercise_id"]), + planned_duration_min=r.get("planned_duration_min"), + ) + for r in cur.fetchall() + ] + + +def collect_progression_graph_exercise_occurrences(cur, graph_id: int) -> List[ExerciseOccurrence]: + """Jedes Vorkommen als from- oder to-Endpunkt einer Kante zählt (ohne Dauer → Default).""" + cur.execute( + """ + SELECT from_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s + UNION ALL + SELECT to_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s + """, + (int(graph_id), int(graph_id)), + ) + return [ + ExerciseOccurrence( + exercise_id=int(r["exercise_id"]), + planned_duration_min=None, + context_label=None, + ) + for r in cur.fetchall() + ] + + +def profile_for_occurrences( + cur, + occurrences: Sequence[ExerciseOccurrence], + *, + default_item_minutes: int = DEFAULT_ITEM_MINUTES, +) -> Dict[str, Any]: + eids = [o.exercise_id for o in occurrences] + skills_map = fetch_exercise_skills_bulk(cur, eids) + return compute_skill_profile( + occurrences, skills_map, default_item_minutes=default_item_minutes + ) + + +def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: + """Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge).""" + wanted = {int(x) for x in skill_ids if x is not None} + if not wanted: + return { + "match_weight": 0.0, + "match_percent": 0.0, + "matched_skill_ids": [], + "matched_skills": [], + } + matched = [] + match_weight = 0.0 + total = float(profile.get("total_weight") or 0) + for sk in profile.get("skills") or []: + sid = int(sk["skill_id"]) + if sid in wanted: + matched.append(sk) + match_weight += float(sk.get("weight") or 0) + match_percent = (match_weight / total * 100.0) if total > 0 else 0.0 + return { + "match_weight": _round2(match_weight), + "match_percent": _round2(match_percent), + "matched_skill_ids": [int(m["skill_id"]) for m in matched], + "matched_skills": matched, + } diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py new file mode 100644 index 0000000..e7640ab --- /dev/null +++ b/backend/tests/test_skill_scoring.py @@ -0,0 +1,60 @@ +"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3).""" +from skill_scoring import ( + ExerciseOccurrence, + compute_skill_profile, + match_score_for_skill_ids, + _skill_link_multiplier, +) + + +def test_skill_link_multiplier_primary_and_intensity(): + assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2 + assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85 + + +def test_compute_skill_profile_aggregates_weights(): + occurrences = [ + ExerciseOccurrence(exercise_id=1, planned_duration_min=60), + ExerciseOccurrence(exercise_id=1, planned_duration_min=30), + ] + skills_map = { + 1: [ + { + "skill_id": 10, + "skill_name": "Distanz", + "category": "kihon", + "is_primary": True, + "intensity": "hoch", + "exercise_title": "Übung A", + }, + { + "skill_id": 11, + "skill_name": "Balance", + "category": "kihon", + "is_primary": False, + "intensity": "mittel", + "exercise_title": "Übung A", + }, + ], + } + profile = compute_skill_profile(occurrences, skills_map) + assert profile["exercise_occurrence_count"] == 2 + assert profile["distinct_exercise_count"] == 1 + assert len(profile["skills"]) == 2 + assert profile["skills"][0]["skill_id"] == 10 + assert profile["total_weight"] > profile["skills"][1]["weight"] + assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1 + + +def test_match_score_for_skill_ids(): + profile = { + "total_weight": 100.0, + "skills": [ + {"skill_id": 1, "skill_name": "A", "weight": 40.0}, + {"skill_id": 2, "skill_name": "B", "weight": 60.0}, + ], + } + m = match_score_for_skill_ids(profile, [1]) + assert m["match_weight"] == 40.0 + assert m["match_percent"] == 40.0 + assert m["matched_skill_ids"] == [1] diff --git a/backend/version.py b/backend/version.py index 529fd57..2752721 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.150" +APP_VERSION = "0.8.151" BUILD_DATE = "2026-05-20" DB_SCHEMA_VERSION = "20260520066" @@ -20,6 +20,7 @@ MODULE_VERSIONS = { "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "groups": "0.1.0", "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder + "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint @@ -36,6 +37,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.151", + "date": "2026-05-20", + "changes": [ + "Phase 3 Fähigkeiten-Scoring: skill_scoring.py (Dauer × Vorkommen × Primär/Intensität/Beitrag)", + "GET skill-profile für Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph", + "GET /api/skill-discovery/suggestions nach skill_ids", + ], + }, { "version": "0.8.149", "date": "2026-05-19", diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js new file mode 100644 index 0000000..7c20c3c --- /dev/null +++ b/frontend/src/api/skillProfiles.js @@ -0,0 +1,26 @@ +import { request } from './client.js' + +export async function getFrameworkProgramSkillProfile(frameworkId) { + return request(`/api/training-framework-programs/${frameworkId}/skill-profile`) +} + +export async function getTrainingModuleSkillProfile(moduleId) { + return request(`/api/training-modules/${moduleId}/skill-profile`) +} + +export async function getProgressionGraphSkillProfile(graphId) { + return request(`/api/exercise-progression-graphs/${graphId}/skill-profile`) +} + +/** + * @param {number[]} skillIds + * @param {{ types?: string, limit?: number }} opts + */ +export async function getSkillDiscoverySuggestions(skillIds, opts = {}) { + const ids = (skillIds || []).filter((x) => x != null && Number(x) > 0).join(',') + if (!ids) return { skill_ids: [], suggestions: [] } + const params = new URLSearchParams({ skill_ids: ids }) + if (opts.types) params.set('types', opts.types) + if (opts.limit != null) params.set('limit', String(opts.limit)) + return request(`/api/skill-discovery/suggestions?${params.toString()}`) +} diff --git a/frontend/src/app.css b/frontend/src/app.css index 958b06a..9e693c5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2475,6 +2475,282 @@ html.modal-scroll-locked .app-main { } } +/* Phase 3: Fähigkeiten-Profil & Planungs-Vorschläge */ +.skill-profile { + margin-bottom: 1rem; + padding: 0; + overflow: hidden; +} +.skill-profile--loading, +.skill-profile--error { + padding: 1rem 1.15rem; +} +.skill-profile__status { + margin: 0; + color: var(--text2); + font-size: 0.9rem; +} +.skill-profile__toggle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + width: 100%; + padding: 0.85rem 1.1rem; + border: none; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} +.skill-profile__toggle-title { + font-weight: 700; + color: var(--text1); +} +.skill-profile__toggle-badge { + font-size: 0.78rem; + color: var(--accent-dark); + background: var(--accent-light); + padding: 3px 8px; + border-radius: 999px; +} +.skill-profile__toggle-icon { + margin-left: auto; + color: var(--text3); +} +.skill-profile__body { + padding: 0 1.1rem 1rem; + border-top: 1px solid var(--border); +} +.skill-profile__hint { + margin: 0.65rem 0 0.75rem; +} +.skill-profile__empty { + margin: 0; + color: var(--text2); + font-size: 0.88rem; + line-height: 1.5; +} +.skill-profile__stats { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + font-size: 0.82rem; + color: var(--text2); + margin-bottom: 0.75rem; +} +.skill-profile__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.skill-profile__list--nested { + margin: 8px 0 0; + padding-left: 8px; + border-left: 2px solid var(--border); +} +.skill-profile__row-head { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.skill-profile__name { + font-weight: 600; + font-size: 0.88rem; + color: var(--text1); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.skill-profile__pct { + font-size: 0.82rem; + font-weight: 700; + color: var(--accent-dark); + flex-shrink: 0; +} +.skill-profile__bar-track { + height: 8px; + border-radius: 4px; + background: var(--surface2); + overflow: hidden; +} +.skill-profile__bar-fill { + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, var(--accent) 0%, var(--accent-dark) 100%); + min-width: 2px; +} +.skill-profile__meta-hint { + display: block; + font-size: 0.72rem; + color: var(--text3); + margin-top: 3px; +} +.skill-profile__categories { + margin-top: 0.85rem; +} +.skill-profile__categories-label, +.skill-profile__slots-label { + display: block; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin-bottom: 6px; +} +.skill-profile__category-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.skill-profile__category-chip { + font-size: 0.78rem; + padding: 3px 8px; + border-radius: 6px; + background: var(--surface2); + color: var(--text2); +} +.skill-profile__slots { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} +.skill-profile__slot-list { + list-style: none; + margin: 0; + padding: 0; +} +.skill-profile__slot-btn { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px; + width: 100%; + padding: 8px 0; + border: none; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; + color: var(--text1); +} +.skill-profile__slot-top { + font-size: 0.82rem; + color: var(--accent-dark); + font-weight: 600; +} +.skill-profile__slot-top--muted { + color: var(--text3); + font-weight: 500; +} + +.skill-discovery { + padding: 1.15rem 1.25rem; + max-width: 52rem; +} +.skill-discovery__title { + margin: 0 0 0.35rem; + font-size: 1.1rem; +} +.skill-discovery__lead { + margin: 0 0 1rem; + max-width: 40rem; +} +.skill-discovery__pick-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 8px; + max-height: 220px; + overflow-y: auto; + padding: 8px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface2); + margin-bottom: 1rem; +} +.skill-discovery__pick { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + font-size: 0.85rem; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; +} +.skill-discovery__pick:hover { + background: var(--surface); +} +.skill-discovery__pick-name { + font-weight: 600; + color: var(--text1); +} +.skill-discovery__pick-cat { + font-size: 0.72rem; + color: var(--text3); +} +.skill-discovery__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 0.75rem; +} +.skill-discovery__error { + color: var(--danger); + margin: 0 0 0.75rem; +} +.skill-discovery__results { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.skill-discovery__result { + padding: 0.85rem 1rem; + margin-bottom: 0; +} +.skill-discovery__result-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px; + margin-bottom: 4px; +} +.skill-discovery__result-type { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + color: var(--text3); +} +.skill-discovery__result-match { + font-size: 0.82rem; + font-weight: 700; + color: var(--accent-dark); +} +.skill-discovery__result-title { + display: block; + margin-bottom: 4px; +} +.skill-discovery__result-skills { + margin: 0 0 8px; + font-size: 0.85rem; + color: var(--text2); +} +.skill-discovery__result-link { + text-decoration: none; +} +.skill-discovery__no-hit { + margin: 0; +} + /* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */ .page-form-editor__body .framework-edit { min-width: 0; diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index dfd8661..983bd08 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' @@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({ const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') const [uiTab, setUiTab] = useState('overview') + const [skillProfileData, setSkillProfileData] = useState(null) + const [skillProfileLoading, setSkillProfileLoading] = useState(false) + const [skillProfileError, setSkillProfileError] = useState('') useEffect(() => { setSelectedGraphId(null) @@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({ } }, [refreshGraphs, tenantClubDepKey]) + useEffect(() => { + if (!selectedGraphId) { + setSkillProfileData(null) + return undefined + } + let cancelled = false + ;(async () => { + setSkillProfileLoading(true) + setSkillProfileError('') + try { + const data = await api.getProgressionGraphSkillProfile(selectedGraphId) + if (!cancelled) setSkillProfileData(data) + } catch (e) { + if (!cancelled) { + setSkillProfileData(null) + setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') + } + } finally { + if (!cancelled) setSkillProfileLoading(false) + } + })() + return () => { + cancelled = true + } + }, [selectedGraphId, edges.length]) + useEffect(() => { if (!selectedGraphId) { setEdges([]) @@ -652,6 +682,14 @@ export default function ExerciseProgressionGraphPanel({ {selectedGraphId && uiTab === 'overview' && ( <> +

Sequenz / Reihe anlegen

diff --git a/frontend/src/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx new file mode 100644 index 0000000..dd09642 --- /dev/null +++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx @@ -0,0 +1,163 @@ +import React, { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../../utils/api' + +const ARTIFACT_LABELS = { + framework_program: 'Rahmenprogramm', + training_module: 'Trainingsmodul', + progression_graph: 'Regressionspfad', +} + +/** + * Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3). + */ +export default function SkillDiscoveryPanel({ skills = [] }) { + const [selectedIds, setSelectedIds] = useState([]) + const [query, setQuery] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [result, setResult] = useState(null) + + const filteredSkills = useMemo(() => { + const q = query.trim().toLowerCase() + const list = (skills || []).filter((s) => s.status !== 'inactive') + if (!q) return list + return list.filter( + (s) => + (s.name || '').toLowerCase().includes(q) || + (s.category || '').toLowerCase().includes(q) + ) + }, [skills, query]) + + const toggleSkill = (id) => { + const s = String(id) + setSelectedIds((prev) => + prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s] + ) + } + + async function handleSearch() { + const ids = selectedIds.map((x) => parseInt(x, 10)).filter((n) => n > 0) + if (!ids.length) { + setError('Wähle mindestens eine Fähigkeit.') + return + } + setLoading(true) + setError('') + setResult(null) + try { + const data = await api.getSkillDiscoverySuggestions(ids, { limit: 25 }) + setResult(data) + } catch (e) { + setError(e.message || 'Suche fehlgeschlagen') + } finally { + setLoading(false) + } + } + + return ( +

+

Planungs-Vorschläge

+

+ Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst — Shinkan schlägt passende + Rahmenprogramme, Trainingsmodule und Regressionspfade aus der Bibliothek vor (gewichtet nach + Übungs-Verknüpfungen). +

+ +
+ + setQuery(e.target.value)} + placeholder="Name oder Kategorie …" + /> +
+ +
+ {filteredSkills.length === 0 ? ( +

Keine Fähigkeiten gefunden.

+ ) : ( + filteredSkills.map((sk) => ( + + )) + )} +
+ +
+ + {selectedIds.length > 0 ? ( + + ) : null} +
+ + {error ?

{error}

: null} + + {result?.suggestions?.length > 0 ? ( +
    + {result.suggestions.map((item) => ( +
  • +
    + + {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type} + + + Passung {item.match?.match_percent ?? 0}% + +
    + + {item.artifact_title || `#${item.artifact_id}`} + + {item.match?.matched_skills?.length > 0 ? ( +

    + {item.match.matched_skills.map((m) => m.skill_name).join(' · ')} +

    + ) : null} + {item.path ? ( + + Öffnen + + ) : item.artifact_type === 'progression_graph' ? ( +

    + Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten. +

    + ) : null} +
  • + ))} +
+ ) : result && !loading ? ( +

+ Keine passenden Artefakte in deiner sichtbaren Bibliothek — prüfe Fähigkeiten-Verknüpfungen an + den Übungen oder erweitere die Auswahl. +

+ ) : null} +
+ ) +} diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx new file mode 100644 index 0000000..dd58509 --- /dev/null +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -0,0 +1,189 @@ +import React, { useMemo, useState } from 'react' + +function SkillBar({ skill, maxShare }) { + const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0 + return ( +
  • +
    + + {skill.skill_name} + + {skill.share_percent}% +
    +
  • + ) +} + +/** + * Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte. + */ +export default function SkillProfilePanel({ + profile, + slots = null, + loading = false, + error = '', + title = 'Fähigkeiten-Profil', + hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).', + defaultExpanded = true, +}) { + const [expanded, setExpanded] = useState(defaultExpanded) + const [slotOpenId, setSlotOpenId] = useState(null) + + const skills = profile?.skills || [] + const maxShare = useMemo( + () => Math.max(...skills.map((s) => s.share_percent || 0), 1), + [skills] + ) + + if (loading) { + return ( +
    +

    Fähigkeiten-Profil wird berechnet…

    +
    + ) + } + + if (error) { + return ( +
    +

    {error}

    +
    + ) + } + + const noData = + !profile || + (profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0) + + return ( +
    + + + {expanded ? ( +
    +

    {hint}

    + + {noData ? ( +

    + Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe + Fähigkeiten in der Übungsbearbeitung. +

    + ) : profile.exercises_with_skills_count === 0 ? ( +

    + {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im + Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt. +

    + ) : ( + <> +
    + + {profile.distinct_exercise_count} Übungen + + + {skills.length} Fähigkeiten + + + {profile.exercise_occurrence_count} Positionen + +
    + +
      + {skills.slice(0, 12).map((sk) => ( + + ))} +
    + + {profile.by_category?.length > 1 ? ( +
    + Nach Kategorie +
    + {profile.by_category.slice(0, 6).map((c) => ( + + {c.category} {c.share_percent}% + + ))} +
    +
    + ) : null} + + )} + + {slots && slots.length > 0 ? ( +
    + Pro Session +
      + {slots.map((sl) => { + const open = slotOpenId === sl.slot_id + const top = sl.profile?.skills?.[0] + return ( +
    • + + {open && sl.profile?.skills?.length > 0 ? ( +
        + {sl.profile.skills.slice(0, 6).map((sk) => ( + x.share_percent || 0), + 1 + )} + /> + ))} +
      + ) : null} +
    • + ) + })} +
    +
    + ) : null} +
    + ) : null} +
    + ) +} diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx index 60c93d1..691aed9 100644 --- a/frontend/src/pages/SkillsPage.jsx +++ b/frontend/src/pages/SkillsPage.jsx @@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import PageSectionNav from '../components/PageSectionNav' +import SkillDiscoveryPanel from '../components/skills/SkillDiscoveryPanel' const SKILLS_SECTION_TABS = [ { id: 'skills', label: 'Fähigkeiten' }, { id: 'methods', label: 'Trainingsmethoden' }, + { id: 'discovery', label: 'Planungs-Vorschläge' }, ] function SkillsPage() { @@ -243,6 +245,8 @@ function SkillsPage() { )} + {activeTab === 'discovery' && } + {/* Methods Tab */} {activeTab === 'methods' && ( <> diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 6f10322..322660f 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import PageSectionNav from '../components/PageSectionNav' import PageFormEditorChrome from '../components/PageFormEditorChrome' +import SkillProfilePanel from '../components/skills/SkillProfilePanel' import { useToast } from '../context/ToastContext' import { useNavReturn } from '../hooks/useNavReturn' import { @@ -248,6 +249,10 @@ export default function TrainingFrameworkProgramEditPage() { ) /** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */ const [mobileSlotIdx, setMobileSlotIdx] = useState(0) + const [skillProfileData, setSkillProfileData] = useState(null) + const [skillProfileLoading, setSkillProfileLoading] = useState(false) + const [skillProfileError, setSkillProfileError] = useState('') + const [skillProfileTick, setSkillProfileTick] = useState(0) const toast = useToast() const baselineRef = useRef(null) @@ -301,6 +306,32 @@ export default function TrainingFrameworkProgramEditPage() { return () => document.removeEventListener('pointerdown', onPointerDown, true) }, []) + useEffect(() => { + if (isNew || !idParam) { + setSkillProfileData(null) + return undefined + } + let cancelled = false + ;(async () => { + setSkillProfileLoading(true) + setSkillProfileError('') + try { + const data = await api.getFrameworkProgramSkillProfile(idParam) + if (!cancelled) setSkillProfileData(data) + } catch (e) { + if (!cancelled) { + setSkillProfileData(null) + setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') + } + } finally { + if (!cancelled) setSkillProfileLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isNew, idParam, skillProfileTick]) + const loadMeta = useCallback(async () => { try { const [cl, fa, sd, tt, tg] = await Promise.all([ @@ -480,6 +511,7 @@ export default function TrainingFrameworkProgramEditPage() { setBypassDirty(false) setBaselineReady(true) toast.success('Gespeichert.') + setSkillProfileTick((t) => t + 1) if (closeAfter) goBack() return true } catch (e) { @@ -940,6 +972,17 @@ export default function TrainingFrameworkProgramEditPage() { />
    + {!isNew ? ( + + ) : null} +
    { + if (isNew || !Number.isFinite(moduleId)) { + setSkillProfileData(null) + return undefined + } + let cancelled = false + ;(async () => { + setSkillProfileLoading(true) + setSkillProfileError('') + try { + const data = await api.getTrainingModuleSkillProfile(moduleId) + if (!cancelled) setSkillProfileData(data) + } catch (e) { + if (!cancelled) { + setSkillProfileData(null) + setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') + } + } finally { + if (!cancelled) setSkillProfileLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isNew, moduleId, skillProfileTick]) + const { user } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) @@ -339,6 +370,7 @@ export default function TrainingModuleEditPage() { baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBypassDirty(false) toast.success('Gespeichert.') + setSkillProfileTick((t) => t + 1) if (closeAfter) goBack() return true } catch (err) { @@ -391,6 +423,14 @@ export default function TrainingModuleEditPage() { onSubmit={handleSave} >
    + {!isNew ? ( + + ) : null}
    setTitle(e.target.value)} /> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 4d999ad..9528ba1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -7,10 +7,12 @@ import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js' import * as exercises from '../api/exercises.js' import * as planning from '../api/planning.js' +import * as skillProfiles from '../api/skillProfiles.js' export { ACTIVE_CLUB_STORAGE_KEY } export * from '../api/exercises.js' export * from '../api/planning.js' +export * from '../api/skillProfiles.js' // ============================================================================ // Auth From f67bf280c3105f3cb7453935efecb504962cc3c5 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 08:11:46 +0200 Subject: [PATCH 02/10] Add Skill Profiles API Integration for Phase 3 --- frontend/src/utils/api.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 9528ba1..64454eb 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -755,6 +755,9 @@ export const api = { // Training Planning → frontend/src/api/planning.js ...planning, + // Fähigkeiten-Profile & Vorschläge (Phase 3) → frontend/src/api/skillProfiles.js + ...skillProfiles, + // Catalogs listFocusAreas, createFocusArea, From 8f8bdf6d8b14865e888acd3f08f1f6329f68427a Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 08:24:23 +0200 Subject: [PATCH 03/10] Update Skill Scoring Specification and Implementation to v1.1 - Enhanced the skill scoring formula to incorporate intensity and level range factors, improving the accuracy of skill contributions. - Removed the use of `is_primary` and `development_contribution` from calculations, streamlining the scoring process. - Updated documentation to reflect changes in the scoring logic and versioning. - Adjusted frontend components to align with the new scoring criteria, ensuring consistent user experience across the application. --- .claude/docs/technical/SKILL_SCORING_SPEC.md | 33 ++++++- backend/skill_scoring.py | 86 +++++++++++++------ backend/tests/test_skill_scoring.py | 32 +++++-- .../ExerciseProgressionGraphPanel.jsx | 2 +- .../components/skills/SkillProfilePanel.jsx | 7 +- .../TrainingFrameworkProgramEditPage.jsx | 2 +- 6 files changed, 119 insertions(+), 43 deletions(-) diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md index 7a138ba..8e6e852 100644 --- a/.claude/docs/technical/SKILL_SCORING_SPEC.md +++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md @@ -1,7 +1,7 @@ # Gewichtetes Fähigkeiten-Scoring (Phase 3) **Stand:** 2026-05-20 -**Status:** Variante A (regelbasiert) umgesetzt — v1.0 +**Status:** Variante A (regelbasiert) umgesetzt — **v1.1** (Intensität + Stufen-Spanne, ohne Primär) **Modul:** `backend/skill_scoring.py`, Router `skill_profiles` ## Ziel @@ -19,14 +19,39 @@ Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rah Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`). -## Gewichtungsformel (v1.0) +## Gewichtungsformel (v1.1) Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt): 1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min). 2. Pro verknüpfte Fähigkeit der Übung: - - `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor` - - Link-Faktor = 1.0 × (1.5 wenn `is_primary`) × Intensität (`niedrig` 0.85, `mittel` 1.0, `hoch` 1.2) × Entwicklungsbeitrag (`low` 0.9, `medium` 1.0, `high` 1.15) + - `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor` + - **Link-Faktor** = Intensität × Stufen-Faktor + +### Intensität (Nutzeneinschätzung, UI-Feld) + +| Wert | Faktor | +|------|--------| +| niedrig | 0,85 | +| mittel / leer | 1,0 | +| hoch | 1,2 | + +### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“) + +Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0. + +- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (1–5) +- **Mittelpunkt** = durchschnittliche Stufe +- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20 + +Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1). + +### Bewusst nicht im Scoring + +| Feld | Grund | +|------|--------| +| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein | +| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt | Aggregation: diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index fe18c34..90fdd04 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -1,9 +1,11 @@ """ Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert). -Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul, -Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit, -Intensität und Entwicklungsbeitrag. +Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus: +geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis). + +is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist +in der UI nicht gepflegt und wird ignoriert. """ from __future__ import annotations @@ -25,16 +27,58 @@ _INTENSITY_MULT = { "high": 1.2, } -_DEV_CONTRIB_MULT = { - "low": 0.9, - "niedrig": 0.9, - "medium": 1.0, - "mittel": 1.0, - "high": 1.15, - "hoch": 1.15, +# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js +_LEVEL_RANK = { + "basis": 1, + "grundlagen": 2, + "aufbau": 3, + "fortgeschritten": 4, + "optimierung": 5, + "einsteiger": 1, + "experte": 5, + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, } +def _level_rank(value: Optional[str]) -> Optional[int]: + if value is None: + return None + key = str(value).strip().lower() + if not key: + return None + rank = _LEVEL_RANK.get(key) + return rank if rank is not None else None + + +def _level_range_multiplier( + required_level: Optional[str] = None, + target_level: Optional[str] = None, +) -> float: + """ + Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen → etwas höheres Gewicht. + Fehlen beide Angaben: neutral (1.0). + """ + rr = _level_rank(required_level) + rt = _level_rank(target_level) + if rr is None and rt is None: + return 1.0 + if rr is None: + rr = rt + if rt is None: + rt = rr + if rr > rt: + rr, rt = rt, rr + span = max(1, min(5, rt - rr + 1)) + midpoint = (rr + rt) / 2.0 + span_mult = 0.92 + 0.04 * span + depth_mult = 0.95 + 0.025 * midpoint + return span_mult * depth_mult + + @dataclass(frozen=True) class ExerciseOccurrence: exercise_id: int @@ -56,19 +100,15 @@ def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUT def _skill_link_multiplier( *, - is_primary: bool = False, intensity: Optional[str] = None, - development_contribution: Optional[str] = None, + required_level: Optional[str] = None, + target_level: Optional[str] = None, ) -> float: mult = 1.0 - if is_primary: - mult *= 1.5 if intensity: key = str(intensity).strip().lower() mult *= _INTENSITY_MULT.get(key, 1.0) - if development_contribution: - key = str(development_contribution).strip().lower() - mult *= _DEV_CONTRIB_MULT.get(key, 1.0) + mult *= _level_range_multiplier(required_level, target_level) return mult @@ -116,9 +156,9 @@ def compute_skill_profile( continue sid = int(sid) link_mult = _skill_link_multiplier( - is_primary=bool(link.get("is_primary")), intensity=link.get("intensity"), - development_contribution=link.get("development_contribution"), + required_level=link.get("required_level"), + target_level=link.get("target_level"), ) contribution = minutes_per_occ * occ_count * link_mult if contribution <= 0: @@ -132,14 +172,11 @@ def compute_skill_profile( "focus_areas": link.get("focus_areas"), "weight": 0.0, "occurrence_count": 0, - "primary_link_count": 0, "exercises": {}, } acc = skill_acc[sid] acc["weight"] += contribution acc["occurrence_count"] += occ_count - if link.get("is_primary"): - acc["primary_link_count"] += occ_count ex_key = str(eid) if ex_key not in acc["exercises"]: acc["exercises"][ex_key] = { @@ -173,7 +210,6 @@ def compute_skill_profile( "weight": _round2(acc["weight"]), "share_percent": _round2(share), "occurrence_count": acc["occurrence_count"], - "primary_link_count": acc["primary_link_count"], "top_exercises": ex_list, } ) @@ -193,7 +229,7 @@ def compute_skill_profile( unique_exercises = len(exercise_meta) return { "computed_at": datetime.now(timezone.utc).isoformat(), - "scoring_version": "1.0", + "scoring_version": "1.1", "total_weight": _round2(total_weight), "exercise_occurrence_count": total_occurrences, "distinct_exercise_count": unique_exercises, @@ -221,7 +257,7 @@ def fetch_exercise_skills_bulk( JOIN exercises e ON e.id = es.exercise_id WHERE es.exercise_id IN ({ph}) AND (s.status = 'active' OR s.status IS NULL) - ORDER BY es.exercise_id, es.is_primary DESC, es.skill_id + ORDER BY es.exercise_id, s.name, es.skill_id """, ids, ) diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py index e7640ab..df3695c 100644 --- a/backend/tests/test_skill_scoring.py +++ b/backend/tests/test_skill_scoring.py @@ -3,13 +3,30 @@ from skill_scoring import ( ExerciseOccurrence, compute_skill_profile, match_score_for_skill_ids, + _level_range_multiplier, _skill_link_multiplier, ) -def test_skill_link_multiplier_primary_and_intensity(): - assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2 - assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85 +def test_skill_link_multiplier_intensity_and_levels(): + assert _skill_link_multiplier(intensity="hoch") == 1.2 + assert _skill_link_multiplier(intensity="niedrig") == 0.85 + wide = _skill_link_multiplier( + intensity="mittel", + required_level="basis", + target_level="optimierung", + ) + narrow = _skill_link_multiplier( + intensity="mittel", + required_level="grundlagen", + target_level="grundlagen", + ) + assert wide > narrow + + +def test_level_range_multiplier_span(): + assert _level_range_multiplier(None, None) == 1.0 + assert _level_range_multiplier("aufbau", "fortgeschritten") > _level_range_multiplier("basis", "basis") def test_compute_skill_profile_aggregates_weights(): @@ -23,21 +40,24 @@ def test_compute_skill_profile_aggregates_weights(): "skill_id": 10, "skill_name": "Distanz", "category": "kihon", - "is_primary": True, "intensity": "hoch", + "required_level": "grundlagen", + "target_level": "aufbau", "exercise_title": "Übung A", }, { "skill_id": 11, "skill_name": "Balance", "category": "kihon", - "is_primary": False, - "intensity": "mittel", + "intensity": "niedrig", + "required_level": "basis", + "target_level": "basis", "exercise_title": "Übung A", }, ], } profile = compute_skill_profile(occurrences, skills_map) + assert profile["scoring_version"] == "1.1" assert profile["exercise_occurrence_count"] == 2 assert profile["distinct_exercise_count"] == 1 assert len(profile["skills"]) == 2 diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 983bd08..51c1cf8 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -684,7 +684,7 @@ export default function ExerciseProgressionGraphPanel({ <>
    - {skill.primary_link_count > 0 ? ( - - {skill.primary_link_count}× als Primär-Fähigkeit in Übungen - - ) : null} ) } @@ -34,7 +29,7 @@ export default function SkillProfilePanel({ loading = false, error = '', title = 'Fähigkeiten-Profil', - hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).', + hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis).', defaultExpanded = true, }) { const [expanded, setExpanded] = useState(defaultExpanded) diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 322660f..141e5c1 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() { {!isNew ? ( Date: Thu, 21 May 2026 08:37:58 +0200 Subject: [PATCH 04/10] Update Skill Scoring Specification and Implementation to v1.2 - Enhanced the skill scoring system with category grouping and a universal scale for improved comparability across programs. - Introduced new calculations for artifact share percentage and universal percent, allowing for a more nuanced understanding of skill contributions. - Updated the API to reflect changes in the skill profile structure, including main category and top skill details. - Improved frontend components to display skills by main category, enhancing user experience in skill discovery and profile visualization. - Adjusted tests to validate the new scoring logic and ensure accurate representation of skills and their weights. --- .claude/docs/technical/SKILL_SCORING_SPEC.md | 10 +- backend/routers/skill_profiles.py | 113 ++++++-- backend/skill_scoring.py | 264 ++++++++++++++++-- backend/tests/test_skill_scoring.py | 39 ++- frontend/src/app.css | 59 ++++ .../components/skills/SkillDiscoveryPanel.jsx | 84 ++++-- .../components/skills/SkillProfilePanel.jsx | 172 ++++++++---- .../TrainingFrameworkProgramEditPage.jsx | 2 +- 8 files changed, 604 insertions(+), 139 deletions(-) diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md index 8e6e852..eda832e 100644 --- a/.claude/docs/technical/SKILL_SCORING_SPEC.md +++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md @@ -1,7 +1,7 @@ # Gewichtetes Fähigkeiten-Scoring (Phase 3) **Stand:** 2026-05-20 -**Status:** Variante A (regelbasiert) umgesetzt — **v1.1** (Intensität + Stufen-Spanne, ohne Primär) +**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala) **Modul:** `backend/skill_scoring.py`, Router `skill_profiles` ## Ziel @@ -55,8 +55,12 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1). Aggregation: -- Summe pro `skill_id` → `weight` -- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil) +- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**) +- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär) +- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht) +- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ) + +Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil. ## API diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py index 49474cd..6253cab 100644 --- a/backend/routers/skill_profiles.py +++ b/backend/routers/skill_profiles.py @@ -17,6 +17,7 @@ from skill_scoring import ( collect_module_exercise_occurrences, collect_progression_graph_exercise_occurrences, collect_unit_exercise_occurrences, + compute_corpus_skill_max_weights, compute_skill_profile, match_score_for_skill_ids, profile_for_occurrences, @@ -69,6 +70,13 @@ def framework_program_skill_profile( ) slots_raw = [r2d(r) for r in cur.fetchall()] + ref_max = compute_corpus_skill_max_weights( + cur, + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + all_occurrences: List[ExerciseOccurrence] = [] slot_profiles: List[Dict[str, Any]] = [] @@ -89,7 +97,11 @@ def framework_program_skill_profile( all_occurrences.extend(slot_occ) else: slot_occ = [] - slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile() + slot_profile = ( + profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max) + if slot_occ + else _empty_profile() + ) slot_profiles.append( { "slot_id": slot["id"], @@ -101,12 +113,20 @@ def framework_program_skill_profile( } ) - overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile() + overall = ( + profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max) + if all_occurrences + else _empty_profile() + ) return { "artifact_type": "framework_program", "artifact_id": framework_id, "artifact_title": row.get("title"), + "reference_scale": { + "skills_in_corpus": len(ref_max), + "description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek", + }, "overall": overall, "slots": slot_profiles, } @@ -122,12 +142,25 @@ def training_module_skill_profile( with get_db() as conn: cur = get_cursor(conn) row = _module_access(cur, module_id, profile_id, role) + ref_max = compute_corpus_skill_max_weights( + cur, + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) occurrences = collect_module_exercise_occurrences(cur, module_id) - overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile() + overall = ( + profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max) + if occurrences + else _empty_profile() + ) return { "artifact_type": "training_module", "artifact_id": module_id, "artifact_title": row.get("title"), + "reference_scale": { + "skills_in_corpus": len(ref_max), + }, "overall": overall, } @@ -142,22 +175,34 @@ def progression_graph_skill_profile( with get_db() as conn: cur = get_cursor(conn) row = _require_graph_read(cur, graph_id, profile_id, role) + ref_max = compute_corpus_skill_max_weights( + cur, + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) - overall = profile_for_occurrences( - cur, occurrences, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES - ) if occurrences else _empty_profile() + overall = ( + profile_for_occurrences( + cur, + occurrences, + default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES, + reference_max_by_skill=ref_max, + ) + if occurrences + else _empty_profile() + ) return { "artifact_type": "progression_graph", "artifact_id": graph_id, "artifact_title": row.get("name"), + "reference_scale": { + "skills_in_corpus": len(ref_max), + }, "overall": overall, } -def _empty_profile() -> Dict[str, Any]: - return compute_skill_profile([], {}) - - @router.get("/skill-discovery/suggestions") def skill_discovery_suggestions( skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"), @@ -232,11 +277,8 @@ def skill_discovery_suggestions( "path": f"/planning/framework-programs/{fid}", "match": match, "skill_profile_summary": { - "total_weight": prof.get("total_weight"), - "top_skills": [ - {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]} - for s in (prof.get("skills") or [])[:5] - ], + "total_score": prof.get("total_score"), + "top_by_category": _top_categories_summary(prof), }, } ) @@ -279,11 +321,8 @@ def skill_discovery_suggestions( "path": f"/planning/training-modules/{mid}", "match": match, "skill_profile_summary": { - "total_weight": prof.get("total_weight"), - "top_skills": [ - {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]} - for s in (prof.get("skills") or [])[:5] - ], + "total_score": prof.get("total_score"), + "top_by_category": _top_categories_summary(prof), }, } ) @@ -328,20 +367,14 @@ def skill_discovery_suggestions( "path": None, "match": match, "skill_profile_summary": { - "total_weight": prof.get("total_weight"), - "top_skills": [ - {"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]} - for s in (prof.get("skills") or [])[:5] - ], + "total_score": prof.get("total_score"), + "top_by_category": _top_categories_summary(prof), }, } ) results.sort( - key=lambda x: ( - -float(x.get("match", {}).get("match_weight") or 0), - -(float(x.get("match", {}).get("match_percent") or 0)), - ) + key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0), ) return { "skill_ids": wanted, @@ -350,5 +383,27 @@ def skill_discovery_suggestions( } +def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]: + """Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer.""" + out: List[Dict[str, Any]] = [] + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if not top: + continue + out.append( + { + "main_category_name": mc.get("main_category_name"), + "category_name": cat.get("category_name"), + "skill_id": top.get("skill_id"), + "skill_name": top.get("skill_name"), + "score": top.get("score") or top.get("weight"), + } + ) + if len(out) >= limit: + return out + return out + + def _empty_profile() -> Dict[str, Any]: return compute_skill_profile([], {}) diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 90fdd04..8340a21 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -116,11 +116,93 @@ def _round2(val: float) -> float: return round(val, 2) +def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht.""" + main_map: Dict[int, Dict[str, Any]] = {} + + for sk in skills_out: + mc_id = int(sk.get("main_category_id") or 0) + mc_name = (sk.get("main_category_name") or "").strip() or "—" + cat_id = int(sk.get("category_id") or 0) + cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or "—" + + if mc_id not in main_map: + main_map[mc_id] = { + "main_category_id": mc_id if mc_id else None, + "main_category_name": mc_name, + "weight": 0.0, + "categories": {}, + } + main = main_map[mc_id] + main["weight"] += float(sk.get("weight") or 0) + + if cat_id not in main["categories"]: + main["categories"][cat_id] = { + "category_id": cat_id if cat_id else None, + "category_name": cat_name, + "weight": 0.0, + "skills": [], + } + cat = main["categories"][cat_id] + cat["weight"] += float(sk.get("weight") or 0) + cat["skills"].append(sk) + + result: List[Dict[str, Any]] = [] + for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")): + cats_out: List[Dict[str, Any]] = [] + for cat in sorted( + mc["categories"].values(), + key=lambda x: (-x["weight"], x.get("category_name") or ""), + ): + cat_skills = sorted( + cat["skills"], + key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""), + ) + top = cat_skills[0] if cat_skills else None + cats_out.append( + { + "category_id": cat["category_id"], + "category_name": cat["category_name"], + "weight": _round2(cat["weight"]), + "skills_count": len(cat_skills), + "top_skill": top, + } + ) + result.append( + { + "main_category_id": mc["main_category_id"], + "main_category_name": mc["main_category_name"], + "weight": _round2(mc["weight"]), + "categories": cats_out, + } + ) + return result + + +def _apply_reference_universal_percent( + skills_out: List[Dict[str, Any]], + reference_max_by_skill: Optional[Dict[int, float]] = None, +) -> None: + """ + Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte). + """ + if not reference_max_by_skill: + for sk in skills_out: + sk["universal_percent"] = None + return + for sk in skills_out: + sid = int(sk["skill_id"]) + ref = float(reference_max_by_skill.get(sid) or 0) + w = float(sk.get("weight") or 0) + sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None + + def compute_skill_profile( occurrences: Sequence[ExerciseOccurrence], skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]], *, default_item_minutes: int = DEFAULT_ITEM_MINUTES, + reference_max_by_skill: Optional[Dict[int, float]] = None, ) -> Dict[str, Any]: """ Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills. @@ -168,7 +250,11 @@ def compute_skill_profile( skill_acc[sid] = { "skill_id": sid, "skill_name": link.get("skill_name") or f"Fähigkeit #{sid}", - "category": link.get("category"), + "category": link.get("category_name") or link.get("category"), + "category_id": link.get("category_id"), + "category_name": link.get("category_name") or link.get("category"), + "main_category_id": link.get("main_category_id"), + "main_category_name": link.get("main_category_name"), "focus_areas": link.get("focus_areas"), "weight": 0.0, "occurrence_count": 0, @@ -205,9 +291,15 @@ def compute_skill_profile( { "skill_id": sid, "skill_name": acc["skill_name"], - "category": acc.get("category"), + "category": acc.get("category_name") or acc.get("category"), + "category_id": acc.get("category_id"), + "category_name": acc.get("category_name") or acc.get("category"), + "main_category_id": acc.get("main_category_id"), + "main_category_name": acc.get("main_category_name"), "focus_areas": acc.get("focus_areas"), "weight": _round2(acc["weight"]), + "score": _round2(acc["weight"]), + "artifact_share_percent": _round2(share), "share_percent": _round2(share), "occurrence_count": acc["occurrence_count"], "top_exercises": ex_list, @@ -215,27 +307,32 @@ def compute_skill_profile( ) skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or "")) - by_category: Dict[str, float] = defaultdict(float) - for sk in skills_out: - cat = (sk.get("category") or "").strip() or "—" - by_category[cat] += sk["weight"] - category_rows = [] - for cat, w in sorted(by_category.items(), key=lambda x: (-x[1], x[0])): - share = (w / total_weight * 100.0) if total_weight > 0 else 0.0 - category_rows.append( - {"category": cat, "weight": _round2(w), "share_percent": _round2(share)} - ) + _apply_reference_universal_percent(skills_out, reference_max_by_skill) + + by_main_category = _build_by_main_category(skills_out) + for mc in by_main_category: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if top and reference_max_by_skill: + sid = int(top["skill_id"]) + ref = float(reference_max_by_skill.get(sid) or 0) + if ref > 0: + top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0) unique_exercises = len(exercise_meta) return { "computed_at": datetime.now(timezone.utc).isoformat(), - "scoring_version": "1.1", + "scoring_version": "1.2", + "score_unit": "weighted_minutes", + "score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)", "total_weight": _round2(total_weight), + "total_score": _round2(total_weight), "exercise_occurrence_count": total_occurrences, "distinct_exercise_count": unique_exercises, "exercises_with_skills_count": len(exercises_with_skills), "skills": skills_out, - "by_category": category_rows, + "by_main_category": by_main_category, + "has_reference_scale": bool(reference_max_by_skill), } @@ -250,10 +347,15 @@ def fetch_exercise_skills_bulk( f""" SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity, es.development_contribution, es.required_level, es.target_level, - s.name AS skill_name, s.category, s.focus_areas, + s.name AS skill_name, s.category, + sc.id AS category_id, sc.name AS category_name, + mc.id AS main_category_id, mc.name AS main_category_name, + s.focus_areas, e.title AS exercise_title FROM exercise_skills es JOIN skills s ON s.id = es.skill_id + LEFT JOIN skill_categories sc ON sc.id = s.category_id + LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id) JOIN exercises e ON e.id = es.exercise_id WHERE es.exercise_id IN ({ph}) AND (s.status = 'active' OR s.status IS NULL) @@ -346,21 +448,145 @@ def profile_for_occurrences( occurrences: Sequence[ExerciseOccurrence], *, default_item_minutes: int = DEFAULT_ITEM_MINUTES, + reference_max_by_skill: Optional[Dict[int, float]] = None, ) -> Dict[str, Any]: eids = [o.exercise_id for o in occurrences] skills_map = fetch_exercise_skills_bulk(cur, eids) return compute_skill_profile( - occurrences, skills_map, default_item_minutes=default_item_minutes + occurrences, + skills_map, + default_item_minutes=default_item_minutes, + reference_max_by_skill=reference_max_by_skill, ) +def merge_skill_weights_into_max( + target: Dict[int, float], + profile: Dict[str, Any], +) -> None: + for sk in profile.get("skills") or []: + sid = int(sk["skill_id"]) + w = float(sk.get("weight") or 0) + if w > target.get(sid, 0.0): + target[sid] = w + + +def compute_corpus_skill_max_weights( + cur, + *, + profile_id: int, + role: Optional[str], + effective_club_id: Optional[int], + limit_per_type: int = 50, +) -> Dict[int, float]: + """ + Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte. + Basis für universal_percent (Skala über alle Programme). + """ + from tenant_context import library_content_visibility_sql + + max_by_skill: Dict[int, float] = {} + + def scan_frameworks(): + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT fp.id FROM training_framework_programs fp + WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST + LIMIT %s + """, + (*vis_params, limit_per_type), + ) + for row in cur.fetchall(): + fid = int(row["id"]) + cur.execute( + """ + SELECT tu.id + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + """, + (fid,), + ) + occ: List[ExerciseOccurrence] = [] + for u in cur.fetchall(): + occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) + if not occ: + continue + prof = profile_for_occurrences(cur, occ) + merge_skill_weights_into_max(max_by_skill, prof) + + def scan_modules(): + vis_clause, vis_params = library_content_visibility_sql( + alias="m", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT m.id FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST + LIMIT %s + """, + (*vis_params, limit_per_type), + ) + for row in cur.fetchall(): + mid = int(row["id"]) + occ = collect_module_exercise_occurrences(cur, mid) + if not occ: + continue + prof = profile_for_occurrences(cur, occ) + merge_skill_weights_into_max(max_by_skill, prof) + + def scan_graphs(): + vis_clause, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT g.id FROM exercise_progression_graphs g + WHERE ({vis_clause}) + ORDER BY g.updated_at DESC NULLS LAST + LIMIT %s + """, + (*vis_params, limit_per_type), + ) + for row in cur.fetchall(): + gid = int(row["id"]) + occ = collect_progression_graph_exercise_occurrences(cur, gid) + if not occ: + continue + prof = profile_for_occurrences( + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + ) + merge_skill_weights_into_max(max_by_skill, prof) + + scan_frameworks() + scan_modules() + scan_graphs() + return max_by_skill + + def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: """Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge).""" wanted = {int(x) for x in skill_ids if x is not None} if not wanted: return { "match_weight": 0.0, + "match_score": 0.0, "match_percent": 0.0, + "artifact_focus_percent": 0.0, "matched_skill_ids": [], "matched_skills": [], } @@ -372,10 +598,12 @@ def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) if sid in wanted: matched.append(sk) match_weight += float(sk.get("weight") or 0) - match_percent = (match_weight / total * 100.0) if total > 0 else 0.0 + artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0 return { "match_weight": _round2(match_weight), - "match_percent": _round2(match_percent), + "match_score": _round2(match_weight), + "match_percent": _round2(artifact_focus), + "artifact_focus_percent": _round2(artifact_focus), "matched_skill_ids": [int(m["skill_id"]) for m in matched], "matched_skills": matched, } diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py index df3695c..4e0f0c7 100644 --- a/backend/tests/test_skill_scoring.py +++ b/backend/tests/test_skill_scoring.py @@ -40,6 +40,10 @@ def test_compute_skill_profile_aggregates_weights(): "skill_id": 10, "skill_name": "Distanz", "category": "kihon", + "category_id": 1, + "category_name": "kihon", + "main_category_id": 100, + "main_category_name": "Technik", "intensity": "hoch", "required_level": "grundlagen", "target_level": "aufbau", @@ -49,6 +53,10 @@ def test_compute_skill_profile_aggregates_weights(): "skill_id": 11, "skill_name": "Balance", "category": "kihon", + "category_id": 1, + "category_name": "kihon", + "main_category_id": 100, + "main_category_name": "Technik", "intensity": "niedrig", "required_level": "basis", "target_level": "basis", @@ -57,13 +65,40 @@ def test_compute_skill_profile_aggregates_weights(): ], } profile = compute_skill_profile(occurrences, skills_map) - assert profile["scoring_version"] == "1.1" + assert profile["scoring_version"] == "1.2" assert profile["exercise_occurrence_count"] == 2 assert profile["distinct_exercise_count"] == 1 assert len(profile["skills"]) == 2 assert profile["skills"][0]["skill_id"] == 10 assert profile["total_weight"] > profile["skills"][1]["weight"] assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1 + assert len(profile["by_main_category"]) == 1 + assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10 + + +def test_universal_percent_against_corpus_max(): + occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)] + skills_map = { + 1: [ + { + "skill_id": 10, + "skill_name": "Koordination", + "category_name": "Koordination", + "category_id": 2, + "main_category_id": 200, + "main_category_name": "Körper", + }, + ], + } + profile = compute_skill_profile( + occurrences, + skills_map, + reference_max_by_skill={10: 100.0}, + ) + assert profile["has_reference_scale"] is True + assert profile["skills"][0]["universal_percent"] == 50.0 + top = profile["by_main_category"][0]["categories"][0]["top_skill"] + assert top["universal_percent"] == 50.0 def test_match_score_for_skill_ids(): @@ -76,5 +111,7 @@ def test_match_score_for_skill_ids(): } m = match_score_for_skill_ids(profile, [1]) assert m["match_weight"] == 40.0 + assert m["match_score"] == 40.0 assert m["match_percent"] == 40.0 + assert m["artifact_focus_percent"] == 40.0 assert m["matched_skill_ids"] == [1] diff --git a/frontend/src/app.css b/frontend/src/app.css index 9e693c5..f684c21 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2591,6 +2591,46 @@ html.modal-scroll-locked .app-main { color: var(--text3); margin-top: 3px; } +.skill-profile__by-category { + display: flex; + flex-direction: column; + gap: 14px; +} +.skill-profile__main-cat { + padding: 10px 12px; + border-radius: 10px; + background: var(--surface2); + border: 1px solid var(--border); +} +.skill-profile__main-cat-title { + margin: 0 0 8px; + font-size: 0.82rem; + font-weight: 700; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.skill-profile__cat-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.skill-profile__cat-item { + display: flex; + flex-direction: column; + gap: 4px; +} +.skill-profile__cat-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text3); +} +.skill-profile__cat-row { + list-style: none; +} .skill-profile__categories { margin-top: 0.85rem; } @@ -2744,6 +2784,25 @@ html.modal-scroll-locked .app-main { font-size: 0.85rem; color: var(--text2); } +.skill-discovery__result-cats { + list-style: none; + margin: 0 0 8px; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + color: var(--text2); +} +.skill-discovery__result-cats li { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.skill-discovery__result-cat-name { + font-weight: 600; + color: var(--text3); +} .skill-discovery__result-link { text-decoration: none; } diff --git a/frontend/src/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx index dd09642..203fdc3 100644 --- a/frontend/src/components/skills/SkillDiscoveryPanel.jsx +++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx @@ -8,6 +8,12 @@ const ARTIFACT_LABELS = { progression_graph: 'Regressionspfad', } +function formatScore(value) { + const n = Number(value) + if (!Number.isFinite(n)) return '0' + return n % 1 === 0 ? String(n) : n.toFixed(1) +} + /** * Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3). */ @@ -60,8 +66,8 @@ export default function SkillDiscoveryPanel({ skills = [] }) {

    Planungs-Vorschläge

    Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst — Shinkan schlägt passende - Rahmenprogramme, Trainingsmodule und Regressionspfade aus der Bibliothek vor (gewichtet nach - Übungs-Verknüpfungen). + Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem + Trainingsgewicht (nicht nach Anteil innerhalb des Plans).

    @@ -122,35 +128,51 @@ export default function SkillDiscoveryPanel({ skills = [] }) { {result?.suggestions?.length > 0 ? (
      - {result.suggestions.map((item) => ( -
    • -
      - - {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type} - - - Passung {item.match?.match_percent ?? 0}% - -
      - - {item.artifact_title || `#${item.artifact_id}`} - - {item.match?.matched_skills?.length > 0 ? ( -

      - {item.match.matched_skills.map((m) => m.skill_name).join(' · ')} -

      - ) : null} - {item.path ? ( - - Öffnen - - ) : item.artifact_type === 'progression_graph' ? ( -

      - Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten. -

      - ) : null} -
    • - ))} + {result.suggestions.map((item) => { + const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0 + const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent + const topByCat = item.skill_profile_summary?.top_by_category || [] + return ( +
    • +
      + + {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type} + + + Gewicht {formatScore(matchScore)} + +
      + + {item.artifact_title || `#${item.artifact_id}`} + + {item.match?.matched_skills?.length > 0 ? ( +

      + {item.match.matched_skills.map((m) => m.skill_name).join(' · ')} + {focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null} +

      + ) : null} + {topByCat.length > 0 ? ( +
        + {topByCat.slice(0, 4).map((row) => ( +
      • + {row.category_name} + {row.skill_name} +
      • + ))} +
      + ) : null} + {item.path ? ( + + Öffnen + + ) : item.artifact_type === 'progression_graph' ? ( +

      + Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten. +

      + ) : null} +
    • + ) + })}
    ) : result && !loading ? (

    diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx index 1e90101..c268b06 100644 --- a/frontend/src/components/skills/SkillProfilePanel.jsx +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -1,25 +1,107 @@ import React, { useMemo, useState } from 'react' -function SkillBar({ skill, maxShare }) { - const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0 +function skillWeight(skill) { + return Number(skill?.weight ?? skill?.score ?? 0) +} + +function formatWeight(value) { + const n = Number(value) + if (!Number.isFinite(n)) return '0' + return n % 1 === 0 ? String(n) : n.toFixed(1) +} + +function barFillPercent(skill, maxWeight, hasReferenceScale) { + if (hasReferenceScale && skill?.universal_percent != null) { + return Math.min(100, Number(skill.universal_percent)) + } + const w = skillWeight(skill) + return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0 +} + +function metricLabel(skill, hasReferenceScale) { + if (hasReferenceScale && skill?.universal_percent != null) { + return `${skill.universal_percent}% Bibliothek` + } + return formatWeight(skillWeight(skill)) +} + +function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) { + if (!skill) return null + const pct = barFillPercent(skill, maxWeight, hasReferenceScale) return ( -

  • +
  • {skill.skill_name} - {skill.share_percent}% + + {metricLabel(skill, hasReferenceScale)} +
  • ) } +function CategoryGroupedProfile({ profile, ariaLabel }) { + const groups = profile?.by_main_category || [] + const hasReferenceScale = Boolean(profile?.has_reference_scale) + const maxWeight = useMemo(() => { + let max = 1 + for (const mc of groups) { + for (const cat of mc.categories || []) { + const w = skillWeight(cat.top_skill) + if (w > max) max = w + } + } + return max + }, [groups]) + + if (!groups.length) return null + + return ( +
    + {groups.map((mc) => ( +
    +

    {mc.main_category_name}

    +
      + {(mc.categories || []).map((cat) => ( +
    • + {cat.category_name} + +
    • + ))} +
    +
    + ))} +
    + ) +} + +function topCategoryBadge(profile) { + const parts = [] + for (const mc of profile?.by_main_category || []) { + for (const cat of mc.categories || []) { + const top = cat.top_skill + if (!top) continue + parts.push(`${cat.category_name}: ${top.skill_name}`) + if (parts.length >= 2) return parts.join(' · ') + } + } + return parts.join(' · ') +} + /** * Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte. */ @@ -29,17 +111,13 @@ export default function SkillProfilePanel({ loading = false, error = '', title = 'Fähigkeiten-Profil', - hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis).', + hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.', defaultExpanded = true, }) { const [expanded, setExpanded] = useState(defaultExpanded) const [slotOpenId, setSlotOpenId] = useState(null) - const skills = profile?.skills || [] - const maxShare = useMemo( - () => Math.max(...skills.map((s) => s.share_percent || 0), 1), - [skills] - ) + const badge = useMemo(() => topCategoryBadge(profile), [profile]) if (loading) { return ( @@ -61,6 +139,11 @@ export default function SkillProfilePanel({ !profile || (profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0) + const categoryCount = (profile?.by_main_category || []).reduce( + (n, mc) => n + (mc.categories?.length || 0), + 0 + ) + return (
    -
      - {skills.slice(0, 12).map((sk) => ( - - ))} -
    - - {profile.by_category?.length > 1 ? ( -
    - Nach Kategorie -
    - {profile.by_category.slice(0, 6).map((c) => ( - - {c.category} {c.share_percent}% - - ))} -
    -
    - ) : null} + )} @@ -135,7 +205,7 @@ export default function SkillProfilePanel({
      {slots.map((sl) => { const open = slotOpenId === sl.slot_id - const top = sl.profile?.skills?.[0] + const slotBadge = topCategoryBadge(sl.profile) return (
    • - {open && sl.profile?.skills?.length > 0 ? ( -
        - {sl.profile.skills.slice(0, 6).map((sk) => ( - x.share_percent || 0), - 1 - )} - /> - ))} -
      + {open && sl.profile?.by_main_category?.length > 0 ? ( + ) : null}
    • ) diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 141e5c1..99f3d8d 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() { {!isNew ? ( Date: Thu, 21 May 2026 09:05:13 +0200 Subject: [PATCH 05/10] Enhance Skill Scoring and Profile Features - Updated the skill scoring specification to include club-specific metrics and improved aggregation methods for skill profiles. - Introduced new API endpoints for batch skill profile summaries, allowing for efficient retrieval of compact skill data. - Enhanced frontend components to display skill profiles with club comparisons, improving user interaction and visibility of skill strengths. - Added filtering options for skills in the framework programs, enabling users to refine selections based on training weight relative to club maximums. - Improved CSS styles for skill profile displays, ensuring a cohesive and user-friendly interface across the application. --- .claude/docs/technical/SKILL_SCORING_SPEC.md | 4 +- backend/routers/skill_profiles.py | 200 ++++++-- backend/skill_scoring.py | 435 ++++++++++++++---- backend/tenant_context.py | 27 ++ frontend/src/api/skillProfiles.js | 14 + frontend/src/app.css | 101 ++++ .../planning/FrameworkProgramListCard.jsx | 33 +- .../planning/FrameworkProgramsFilterBlock.jsx | 64 ++- .../components/skills/SkillProfileCompact.jsx | 67 +++ .../skills/SkillProfileFullModal.jsx | 77 ++++ .../components/skills/SkillProfilePanel.jsx | 13 +- .../TrainingFrameworkProgramsListPage.jsx | 58 ++- .../src/pages/TrainingModulesListPage.jsx | 62 ++- .../src/utils/frameworkProgramListHelpers.js | 55 ++- frontend/src/utils/skillProfileListHelpers.js | 62 +++ 15 files changed, 1131 insertions(+), 141 deletions(-) create mode 100644 frontend/src/components/skills/SkillProfileCompact.jsx create mode 100644 frontend/src/components/skills/SkillProfileFullModal.jsx create mode 100644 frontend/src/utils/skillProfileListHelpers.js diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md index eda832e..17ef945 100644 --- a/.claude/docs/technical/SKILL_SCORING_SPEC.md +++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md @@ -58,7 +58,9 @@ Aggregation: - Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**) - `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär) - `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht) -- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ) +- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade +- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht) +- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil. diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py index 6253cab..b640213 100644 --- a/backend/routers/skill_profiles.py +++ b/backend/routers/skill_profiles.py @@ -17,10 +17,13 @@ from skill_scoring import ( collect_module_exercise_occurrences, collect_progression_graph_exercise_occurrences, collect_unit_exercise_occurrences, + compute_club_corpus_reference, compute_corpus_skill_max_weights, compute_skill_profile, match_score_for_skill_ids, profile_for_occurrences, + reference_scale_meta, + top_categories_summary, ) from routers.training_framework_programs import _framework_access @@ -70,12 +73,9 @@ def framework_program_skill_profile( ) slots_raw = [r2d(r) for r in cur.fetchall()] - ref_max = compute_corpus_skill_max_weights( - cur, - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) + corpus = _load_club_corpus(cur, tenant) + ref_max = corpus["max_by_skill"] + ref_by_skill = corpus["ref_by_skill"] all_occurrences: List[ExerciseOccurrence] = [] slot_profiles: List[Dict[str, Any]] = [] @@ -118,14 +118,22 @@ def framework_program_skill_profile( if all_occurrences else _empty_profile() ) + _enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id) + for slot in slot_profiles: + _enrich_profile_club_best( + slot.get("profile") or {}, + ref_by_skill, + "framework_program", + framework_id, + ) return { "artifact_type": "framework_program", "artifact_id": framework_id, "artifact_title": row.get("title"), - "reference_scale": { - "skills_in_corpus": len(ref_max), - "description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek", + "reference_scale": reference_scale_meta(corpus), + "club_best_by_skill": { + str(k): v for k, v in ref_by_skill.items() }, "overall": overall, "slots": slot_profiles, @@ -142,24 +150,23 @@ def training_module_skill_profile( with get_db() as conn: cur = get_cursor(conn) row = _module_access(cur, module_id, profile_id, role) - ref_max = compute_corpus_skill_max_weights( - cur, - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) + corpus = _load_club_corpus(cur, tenant) + ref_max = corpus["max_by_skill"] + ref_by_skill = corpus["ref_by_skill"] occurrences = collect_module_exercise_occurrences(cur, module_id) overall = ( profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max) if occurrences else _empty_profile() ) + _enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id) return { "artifact_type": "training_module", "artifact_id": module_id, "artifact_title": row.get("title"), - "reference_scale": { - "skills_in_corpus": len(ref_max), + "reference_scale": reference_scale_meta(corpus), + "club_best_by_skill": { + str(k): v for k, v in ref_by_skill.items() }, "overall": overall, } @@ -175,12 +182,9 @@ def progression_graph_skill_profile( with get_db() as conn: cur = get_cursor(conn) row = _require_graph_read(cur, graph_id, profile_id, role) - ref_max = compute_corpus_skill_max_weights( - cur, - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) + corpus = _load_club_corpus(cur, tenant) + ref_max = corpus["max_by_skill"] + ref_by_skill = corpus["ref_by_skill"] occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) overall = ( profile_for_occurrences( @@ -192,17 +196,96 @@ def progression_graph_skill_profile( if occurrences else _empty_profile() ) + _enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id) return { "artifact_type": "progression_graph", "artifact_id": graph_id, "artifact_title": row.get("name"), - "reference_scale": { - "skills_in_corpus": len(ref_max), + "reference_scale": reference_scale_meta(corpus), + "club_best_by_skill": { + str(k): v for k, v in ref_by_skill.items() }, "overall": overall, } +@router.post("/skill-profiles/batch-summaries") +def batch_skill_profile_summaries( + data: dict, + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL). + Body: { framework_program_ids?: number[], training_module_ids?: number[] } + """ + fp_ids = _parse_id_list(data.get("framework_program_ids")) + mod_ids = _parse_id_list(data.get("training_module_ids")) + if not fp_ids and not mod_ids: + raise HTTPException( + status_code=400, + detail="framework_program_ids oder training_module_ids erforderlich", + ) + + profile_id = tenant.profile_id + role = tenant.global_role + summaries: Dict[str, Dict[str, Any]] = {} + + with get_db() as conn: + cur = get_cursor(conn) + corpus = compute_club_corpus_reference( + cur, + profile_id=tenant.profile_id, + effective_club_id=tenant.effective_club_id, + include_artifact_summaries=True, + ) + ref_by_skill = corpus["ref_by_skill"] + all_summaries = corpus.get("artifact_summaries") or {} + + if fp_ids: + allowed_fp = [] + for fid in fp_ids: + try: + _framework_access(cur, fid, profile_id, role) + allowed_fp.append(fid) + except HTTPException: + pass + for fid in allowed_fp: + key = f"framework_program:{fid}" + if key in all_summaries: + summaries[key] = all_summaries[key] + + if mod_ids: + allowed_mod = [] + for mid in mod_ids: + try: + _module_access(cur, mid, profile_id, role) + allowed_mod.append(mid) + except HTTPException: + pass + for mid in allowed_mod: + key = f"training_module:{mid}" + if key in all_summaries: + summaries[key] = all_summaries[key] + + skill_ids_seen: set[int] = set() + for summary in summaries.values(): + for sk in summary.get("skills") or []: + if sk.get("skill_id") is not None: + skill_ids_seen.add(int(sk["skill_id"])) + + club_best_subset = { + str(sid): ref_by_skill[sid] + for sid in skill_ids_seen + if sid in ref_by_skill + } + + return { + "reference_scale": reference_scale_meta(corpus), + "club_best_by_skill": club_best_subset, + "summaries": summaries, + } + + @router.get("/skill-discovery/suggestions") def skill_discovery_suggestions( skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"), @@ -278,7 +361,7 @@ def skill_discovery_suggestions( "match": match, "skill_profile_summary": { "total_score": prof.get("total_score"), - "top_by_category": _top_categories_summary(prof), + "top_by_category": top_categories_summary(prof), }, } ) @@ -322,7 +405,7 @@ def skill_discovery_suggestions( "match": match, "skill_profile_summary": { "total_score": prof.get("total_score"), - "top_by_category": _top_categories_summary(prof), + "top_by_category": top_categories_summary(prof), }, } ) @@ -368,7 +451,7 @@ def skill_discovery_suggestions( "match": match, "skill_profile_summary": { "total_score": prof.get("total_score"), - "top_by_category": _top_categories_summary(prof), + "top_by_category": top_categories_summary(prof), }, } ) @@ -405,5 +488,66 @@ def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dic return out +def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]: + if not raw: + return [] + if not isinstance(raw, list): + raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein") + out: List[int] = [] + for item in raw: + try: + n = int(item) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None + if n > 0 and n not in out: + out.append(n) + if len(out) >= max_count: + break + return out + + +def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]: + return compute_club_corpus_reference( + cur, + profile_id=tenant.profile_id, + effective_club_id=tenant.effective_club_id, + ) + + +def _enrich_profile_club_best( + profile: Dict[str, Any], + ref_by_skill: Dict[int, Dict[str, Any]], + artifact_type: Optional[str] = None, + artifact_id: Optional[int] = None, +) -> None: + """Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze).""" + if not profile or not ref_by_skill: + return + + def attach(sk: Optional[Dict[str, Any]]) -> None: + if not sk or sk.get("skill_id") is None: + return + sid = int(sk["skill_id"]) + ref = ref_by_skill.get(sid) + if not ref: + return + if ( + artifact_type + and artifact_id is not None + and ref.get("artifact_type") == artifact_type + and int(ref.get("artifact_id") or 0) == int(artifact_id) + ): + return + w = float(sk.get("weight") or 0) + if w < float(ref.get("weight") or 0) - 0.01: + sk["club_best"] = ref + + for sk in profile.get("skills") or []: + attach(sk) + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + attach(cat.get("top_skill")) + + def _empty_profile() -> Dict[str, Any]: return compute_skill_profile([], {}) diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 8340a21..77ef858 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -471,6 +471,337 @@ def merge_skill_weights_into_max( target[sid] = w +def merge_skill_weights_with_reference( + max_by_skill: Dict[int, float], + ref_by_skill: Dict[int, Dict[str, Any]], + profile: Dict[str, Any], + *, + artifact_type: str, + artifact_id: int, + artifact_title: Optional[str] = None, +) -> None: + """Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt.""" + for sk in profile.get("skills") or []: + sid = int(sk["skill_id"]) + w = float(sk.get("weight") or 0) + if w <= 0: + continue + if w > max_by_skill.get(sid, 0.0): + max_by_skill[sid] = w + ref_by_skill[sid] = { + "artifact_type": artifact_type, + "artifact_id": int(artifact_id), + "artifact_title": (artifact_title or "").strip() or None, + "weight": _round2(w), + } + + +def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]: + """Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery).""" + out: List[Dict[str, Any]] = [] + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if not top: + continue + out.append( + { + "main_category_name": mc.get("main_category_name"), + "category_name": cat.get("category_name"), + "skill_id": top.get("skill_id"), + "skill_name": top.get("skill_name"), + "score": top.get("score") or top.get("weight"), + "weight": top.get("weight"), + "universal_percent": top.get("universal_percent"), + } + ) + if len(out) >= limit: + return out + return out + + +def _apply_reference_to_profile( + profile: Dict[str, Any], + reference_max_by_skill: Optional[Dict[int, float]], +) -> None: + _apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill) + profile["by_main_category"] = _build_by_main_category(profile.get("skills") or []) + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if top and reference_max_by_skill: + sid = int(top["skill_id"]) + ref = float(reference_max_by_skill.get(sid) or 0) + if ref > 0: + top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0) + profile["has_reference_scale"] = bool(reference_max_by_skill) + + +def compact_profile_summary( + profile: Dict[str, Any], + ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None, + *, + skills_limit: int = 20, +) -> Dict[str, Any]: + """Leichtgewichtiges Profil für Listen — ohne Übungsdetails.""" + skills_out: List[Dict[str, Any]] = [] + for s in profile.get("skills") or []: + sid = int(s["skill_id"]) + w = float(s.get("weight") or 0) + entry: Dict[str, Any] = { + "skill_id": sid, + "skill_name": s.get("skill_name"), + "category_name": s.get("category_name") or s.get("category"), + "main_category_name": s.get("main_category_name"), + "weight": s.get("weight"), + "universal_percent": s.get("universal_percent"), + } + ref = (ref_by_skill or {}).get(sid) + if ref and w < float(ref.get("weight") or 0) - 0.01: + entry["club_best"] = ref + skills_out.append(entry) + if len(skills_out) >= skills_limit: + break + return { + "total_score": profile.get("total_score"), + "total_weight": profile.get("total_weight"), + "exercise_occurrence_count": profile.get("exercise_occurrence_count"), + "skills_count": len(profile.get("skills") or []), + "top_by_category": top_categories_summary(profile), + "skills": skills_out, + } + + +def batch_framework_occurrences_by_id( + cur, framework_ids: Sequence[int] +) -> Dict[int, List[ExerciseOccurrence]]: + ids = sorted({int(x) for x in framework_ids if x}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT s.framework_program_id, + COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label, + tusi.exercise_id, + tusi.planned_duration_min + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id + INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id + WHERE s.framework_program_id IN ({ph}) + AND tusi.item_type = 'exercise' + AND tusi.exercise_id IS NOT NULL + ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index + """, + ids, + ) + out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list) + for row in cur.fetchall(): + fid = int(row["framework_program_id"]) + out[fid].append( + ExerciseOccurrence( + exercise_id=int(row["exercise_id"]), + planned_duration_min=row.get("planned_duration_min"), + context_label=row.get("slot_label"), + ) + ) + return dict(out) + + +def batch_module_occurrences_by_id( + cur, module_ids: Sequence[int] +) -> Dict[int, List[ExerciseOccurrence]]: + ids = sorted({int(x) for x in module_ids if x}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT module_id, exercise_id, planned_duration_min + FROM training_module_items + WHERE module_id IN ({ph}) + AND item_type = 'exercise' + AND exercise_id IS NOT NULL + ORDER BY module_id, order_index + """, + ids, + ) + out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list) + for row in cur.fetchall(): + mid = int(row["module_id"]) + out[mid].append( + ExerciseOccurrence( + exercise_id=int(row["exercise_id"]), + planned_duration_min=row.get("planned_duration_min"), + ) + ) + return dict(out) + + +def batch_compute_profiles( + occ_by_artifact: Dict[int, List[ExerciseOccurrence]], + skills_map: Dict[int, List[Dict[str, Any]]], + *, + reference_max_by_skill: Optional[Dict[int, float]] = None, + default_item_minutes: int = DEFAULT_ITEM_MINUTES, +) -> Dict[int, Dict[str, Any]]: + return { + aid: compute_skill_profile( + occ, + skills_map, + default_item_minutes=default_item_minutes, + reference_max_by_skill=reference_max_by_skill, + ) + for aid, occ in occ_by_artifact.items() + } + + +def compute_club_corpus_reference( + cur, + *, + profile_id: int, + effective_club_id: Optional[int], + include_artifact_summaries: bool = False, +) -> Dict[str, Any]: + """ + Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein). + Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen). + """ + from tenant_context import club_library_visibility_sql + + max_by_skill: Dict[int, float] = {} + ref_by_skill: Dict[int, Dict[str, Any]] = {} + artifact_count = 0 + raw_profiles: Dict[str, Dict[str, Any]] = {} + + if effective_club_id is None: + return { + "club_id": None, + "max_by_skill": max_by_skill, + "ref_by_skill": ref_by_skill, + "artifact_count": 0, + "artifact_summaries": {}, + } + + def ingest(artifact_type: str, artifact_id: int, title: Optional[str], prof: Dict[str, Any]) -> None: + nonlocal artifact_count + if not prof.get("skills"): + return + artifact_count += 1 + merge_skill_weights_with_reference( + max_by_skill, + ref_by_skill, + prof, + artifact_type=artifact_type, + artifact_id=artifact_id, + artifact_title=title, + ) + if include_artifact_summaries: + raw_profiles[f"{artifact_type}:{artifact_id}"] = prof + + vis_clause, vis_params = club_library_visibility_sql( + alias="fp", + profile_id=profile_id, + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT fp.id, fp.title + FROM training_framework_programs fp + WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST + """, + vis_params, + ) + fw_rows = cur.fetchall() + if fw_rows: + fw_ids = [int(r["id"]) for r in fw_rows] + titles = {int(r["id"]): r.get("title") for r in fw_rows} + occ_map = batch_framework_occurrences_by_id(cur, fw_ids) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) + profiles = batch_compute_profiles(occ_map, skills_map) + for fid, prof in profiles.items(): + ingest("framework_program", fid, titles.get(fid), prof) + + vis_clause, vis_params = club_library_visibility_sql( + alias="m", + profile_id=profile_id, + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT m.id, m.title + FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST + """, + vis_params, + ) + mod_rows = cur.fetchall() + if mod_rows: + mod_ids = [int(r["id"]) for r in mod_rows] + titles = {int(r["id"]): r.get("title") for r in mod_rows} + occ_map = batch_module_occurrences_by_id(cur, mod_ids) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) + profiles = batch_compute_profiles(occ_map, skills_map) + for mid, prof in profiles.items(): + ingest("training_module", mid, titles.get(mid), prof) + + vis_clause, vis_params = club_library_visibility_sql( + alias="g", + profile_id=profile_id, + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT g.id, g.name + FROM exercise_progression_graphs g + WHERE ({vis_clause}) + ORDER BY g.updated_at DESC NULLS LAST + """, + vis_params, + ) + for row in cur.fetchall(): + gid = int(row["id"]) + occ = collect_progression_graph_exercise_occurrences(cur, gid) + if not occ: + continue + prof = profile_for_occurrences( + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + ) + ingest("progression_graph", gid, row.get("name"), prof) + + artifact_summaries: Dict[str, Dict[str, Any]] = {} + if include_artifact_summaries and raw_profiles: + for key, prof in raw_profiles.items(): + _apply_reference_to_profile(prof, max_by_skill) + artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill) + + return { + "club_id": effective_club_id, + "max_by_skill": max_by_skill, + "ref_by_skill": ref_by_skill, + "artifact_count": artifact_count, + "artifact_summaries": artifact_summaries, + } + + +def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]: + return { + "scope": "club", + "club_id": corpus.get("club_id"), + "skills_in_corpus": len(corpus.get("max_by_skill") or {}), + "artifacts_scanned": corpus.get("artifact_count") or 0, + "description": ( + "universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit " + "(nur visibility=club im aktiven Verein)" + ), + } + + def compute_corpus_skill_max_weights( cur, *, @@ -480,102 +811,16 @@ def compute_corpus_skill_max_weights( limit_per_type: int = 50, ) -> Dict[int, float]: """ - Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte. - Basis für universal_percent (Skala über alle Programme). + Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference). + role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte). """ - from tenant_context import library_content_visibility_sql - - max_by_skill: Dict[int, float] = {} - - def scan_frameworks(): - vis_clause, vis_params = library_content_visibility_sql( - alias="fp", - profile_id=profile_id, - role=role, - effective_club_id=effective_club_id, - ) - cur.execute( - f""" - SELECT fp.id FROM training_framework_programs fp - WHERE ({vis_clause}) - ORDER BY fp.updated_at DESC NULLS LAST - LIMIT %s - """, - (*vis_params, limit_per_type), - ) - for row in cur.fetchall(): - fid = int(row["id"]) - cur.execute( - """ - SELECT tu.id - FROM training_framework_slots s - INNER JOIN training_units tu ON tu.framework_slot_id = s.id - WHERE s.framework_program_id = %s - """, - (fid,), - ) - occ: List[ExerciseOccurrence] = [] - for u in cur.fetchall(): - occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) - if not occ: - continue - prof = profile_for_occurrences(cur, occ) - merge_skill_weights_into_max(max_by_skill, prof) - - def scan_modules(): - vis_clause, vis_params = library_content_visibility_sql( - alias="m", - profile_id=profile_id, - role=role, - effective_club_id=effective_club_id, - ) - cur.execute( - f""" - SELECT m.id FROM training_modules m - WHERE ({vis_clause}) - ORDER BY m.updated_at DESC NULLS LAST - LIMIT %s - """, - (*vis_params, limit_per_type), - ) - for row in cur.fetchall(): - mid = int(row["id"]) - occ = collect_module_exercise_occurrences(cur, mid) - if not occ: - continue - prof = profile_for_occurrences(cur, occ) - merge_skill_weights_into_max(max_by_skill, prof) - - def scan_graphs(): - vis_clause, vis_params = library_content_visibility_sql( - alias="g", - profile_id=profile_id, - role=role, - effective_club_id=effective_club_id, - ) - cur.execute( - f""" - SELECT g.id FROM exercise_progression_graphs g - WHERE ({vis_clause}) - ORDER BY g.updated_at DESC NULLS LAST - LIMIT %s - """, - (*vis_params, limit_per_type), - ) - for row in cur.fetchall(): - gid = int(row["id"]) - occ = collect_progression_graph_exercise_occurrences(cur, gid) - if not occ: - continue - prof = profile_for_occurrences( - cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES - ) - merge_skill_weights_into_max(max_by_skill, prof) - - scan_frameworks() - scan_modules() - scan_graphs() - return max_by_skill + del role, limit_per_type + corpus = compute_club_corpus_reference( + cur, + profile_id=profile_id, + effective_club_id=effective_club_id, + ) + return corpus["max_by_skill"] def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: diff --git a/backend/tenant_context.py b/backend/tenant_context.py index e1c9309..fe76393 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -107,6 +107,33 @@ def library_content_visibility_sql( return "(" + " OR ".join(parts) + ")", params +def club_library_visibility_sql( + *, + alias: str, + profile_id: int, + effective_club_id: Optional[int], +) -> tuple[str, List[Any]]: + """ + Nur Inhalte des aktiven Vereins (visibility=club, club_id=active). + Für Skill-Vergleiche im Vereinskontext — ohne official/private anderer Mandanten. + """ + if effective_club_id is None: + return "(1=0)", [] + return ( + f"""( + {alias}.visibility = 'club' + AND {alias}.club_id = %s + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s + AND cm.club_id = {alias}.club_id + AND cm.status = 'active' + ) + )""", + [effective_club_id, profile_id], + ) + + @dataclass class TenantContext: profile_id: int diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js index 7c20c3c..db67033 100644 --- a/frontend/src/api/skillProfiles.js +++ b/frontend/src/api/skillProfiles.js @@ -24,3 +24,17 @@ export async function getSkillDiscoverySuggestions(skillIds, opts = {}) { if (opts.limit != null) params.set('limit', String(opts.limit)) return request(`/api/skill-discovery/suggestions?${params.toString()}`) } + +/** + * Batch-Summaries für Listen (ein Vereins-Corpus-Scan). + * @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload + */ +export async function batchSkillProfileSummaries(payload = {}) { + return request('/api/skill-profiles/batch-summaries', { + method: 'POST', + body: JSON.stringify({ + framework_program_ids: payload.frameworkProgramIds || [], + training_module_ids: payload.trainingModuleIds || [], + }), + }) +} diff --git a/frontend/src/app.css b/frontend/src/app.css index f684c21..f442a8b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2690,6 +2690,107 @@ html.modal-scroll-locked .app-main { font-weight: 500; } +.skill-profile-compact { + margin-top: 4px; +} +.skill-profile-compact__label { + display: block; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin-bottom: 6px; +} +.skill-profile-compact__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.skill-profile-compact__item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); +} +.skill-profile-compact__name { + font-weight: 600; + font-size: 0.86rem; + color: var(--text1); +} +.skill-profile-compact__metric { + font-size: 0.78rem; + font-weight: 700; + color: var(--accent-dark); +} +.skill-profile-compact__best { + margin: 0; + font-size: 0.72rem; + line-height: 1.35; +} +.fw-prog-card__section-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} +.fw-prog-card__section--skills { + margin-top: 0.5rem; +} +.fw-import-skill-grid { + max-height: 180px; + overflow-y: auto; +} +.skill-profile-modal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px 12px; + overflow-y: auto; +} +.skill-profile-modal__backdrop { + position: fixed; + inset: 0; + border: none; + background: rgba(0, 0, 0, 0.45); + cursor: pointer; +} +.skill-profile-modal__panel { + position: relative; + z-index: 1; + width: min(42rem, 100%); + max-height: calc(100vh - 48px); + overflow-y: auto; + padding: 1rem 1.1rem 1.25rem; + margin-top: 2vh; +} +.skill-profile-modal__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 0.75rem; +} +.skill-profile-modal__title { + margin: 0; + font-size: 1.05rem; +} +.skill-profile-modal__scale { + margin: 0.5rem 0 0; +} + .skill-discovery { padding: 1.15rem 1.25rem; max-width: 52rem; diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx index 2ccd4ed..1d390d3 100644 --- a/frontend/src/components/planning/FrameworkProgramListCard.jsx +++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx @@ -1,5 +1,6 @@ import React from 'react' import NavStateLink from '../NavStateLink' +import SkillProfileCompact from '../skills/SkillProfileCompact' import { frameworkSessionDurationLabel, splitFrameworkCommaAgg, @@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) { /** * Einzelkarte für die Rahmenprogramm-Bibliothek. */ -export default function FrameworkProgramListCard({ row, returnContext, onDelete }) { +export default function FrameworkProgramListCard({ + row, + returnContext, + onDelete, + skillSummary = null, + skillSummaryLoading = false, + skillFilterIds = [], + skillDisplayLimit = 6, + onShowSkillProfile, +}) { const title = (row.title || '').trim() || `Rahmen #${row.id}` const description = (row.description || '').trim() const durationLabel = frameworkSessionDurationLabel(row) @@ -112,6 +122,27 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete ) : null} +
      +
      +

      Fähigkeiten

      + {onShowSkillProfile ? ( + + ) : null} +
      + +
      +
      filterFrameworkPrograms(programs, filters).length, - [programs, filters] + () => filterFrameworkPrograms(programs, filters, skillSummaries).length, + [programs, filters, skillSummaries] ) const totalCount = (programs || []).length @@ -43,8 +45,9 @@ export default function FrameworkProgramsFilterBlock({ focusAreas: catalogFocusAreas, trainingTypes: catalogTrainingTypes, targetGroups: catalogTargetGroups, + skills: catalogSkills, }), - [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups] + [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills] ) const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch })) @@ -285,6 +288,61 @@ export default function FrameworkProgramsFilterBlock({
    ) : null} + + {catalogSkills.length > 0 ? ( +
    + Fähigkeiten (Vereinsvergleich) +

    + Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit — ohne + Punktewerte eingeben. +

    +
    + {catalogSkills.map((sk) => ( + + ))} +
    + {(filters.skillIds || []).length > 0 ? ( +
    +
    + + +
    +
    + + +
    +
    + ) : null} +
    + ) : null}
    {showHint ? (

    diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx new file mode 100644 index 0000000..7ecc740 --- /dev/null +++ b/frontend/src/components/skills/SkillProfileCompact.jsx @@ -0,0 +1,67 @@ +import React from 'react' +import { + artifactPath, + artifactTypeLabel, + compactSkillDisplayRows, + formatClubPercent, +} from '../../utils/skillProfileListHelpers' + +function formatWeight(value) { + const n = Number(value) + if (!Number.isFinite(n)) return '0' + return n % 1 === 0 ? String(n) : n.toFixed(1) +} + +/** + * Kompakte Fähigkeiten-Zeile für Listen/Kacheln. + */ +export default function SkillProfileCompact({ + summary, + skillIds = [], + loading = false, + emptyText = 'Noch keine Übungen mit Fähigkeiten', + displayLimit = 6, + showClubBest = true, +}) { + if (loading) { + return

    Fähigkeiten werden berechnet…

    + } + + const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit }) + + if (!rows.length) { + return summary ? ( +

    {emptyText}

    + ) : null + } + + return ( +
    + Fähigkeiten +
      + {rows.map((sk) => { + const best = sk.club_best + const path = showClubBest && best ? artifactPath(best) : null + return ( +
    • + + {sk.skill_name} + + + {formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein + + {showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? ( + + Vereins-Top: {artifactTypeLabel(best.artifact_type)} „{best.artifact_title || best.artifact_id}“ ( + {formatWeight(best.weight)}) + + ) : sk.universal_percent >= 100 ? ( + Stärkste Vereins-Nutzung dieser Fähigkeit + ) : null} +
    • + ) + })} +
    +
    + ) +} diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx new file mode 100644 index 0000000..907098a --- /dev/null +++ b/frontend/src/components/skills/SkillProfileFullModal.jsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react' +import api from '../../utils/api' +import SkillProfilePanel from './SkillProfilePanel' + +/** + * Vollständiges Fähigkeiten-Profil in einem Modal (Listen-Kontext). + */ +export default function SkillProfileFullModal({ + open, + onClose, + artifactType = 'framework_program', + artifactId, + title = 'Fähigkeiten-Profil', +}) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [data, setData] = useState(null) + + useEffect(() => { + if (!open || !artifactId) return undefined + let cancelled = false + setLoading(true) + setError('') + setData(null) + const load = + artifactType === 'training_module' + ? api.getTrainingModuleSkillProfile(artifactId) + : artifactType === 'progression_graph' + ? api.getProgressionGraphSkillProfile(artifactId) + : api.getFrameworkProgramSkillProfile(artifactId) + load + .then((res) => { + if (!cancelled) setData(res) + }) + .catch((e) => { + if (!cancelled) setError(e.message || 'Profil konnte nicht geladen werden') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, artifactId, artifactType]) + + if (!open) return null + + return ( +
    + + + + {data?.reference_scale?.scope === 'club' && !loading ? ( +

    + Vergleichsbasis: {data.reference_scale.artifacts_scanned ?? 0} Vereins-Artefakte ( + {data.reference_scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz). +

    + ) : null} +
    + + ) +} diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx index c268b06..54c7708 100644 --- a/frontend/src/components/skills/SkillProfilePanel.jsx +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -20,7 +20,7 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) { function metricLabel(skill, hasReferenceScale) { if (hasReferenceScale && skill?.universal_percent != null) { - return `${skill.universal_percent}% Bibliothek` + return `${skill.universal_percent}% vom Vereins-Maximum` } return formatWeight(skillWeight(skill)) } @@ -43,7 +43,14 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) { {hasReferenceScale ? ( - Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme + Trainingsgewicht {formatWeight(skillWeight(skill))} + {skill.club_best ? ( + <> + {' '} + · Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} ( + {formatWeight(skill.club_best.weight)}) + + ) : null} ) : null} @@ -111,7 +118,7 @@ export default function SkillProfilePanel({ loading = false, error = '', title = 'Fähigkeiten-Profil', - hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.', + hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.', defaultExpanded = true, }) { const [expanded, setExpanded] = useState(defaultExpanded) diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index d504c20..048e2a6 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -3,6 +3,7 @@ import api from '../utils/api' import NavStateLink from '../components/NavStateLink' import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock' import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard' +import SkillProfileFullModal from '../components/skills/SkillProfileFullModal' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext' @@ -22,12 +23,16 @@ export default function TrainingFrameworkProgramsListPage() { const [catalogFocusAreas, setCatalogFocusAreas] = useState([]) const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([]) const [catalogTargetGroups, setCatalogTargetGroups] = useState([]) + const [catalogSkills, setCatalogSkills] = useState([]) const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) const [filterPanelOpen, setFilterPanelOpen] = useState(true) + const [skillSummaries, setSkillSummaries] = useState({}) + const [summariesLoading, setSummariesLoading] = useState(false) + const [profileModal, setProfileModal] = useState(null) const filteredRows = useMemo( - () => filterFrameworkPrograms(rows, filters), - [rows, filters] + () => filterFrameworkPrograms(rows, filters, skillSummaries), + [rows, filters, skillSummaries] ) const filterActive = hasActiveFrameworkImportFilters(filters) @@ -35,22 +40,25 @@ export default function TrainingFrameworkProgramsListPage() { setLoading(true) setError('') try { - const [list, fa, tt, tg] = await Promise.all([ + const [list, fa, tt, tg, skills] = await Promise.all([ api.listTrainingFrameworkPrograms(), api.listFocusAreas({ status: 'active' }), api.listTrainingTypes({ status: 'active' }), api.listTargetGroups({ status: 'active' }), + api.listSkills({ status: 'active' }), ]) setRows(Array.isArray(list) ? list : []) setCatalogFocusAreas(Array.isArray(fa) ? fa : []) setCatalogTrainingTypes(Array.isArray(tt) ? tt : []) setCatalogTargetGroups(Array.isArray(tg) ? tg : []) + setCatalogSkills(Array.isArray(skills) ? skills : []) } catch (e) { setError(e.message || 'Laden fehlgeschlagen') setRows([]) setCatalogFocusAreas([]) setCatalogTrainingTypes([]) setCatalogTargetGroups([]) + setCatalogSkills([]) } finally { setLoading(false) } @@ -60,6 +68,29 @@ export default function TrainingFrameworkProgramsListPage() { load() }, [load, tenantClubDepKey]) + useEffect(() => { + if (!rows.length) { + setSkillSummaries({}) + return undefined + } + let cancelled = false + setSummariesLoading(true) + api + .batchSkillProfileSummaries({ frameworkProgramIds: rows.map((r) => r.id) }) + .then((data) => { + if (!cancelled) setSkillSummaries(data?.summaries || {}) + }) + .catch(() => { + if (!cancelled) setSkillSummaries({}) + }) + .finally(() => { + if (!cancelled) setSummariesLoading(false) + }) + return () => { + cancelled = true + } + }, [rows, tenantClubDepKey]) + async function handleDelete(id, title) { if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return try { @@ -136,6 +167,8 @@ export default function TrainingFrameworkProgramsListPage() { catalogFocusAreas={catalogFocusAreas} catalogTrainingTypes={catalogTrainingTypes} catalogTargetGroups={catalogTargetGroups} + catalogSkills={catalogSkills} + skillSummaries={skillSummaries} durationRadioName="fw-list-duration-mode" className="fw-prog-filter-block--list" /> @@ -157,6 +190,17 @@ export default function TrainingFrameworkProgramsListPage() { row={r} returnContext={frameworkListReturn} onDelete={handleDelete} + skillSummary={skillSummaries[`framework_program:${r.id}`]} + skillSummaryLoading={summariesLoading} + skillFilterIds={filters.skillIds || []} + skillDisplayLimit={filters.skillDisplayLimit || 10} + onShowSkillProfile={(row) => + setProfileModal({ + artifactType: 'framework_program', + artifactId: row.id, + title: (row.title || '').trim() || `Rahmen #${row.id}`, + }) + } /> ))} @@ -164,6 +208,14 @@ export default function TrainingFrameworkProgramsListPage() { )} )} + + setProfileModal(null)} + artifactType={profileModal?.artifactType} + artifactId={profileModal?.artifactId} + title={profileModal?.title} + /> ) } diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index ecea0db..c8c5754 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import api from '../utils/api' import NavStateLink from '../components/NavStateLink' +import SkillProfileCompact from '../components/skills/SkillProfileCompact' +import SkillProfileFullModal from '../components/skills/SkillProfileFullModal' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext' @@ -12,6 +14,9 @@ export default function TrainingModulesListPage() { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [skillSummaries, setSkillSummaries] = useState({}) + const [summariesLoading, setSummariesLoading] = useState(false) + const [profileModal, setProfileModal] = useState(null) const load = useCallback(async () => { setLoading(true) @@ -31,6 +36,29 @@ export default function TrainingModulesListPage() { load() }, [load, tenantClubDepKey]) + useEffect(() => { + if (!rows.length) { + setSkillSummaries({}) + return undefined + } + let cancelled = false + setSummariesLoading(true) + api + .batchSkillProfileSummaries({ trainingModuleIds: rows.map((r) => r.id) }) + .then((data) => { + if (!cancelled) setSkillSummaries(data?.summaries || {}) + }) + .catch(() => { + if (!cancelled) setSkillSummaries({}) + }) + .finally(() => { + if (!cancelled) setSummariesLoading(false) + }) + return () => { + cancelled = true + } + }, [rows, tenantClubDepKey]) + async function handleDelete(id, title) { if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return try { @@ -58,8 +86,7 @@ export default function TrainingModulesListPage() { Trainingsmodule

    - Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als - lokale Kopie (mit Herkunftsmarkierung). + Wiederverwendbare Übungsfolgen für die Trainingsplanung. Fähigkeiten werden im Vereinskontext verglichen.

    -

    - Sichtbarkeit: {r.visibility || '—'} -

    +
    + +
    + )} + + setProfileModal(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) +} From 9a0cf7f823fab895010db15616b78ea11f492898 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 09:33:20 +0200 Subject: [PATCH 06/10] Enhance Skill Scoring and Profile Functionality - Introduced a new function to calculate club-specific skill percentages, ensuring values are capped at 100%. - Updated skill profile calculations to include indicators for the best club performance per skill. - Enhanced frontend components to display club best indicators and improved layout for skill profiles. - Refactored CSS styles for skill profile components, ensuring a more cohesive and user-friendly interface. - Updated tests to validate new functionality and ensure accurate representation of skill metrics. --- backend/skill_scoring.py | 39 ++- backend/tests/test_skill_scoring.py | 12 +- frontend/src/app.css | 73 ++++-- .../planning/FrameworkProgramListCard.jsx | 4 +- .../components/skills/SkillProfileCompact.jsx | 86 +++---- .../skills/SkillProfileFullModal.jsx | 2 + .../components/skills/SkillProfilePanel.jsx | 242 +++++++++++------- .../src/pages/TrainingModulesListPage.jsx | 4 +- frontend/src/utils/skillProfileListHelpers.js | 39 ++- 9 files changed, 312 insertions(+), 189 deletions(-) diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 77ef858..9efb3e2 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -179,22 +179,44 @@ def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, return result +def _club_universal_percent( + weight: float, + corpus_ref: float, +) -> tuple[Optional[float], bool]: + """ + Anteil am Vereins-Maximum (max. 100 %). + effective_ref = max(Korpus-Max, eigenes Gewicht) — verhindert Werte >100 %, + wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official). + """ + w = float(weight or 0) + ref = float(corpus_ref or 0) + if w <= 0: + return None, False + effective_ref = max(ref, w) + pct = min(100.0, w / effective_ref * 100.0) + is_best = ref <= 0 or w >= ref - 0.01 + return _round2(pct), is_best + + def _apply_reference_universal_percent( skills_out: List[Dict[str, Any]], reference_max_by_skill: Optional[Dict[int, float]] = None, ) -> None: """ - Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte). + Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %). """ if not reference_max_by_skill: for sk in skills_out: sk["universal_percent"] = None + sk["is_club_best_for_skill"] = False return for sk in skills_out: sid = int(sk["skill_id"]) ref = float(reference_max_by_skill.get(sid) or 0) w = float(sk.get("weight") or 0) - sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None + pct, is_best = _club_universal_percent(w, ref) + sk["universal_percent"] = pct + sk["is_club_best_for_skill"] = is_best def compute_skill_profile( @@ -316,8 +338,9 @@ def compute_skill_profile( if top and reference_max_by_skill: sid = int(top["skill_id"]) ref = float(reference_max_by_skill.get(sid) or 0) - if ref > 0: - top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0) + pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref) + top["universal_percent"] = pct + top["is_club_best_for_skill"] = is_best unique_exercises = len(exercise_meta) return { @@ -513,6 +536,7 @@ def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict "score": top.get("score") or top.get("weight"), "weight": top.get("weight"), "universal_percent": top.get("universal_percent"), + "is_club_best_for_skill": top.get("is_club_best_for_skill"), } ) if len(out) >= limit: @@ -532,8 +556,9 @@ def _apply_reference_to_profile( if top and reference_max_by_skill: sid = int(top["skill_id"]) ref = float(reference_max_by_skill.get(sid) or 0) - if ref > 0: - top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0) + pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref) + top["universal_percent"] = pct + top["is_club_best_for_skill"] = is_best profile["has_reference_scale"] = bool(reference_max_by_skill) @@ -559,6 +584,8 @@ def compact_profile_summary( ref = (ref_by_skill or {}).get(sid) if ref and w < float(ref.get("weight") or 0) - 0.01: entry["club_best"] = ref + if s.get("is_club_best_for_skill"): + entry["is_club_best_for_skill"] = True skills_out.append(entry) if len(skills_out) >= skills_limit: break diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py index 4e0f0c7..dbe4531 100644 --- a/backend/tests/test_skill_scoring.py +++ b/backend/tests/test_skill_scoring.py @@ -3,6 +3,7 @@ from skill_scoring import ( ExerciseOccurrence, compute_skill_profile, match_score_for_skill_ids, + _club_universal_percent, _level_range_multiplier, _skill_link_multiplier, ) @@ -97,8 +98,15 @@ def test_universal_percent_against_corpus_max(): ) assert profile["has_reference_scale"] is True assert profile["skills"][0]["universal_percent"] == 50.0 - top = profile["by_main_category"][0]["categories"][0]["top_skill"] - assert top["universal_percent"] == 50.0 + assert profile["skills"][0]["is_club_best_for_skill"] is False + + +def test_club_universal_percent_capped_at_100(): + pct, is_best = _club_universal_percent(150.0, 100.0) + assert pct == 100.0 + assert is_best is True + pct2, _ = _club_universal_percent(72.6, 100.0) + assert pct2 == 72.6 def test_match_score_for_skill_ids(): diff --git a/frontend/src/app.css b/frontend/src/app.css index f442a8b..1c90601 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2693,46 +2693,73 @@ html.modal-scroll-locked .app-main { .skill-profile-compact { margin-top: 4px; } -.skill-profile-compact__label { - display: block; - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text3); - margin-bottom: 6px; -} -.skill-profile-compact__list { +.skill-kpi-grid { list-style: none; margin: 0; padding: 0; display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 6px; } -.skill-profile-compact__item { +.skill-kpi-grid--loading, +.skill-kpi-grid--empty { + margin: 0; +} +.skill-kpi-tile { display: flex; flex-direction: column; - gap: 2px; - padding: 6px 8px; + gap: 1px; + min-width: 5.5rem; + max-width: 8.5rem; + padding: 5px 8px; border-radius: 8px; - background: var(--surface2); border: 1px solid var(--border); + background: var(--surface2); + flex: 0 1 auto; } -.skill-profile-compact__name { +.skill-kpi-tile--highlight { + border-color: var(--accent); + background: var(--accent-light); +} +.skill-kpi-tile--best { + box-shadow: inset 0 0 0 1px var(--accent-dark); +} +.skill-kpi-tile__cat { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.skill-kpi-tile__name { + font-size: 0.76rem; font-weight: 600; - font-size: 0.86rem; color: var(--text1); + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.skill-profile-compact__metric { - font-size: 0.78rem; +.skill-kpi-tile__pct { + font-size: 0.72rem; font-weight: 700; color: var(--accent-dark); + margin-top: 2px; } -.skill-profile-compact__best { - margin: 0; - font-size: 0.72rem; - line-height: 1.35; +.skill-profile__name-cat { + font-weight: 500; + color: var(--text3); +} +.skill-profile--embedded .skill-profile__body { + padding: 0; + border-top: none; +} +.skill-profile--embedded .skill-profile__hint { + margin-top: 0; } .fw-prog-card__section-head { display: flex; diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx index 1d390d3..7743774 100644 --- a/frontend/src/components/planning/FrameworkProgramListCard.jsx +++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx @@ -131,15 +131,15 @@ export default function FrameworkProgramListCard({ className="btn btn-secondary btn-small fw-prog-card__skills-btn" onClick={() => onShowSkillProfile(row)} > - Vollständiges Profil + Alle anzeigen ) : null}
    diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx index 7ecc740..0aaa7e2 100644 --- a/frontend/src/components/skills/SkillProfileCompact.jsx +++ b/frontend/src/components/skills/SkillProfileCompact.jsx @@ -1,67 +1,55 @@ import React from 'react' -import { - artifactPath, - artifactTypeLabel, - compactSkillDisplayRows, - formatClubPercent, -} from '../../utils/skillProfileListHelpers' - -function formatWeight(value) { - const n = Number(value) - if (!Number.isFinite(n)) return '0' - return n % 1 === 0 ? String(n) : n.toFixed(1) -} +import { formatClubPercent, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers' /** - * Kompakte Fähigkeiten-Zeile für Listen/Kacheln. + * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten). */ export default function SkillProfileCompact({ summary, - skillIds = [], loading = false, - emptyText = 'Noch keine Übungen mit Fähigkeiten', - displayLimit = 6, - showClubBest = true, + emptyText = 'Keine Fähigkeiten', + displayLimit = 8, + highlightSkillIds = [], }) { if (loading) { - return

    Fähigkeiten werden berechnet…

    + return ( +
    + +
    + ) } - const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit }) + const rows = kpiRowsFromSummary(summary, { limit: displayLimit }) + const highlight = new Set((highlightSkillIds || []).map(String)) if (!rows.length) { - return summary ? ( -

    {emptyText}

    - ) : null + return summary ?

    {emptyText}

    : null } return ( -
    - Fähigkeiten -
      - {rows.map((sk) => { - const best = sk.club_best - const path = showClubBest && best ? artifactPath(best) : null - return ( -
    • - - {sk.skill_name} - - - {formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein - - {showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? ( - - Vereins-Top: {artifactTypeLabel(best.artifact_type)} „{best.artifact_title || best.artifact_id}“ ( - {formatWeight(best.weight)}) - - ) : sk.universal_percent >= 100 ? ( - Stärkste Vereins-Nutzung dieser Fähigkeit - ) : null} -
    • - ) - })} -
    -
    +
      + {rows.map((row) => { + const highlighted = highlight.has(String(row.skill_id)) + const isBest = row.is_club_best_for_skill || row.universal_percent >= 100 + return ( +
    • + {row.category_name} + {row.skill_name} + + {isBest ? '★ ' : ''} + {formatClubPercent(row.universal_percent)} + +
    • + ) + })} +
    ) } diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx index 907098a..cf1d61a 100644 --- a/frontend/src/components/skills/SkillProfileFullModal.jsx +++ b/frontend/src/components/skills/SkillProfileFullModal.jsx @@ -63,6 +63,8 @@ export default function SkillProfileFullModal({ loading={loading} error={error} title="Vollständiges Profil" + displayMode="full" + embedded defaultExpanded /> {data?.reference_scale?.scope === 'club' && !loading ? ( diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx index 54c7708..cf87dea 100644 --- a/frontend/src/components/skills/SkillProfilePanel.jsx +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -10,6 +10,12 @@ function formatWeight(value) { return n % 1 === 0 ? String(n) : n.toFixed(1) } +function formatClubPercent(value) { + if (value == null || !Number.isFinite(Number(value))) return '—' + const n = Math.min(100, Number(value)) + return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%` +} + function barFillPercent(skill, maxWeight, hasReferenceScale) { if (hasReferenceScale && skill?.universal_percent != null) { return Math.min(100, Number(skill.universal_percent)) @@ -20,18 +26,22 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) { function metricLabel(skill, hasReferenceScale) { if (hasReferenceScale && skill?.universal_percent != null) { - return `${skill.universal_percent}% vom Vereins-Maximum` + const best = skill.is_club_best_for_skill ? ' ★' : '' + return `${formatClubPercent(skill.universal_percent)} Verein${best}` } return formatWeight(skillWeight(skill)) } -function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) { +function SkillRow({ skill, maxWeight, hasReferenceScale }) { if (!skill) return null const pct = barFillPercent(skill, maxWeight, hasReferenceScale) return ( -
  • +
  • + {skill.category_name ? ( + {skill.category_name} · + ) : null} {skill.skill_name} @@ -43,13 +53,15 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
    {hasReferenceScale ? ( - Trainingsgewicht {formatWeight(skillWeight(skill))} + Gewicht {formatWeight(skillWeight(skill))} {skill.club_best ? ( <> {' '} · Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} ( {formatWeight(skill.club_best.weight)}) + ) : skill.is_club_best_for_skill ? ( + ' · Stärkste Vereins-Nutzung' ) : null} ) : null} @@ -57,6 +69,11 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) { ) } +function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) { + if (!skill) return null + return +} + function CategoryGroupedProfile({ profile, ariaLabel }) { const groups = profile?.by_main_category || [] const hasReferenceScale = Boolean(profile?.has_reference_scale) @@ -82,11 +99,13 @@ function CategoryGroupedProfile({ profile, ariaLabel }) { {(mc.categories || []).map((cat) => (
  • {cat.category_name} - +
    + +
  • ))} @@ -96,6 +115,30 @@ function CategoryGroupedProfile({ profile, ariaLabel }) { ) } +function FullSkillsProfile({ profile, ariaLabel }) { + const skills = profile?.skills || [] + const hasReferenceScale = Boolean(profile?.has_reference_scale) + const maxWeight = useMemo( + () => Math.max(...skills.map((s) => skillWeight(s)), 1), + [skills] + ) + + if (!skills.length) return null + + return ( +
      + {skills.map((sk) => ( + + ))} +
    + ) +} + function topCategoryBadge(profile) { const parts = [] for (const mc of profile?.by_main_category || []) { @@ -111,6 +154,7 @@ function topCategoryBadge(profile) { /** * Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte. + * displayMode: 'summary' = Top je Kategorie (Editor), 'full' = alle Fähigkeiten (Modal). */ export default function SkillProfilePanel({ profile, @@ -118,17 +162,25 @@ export default function SkillProfilePanel({ loading = false, error = '', title = 'Fähigkeiten-Profil', - hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.', + hint = '', defaultExpanded = true, + displayMode = 'summary', + embedded = false, }) { const [expanded, setExpanded] = useState(defaultExpanded) const [slotOpenId, setSlotOpenId] = useState(null) + const defaultHint = + displayMode === 'full' + ? 'Alle verknüpften Fähigkeiten nach Trainingsgewicht. Vergleich zum Vereins-Maximum je Fähigkeit (max. 100 %).' + : 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum.' + + const hintText = hint || defaultHint const badge = useMemo(() => topCategoryBadge(profile), [profile]) if (loading) { return ( -
    +

    Fähigkeiten-Profil wird berechnet…

    ) @@ -136,7 +188,7 @@ export default function SkillProfilePanel({ if (error) { return ( -
    +

    {error}

    ) @@ -151,6 +203,92 @@ export default function SkillProfilePanel({ 0 ) + const body = ( +
    +

    {hintText}

    + + {noData ? ( +

    + Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe + Fähigkeiten in der Übungsbearbeitung. +

    + ) : profile.exercises_with_skills_count === 0 ? ( +

    + {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im + Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt. +

    + ) : ( + <> +
    + + {profile.distinct_exercise_count} Übungen + + + {profile.skills?.length ?? 0} Fähigkeiten + + {displayMode === 'summary' ? ( + + {categoryCount} Kategorien + + ) : null} + + {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht + +
    + + {displayMode === 'full' ? ( + + ) : ( + + )} + + )} + + {displayMode === 'summary' && slots && slots.length > 0 ? ( +
    + Pro Session +
      + {slots.map((sl) => { + const open = slotOpenId === sl.slot_id + const slotBadge = topCategoryBadge(sl.profile) + return ( +
    • + + {open && sl.profile?.by_main_category?.length > 0 ? ( + + ) : null} +
    • + ) + })} +
    +
    + ) : null} +
    + ) + + if (embedded) { + return
    {body}
    + } + return (
    - - {expanded ? ( -
    -

    {hint}

    - - {noData ? ( -

    - Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe - Fähigkeiten in der Übungsbearbeitung. -

    - ) : profile.exercises_with_skills_count === 0 ? ( -

    - {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im - Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt. -

    - ) : ( - <> -
    - - {profile.distinct_exercise_count} Übungen - - - {profile.skills?.length ?? 0} Fähigkeiten - - - {categoryCount} Kategorien - - - {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht - -
    - - - - )} - - {slots && slots.length > 0 ? ( -
    - Pro Session -
      - {slots.map((sl) => { - const open = slotOpenId === sl.slot_id - const slotBadge = topCategoryBadge(sl.profile) - return ( -
    • - - {open && sl.profile?.by_main_category?.length > 0 ? ( - - ) : null} -
    • - ) - })} -
    -
    - ) : null} -
    - ) : null} + {expanded ? body : null}
    ) } diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index c8c5754..03422d4 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -141,11 +141,11 @@ export default function TrainingModulesListPage() { ({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})

    -
    +
    diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js index 63b226d..1fdf71c 100644 --- a/frontend/src/utils/skillProfileListHelpers.js +++ b/frontend/src/utils/skillProfileListHelpers.js @@ -26,10 +26,34 @@ export function maxSelectedSkillClubPercent(summary, skillIds = []) { export function formatClubPercent(value) { if (value == null || !Number.isFinite(Number(value))) return '—' - const n = Number(value) + const n = Math.min(100, Number(value)) return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%` } +/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */ +export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) { + if (!summary) return [] + let rows = (summary.top_by_category || []).map((row) => ({ + skill_id: row.skill_id, + skill_name: row.skill_name, + category_name: row.category_name, + main_category_name: row.main_category_name, + weight: row.weight ?? row.score, + universal_percent: row.universal_percent, + is_club_best_for_skill: row.is_club_best_for_skill, + })) + if (skillIds.length) { + const wanted = new Set(skillIds.map(String)) + rows = rows.filter((row) => wanted.has(String(row.skill_id))) + } + return rows.slice(0, limit) +} + +/** @deprecated Nutze kpiRowsFromSummary für Listen */ +export function compactSkillDisplayRows(summary, opts = {}) { + return kpiRowsFromSummary(summary, opts) +} + export function artifactTypeLabel(type) { if (type === 'framework_program') return 'Rahmenprogramm' if (type === 'training_module') return 'Modul' @@ -47,16 +71,3 @@ export function artifactPath(ref) { } return null } - -/** Zeilen für Kompakt-Anzeige: gewählte Fähigkeiten oder Top je Kategorie. */ -export function compactSkillDisplayRows(summary, { skillIds = [], limit = 6 } = {}) { - if (!summary) return [] - if (skillIds.length) { - return skillIds - .map((id) => skillEntryFromSummary(summary, id)) - .filter(Boolean) - .sort((a, b) => (b.universal_percent ?? 0) - (a.universal_percent ?? 0)) - .slice(0, limit) - } - return (summary.top_by_category || []).slice(0, limit) -} From 34966b9e846b16777eff79202c470e7d2e72fa13 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 09:42:13 +0200 Subject: [PATCH 07/10] Update Skill Profile Summary and Frontend Components - Modified the `compact_profile_summary` function to allow for dynamic skill and category limits, enhancing flexibility in profile data retrieval. - Updated frontend components to display skill weights and scores more effectively, improving user interaction with skill metrics. - Adjusted CSS styles for skill KPI tiles to better differentiate between score and percentage displays, ensuring a clearer visual representation. - Refactored utility functions to streamline skill summary handling, enhancing overall code maintainability and performance. --- backend/routers/skill_profiles.py | 122 ++++++++++++++++-- backend/skill_scoring.py | 10 +- frontend/src/app.css | 11 +- .../components/skills/SkillProfileCompact.jsx | 7 +- .../components/skills/SkillProfilePanel.jsx | 19 ++- .../src/utils/frameworkProgramListHelpers.js | 14 +- frontend/src/utils/skillProfileListHelpers.js | 21 ++- 7 files changed, 165 insertions(+), 39 deletions(-) diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py index b640213..3467ceb 100644 --- a/backend/routers/skill_profiles.py +++ b/backend/routers/skill_profiles.py @@ -14,12 +14,17 @@ from tenant_context import TenantContext, get_tenant_context, library_content_vi from skill_scoring import ( GRAPH_DEFAULT_ITEM_MINUTES, ExerciseOccurrence, + batch_compute_profiles, + batch_framework_occurrences_by_id, + batch_module_occurrences_by_id, collect_module_exercise_occurrences, collect_progression_graph_exercise_occurrences, collect_unit_exercise_occurrences, + compact_profile_summary, compute_club_corpus_reference, compute_corpus_skill_max_weights, compute_skill_profile, + fetch_exercise_skills_bulk, match_score_for_skill_ids, profile_for_occurrences, reference_scale_meta, @@ -239,33 +244,31 @@ def batch_skill_profile_summaries( include_artifact_summaries=True, ) ref_by_skill = corpus["ref_by_skill"] - all_summaries = corpus.get("artifact_summaries") or {} + allowed_fp: List[int] = [] if fp_ids: - allowed_fp = [] for fid in fp_ids: try: _framework_access(cur, fid, profile_id, role) allowed_fp.append(fid) except HTTPException: pass - for fid in allowed_fp: - key = f"framework_program:{fid}" - if key in all_summaries: - summaries[key] = all_summaries[key] + allowed_mod: List[int] = [] if mod_ids: - allowed_mod = [] for mid in mod_ids: try: _module_access(cur, mid, profile_id, role) allowed_mod.append(mid) except HTTPException: pass - for mid in allowed_mod: - key = f"training_module:{mid}" - if key in all_summaries: - summaries[key] = all_summaries[key] + + summaries = _merge_batch_summaries( + cur, + corpus=corpus, + allowed_fp=allowed_fp, + allowed_mod=allowed_mod, + ) skill_ids_seen: set[int] = set() for summary in summaries.values(): @@ -551,3 +554,100 @@ def _enrich_profile_club_best( def _empty_profile() -> Dict[str, Any]: return compute_skill_profile([], {}) + + +def _summarize_framework_program( + cur, + framework_id: int, + ref_max: Dict[int, float], + ref_by_skill: Dict[int, Dict[str, Any]], +) -> Dict[str, Any]: + cur.execute( + """ + SELECT tu.id + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + """, + (int(framework_id),), + ) + occ: List[ExerciseOccurrence] = [] + for u in cur.fetchall(): + occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) + prof = ( + profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max) + if occ + else _empty_profile() + ) + _enrich_profile_club_best(prof, ref_by_skill, "framework_program", int(framework_id)) + return compact_profile_summary(prof, ref_by_skill) + + +def _summarize_training_module( + cur, + module_id: int, + ref_max: Dict[int, float], + ref_by_skill: Dict[int, Dict[str, Any]], +) -> Dict[str, Any]: + occ = collect_module_exercise_occurrences(cur, int(module_id)) + prof = ( + profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max) + if occ + else _empty_profile() + ) + _enrich_profile_club_best(prof, ref_by_skill, "training_module", int(module_id)) + return compact_profile_summary(prof, ref_by_skill) + + +def _merge_batch_summaries( + cur, + *, + corpus: Dict[str, Any], + allowed_fp: List[int], + allowed_mod: List[int], +) -> Dict[str, Dict[str, Any]]: + """Summaries für angeforderte IDs — auch official/private (nicht nur Vereins-Corpus-Cache).""" + ref_max = corpus["max_by_skill"] + ref_by_skill = corpus["ref_by_skill"] + cached = corpus.get("artifact_summaries") or {} + out: Dict[str, Dict[str, Any]] = {} + + missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in cached] + if missing_fp: + occ_map = batch_framework_occurrences_by_id(cur, missing_fp) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max) + for fid in missing_fp: + key = f"framework_program:{fid}" + prof = profiles.get(fid) or _empty_profile() + _enrich_profile_club_best(prof, ref_by_skill, "framework_program", fid) + out[key] = compact_profile_summary(prof, ref_by_skill) + + missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in cached] + if missing_mod: + occ_map = batch_module_occurrences_by_id(cur, missing_mod) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max) + for mid in missing_mod: + key = f"training_module:{mid}" + prof = profiles.get(mid) or _empty_profile() + _enrich_profile_club_best(prof, ref_by_skill, "training_module", mid) + out[key] = compact_profile_summary(prof, ref_by_skill) + + for fid in allowed_fp: + key = f"framework_program:{fid}" + if key in cached: + out[key] = cached[key] + elif key not in out: + out[key] = _summarize_framework_program(cur, fid, ref_max, ref_by_skill) + + for mid in allowed_mod: + key = f"training_module:{mid}" + if key in cached: + out[key] = cached[key] + elif key not in out: + out[key] = _summarize_training_module(cur, mid, ref_max, ref_by_skill) + + return out diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 9efb3e2..6bf5ce0 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -566,9 +566,10 @@ def compact_profile_summary( profile: Dict[str, Any], ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None, *, - skills_limit: int = 20, + skills_limit: int = 0, + category_limit: int = 48, ) -> Dict[str, Any]: - """Leichtgewichtiges Profil für Listen — ohne Übungsdetails.""" + """Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten.""" skills_out: List[Dict[str, Any]] = [] for s in profile.get("skills") or []: sid = int(s["skill_id"]) @@ -579,6 +580,7 @@ def compact_profile_summary( "category_name": s.get("category_name") or s.get("category"), "main_category_name": s.get("main_category_name"), "weight": s.get("weight"), + "score": s.get("score") or s.get("weight"), "universal_percent": s.get("universal_percent"), } ref = (ref_by_skill or {}).get(sid) @@ -587,14 +589,14 @@ def compact_profile_summary( if s.get("is_club_best_for_skill"): entry["is_club_best_for_skill"] = True skills_out.append(entry) - if len(skills_out) >= skills_limit: + if skills_limit > 0 and len(skills_out) >= skills_limit: break return { "total_score": profile.get("total_score"), "total_weight": profile.get("total_weight"), "exercise_occurrence_count": profile.get("exercise_occurrence_count"), "skills_count": len(profile.get("skills") or []), - "top_by_category": top_categories_summary(profile), + "top_by_category": top_categories_summary(profile, limit=category_limit), "skills": skills_out, } diff --git a/frontend/src/app.css b/frontend/src/app.css index 1c90601..343fbee 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2744,12 +2744,17 @@ html.modal-scroll-locked .app-main { -webkit-box-orient: vertical; overflow: hidden; } -.skill-kpi-tile__pct { - font-size: 0.72rem; +.skill-kpi-tile__score { + font-size: 0.78rem; font-weight: 700; - color: var(--accent-dark); + color: var(--text1); margin-top: 2px; } +.skill-kpi-tile__pct { + font-size: 0.68rem; + font-weight: 600; + color: var(--accent-dark); +} .skill-profile__name-cat { font-weight: 500; color: var(--text3); diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx index 0aaa7e2..0905752 100644 --- a/frontend/src/components/skills/SkillProfileCompact.jsx +++ b/frontend/src/components/skills/SkillProfileCompact.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { formatClubPercent, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers' +import { formatClubPercent, formatSkillWeight, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers' /** * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten). @@ -8,7 +8,7 @@ export default function SkillProfileCompact({ summary, loading = false, emptyText = 'Keine Fähigkeiten', - displayLimit = 8, + displayLimit = 24, highlightSkillIds = [], }) { if (loading) { @@ -39,10 +39,11 @@ export default function SkillProfileCompact({ (highlighted ? ' skill-kpi-tile--highlight' : '') + (isBest ? ' skill-kpi-tile--best' : '') } - title={`${row.category_name}: ${row.skill_name}`} + title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} vom Vereins-Maximum`} > {row.category_name} {row.skill_name} + {formatSkillWeight(row.weight ?? row.score)} {isBest ? '★ ' : ''} {formatClubPercent(row.universal_percent)} diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx index cf87dea..8a3b45c 100644 --- a/frontend/src/components/skills/SkillProfilePanel.jsx +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -244,7 +244,7 @@ export default function SkillProfilePanel({ )} - {displayMode === 'summary' && slots && slots.length > 0 ? ( + {slots && slots.length > 0 ? (
    Pro Session
      @@ -270,11 +270,18 @@ export default function SkillProfilePanel({ )} - {open && sl.profile?.by_main_category?.length > 0 ? ( - + {open && sl.profile?.skills?.length > 0 ? ( + displayMode === 'full' ? ( + + ) : ( + + ) ) : null} ) diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js index d029cd2..985f7e5 100644 --- a/frontend/src/utils/frameworkProgramListHelpers.js +++ b/frontend/src/utils/frameworkProgramListHelpers.js @@ -2,7 +2,7 @@ import { formatDurationDisplay, formatSessionDurationRange } from './trainingDur import { frameworkSkillSummaryKey, maxSelectedSkillClubPercent, - skillEntryFromSummary, + summaryHasSkill, } from './skillProfileListHelpers' export function frameworkSessionDurationLabel(row) { @@ -117,7 +117,7 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = { skillIds: [], skillSort: 'title', skillMinClubPercent: 0, - skillDisplayLimit: 10, + skillDisplayLimit: 24, } export function hasActiveFrameworkImportFilters(filters = {}) { @@ -241,14 +241,8 @@ export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = nul if (skillIds.length && skillSummaries) { list = list.filter((r) => { const summary = skillSummaries[frameworkSkillSummaryKey(r.id)] - if (!summary) return minClubPct === 0 - return skillIds.some((sid) => { - const sk = skillEntryFromSummary(summary, sid) - if (!sk) return false - const pct = sk.universal_percent - if (pct == null) return minClubPct === 0 - return pct >= minClubPct - }) + if (!summary) return false + return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct)) }) } diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js index 1fdf71c..dfd2e63 100644 --- a/frontend/src/utils/skillProfileListHelpers.js +++ b/frontend/src/utils/skillProfileListHelpers.js @@ -7,8 +7,19 @@ export function moduleSkillSummaryKey(id) { } export function skillEntryFromSummary(summary, skillId) { - if (!summary?.skills) return null - return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null + if (!summary) return null + const sid = String(skillId) + const fromSkills = (summary.skills || []).find((s) => String(s.skill_id) === sid) + if (fromSkills) return fromSkills + return (summary.top_by_category || []).find((s) => String(s.skill_id) === sid) || null +} + +export function summaryHasSkill(summary, skillId, minClubPct = 0) { + const sk = skillEntryFromSummary(summary, skillId) + if (!sk || !(Number(sk.weight) > 0)) return false + const pct = sk.universal_percent + if (pct == null) return minClubPct === 0 + return pct >= minClubPct } export function maxSelectedSkillClubPercent(summary, skillIds = []) { @@ -30,6 +41,12 @@ export function formatClubPercent(value) { return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%` } +export function formatSkillWeight(value) { + const n = Number(value) + if (!Number.isFinite(n)) return '—' + return n % 1 === 0 ? String(n) : n.toFixed(1) +} + /** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */ export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) { if (!summary) return [] From 2de4c0b7c9bd9315e6fc09b1263b7053d29ee517 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 10:17:22 +0200 Subject: [PATCH 08/10] Refactor Skill Scoring Functions and Enhance Corpus Handling - Introduced new helper functions for managing artifact type corpus, improving code organization and readability. - Updated the `compute_club_corpus_reference` function to utilize the new corpus handling methods, enhancing clarity and maintainability. - Refactored skill profile functions to leverage the new corpus structure, ensuring consistent data retrieval across different artifact types. - Improved the handling of visibility clauses for library content, streamlining database queries for skill profiles. - Enhanced the batch skill profile summary function to aggregate reference data by artifact type, improving performance and accuracy. --- backend/routers/skill_profiles.py | 116 ++++--- backend/skill_scoring.py | 314 ++++++++++++------ .../ExerciseProgressionGraphPanel.jsx | 1 + .../planning/FrameworkProgramListCard.jsx | 1 + .../planning/FrameworkProgramsFilterBlock.jsx | 8 +- .../components/skills/SkillProfileCompact.jsx | 13 +- .../skills/SkillProfileFullModal.jsx | 12 +- .../components/skills/SkillProfilePanel.jsx | 60 ++-- .../TrainingFrameworkProgramEditPage.jsx | 2 +- frontend/src/pages/TrainingModuleEditPage.jsx | 1 + .../src/pages/TrainingModulesListPage.jsx | 5 +- .../src/utils/frameworkProgramListHelpers.js | 2 +- frontend/src/utils/skillProfileListHelpers.js | 20 ++ 13 files changed, 375 insertions(+), 180 deletions(-) diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py index 3467ceb..890ef6a 100644 --- a/backend/routers/skill_profiles.py +++ b/backend/routers/skill_profiles.py @@ -21,9 +21,11 @@ from skill_scoring import ( collect_progression_graph_exercise_occurrences, collect_unit_exercise_occurrences, compact_profile_summary, + compute_planning_corpus_by_type, compute_club_corpus_reference, compute_corpus_skill_max_weights, compute_skill_profile, + corpus_for_artifact_type, fetch_exercise_skills_bulk, match_score_for_skill_ids, profile_for_occurrences, @@ -78,9 +80,10 @@ def framework_program_skill_profile( ) slots_raw = [r2d(r) for r in cur.fetchall()] - corpus = _load_club_corpus(cur, tenant) - ref_max = corpus["max_by_skill"] - ref_by_skill = corpus["ref_by_skill"] + bundle = _load_planning_corpus(cur, tenant) + tc = corpus_for_artifact_type(bundle, "framework_program") + ref_max = tc["max_by_skill"] + ref_by_skill = tc["ref_by_skill"] all_occurrences: List[ExerciseOccurrence] = [] slot_profiles: List[Dict[str, Any]] = [] @@ -136,7 +139,9 @@ def framework_program_skill_profile( "artifact_type": "framework_program", "artifact_id": framework_id, "artifact_title": row.get("title"), - "reference_scale": reference_scale_meta(corpus), + "reference_scale": reference_scale_meta( + tc, "framework_program", effective_club_id=tenant.effective_club_id + ), "club_best_by_skill": { str(k): v for k, v in ref_by_skill.items() }, @@ -155,9 +160,10 @@ def training_module_skill_profile( with get_db() as conn: cur = get_cursor(conn) row = _module_access(cur, module_id, profile_id, role) - corpus = _load_club_corpus(cur, tenant) - ref_max = corpus["max_by_skill"] - ref_by_skill = corpus["ref_by_skill"] + bundle = _load_planning_corpus(cur, tenant) + tc = corpus_for_artifact_type(bundle, "training_module") + ref_max = tc["max_by_skill"] + ref_by_skill = tc["ref_by_skill"] occurrences = collect_module_exercise_occurrences(cur, module_id) overall = ( profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max) @@ -169,7 +175,9 @@ def training_module_skill_profile( "artifact_type": "training_module", "artifact_id": module_id, "artifact_title": row.get("title"), - "reference_scale": reference_scale_meta(corpus), + "reference_scale": reference_scale_meta( + tc, "training_module", effective_club_id=tenant.effective_club_id + ), "club_best_by_skill": { str(k): v for k, v in ref_by_skill.items() }, @@ -187,9 +195,10 @@ def progression_graph_skill_profile( with get_db() as conn: cur = get_cursor(conn) row = _require_graph_read(cur, graph_id, profile_id, role) - corpus = _load_club_corpus(cur, tenant) - ref_max = corpus["max_by_skill"] - ref_by_skill = corpus["ref_by_skill"] + bundle = _load_planning_corpus(cur, tenant) + tc = corpus_for_artifact_type(bundle, "progression_graph") + ref_max = tc["max_by_skill"] + ref_by_skill = tc["ref_by_skill"] occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) overall = ( profile_for_occurrences( @@ -206,7 +215,9 @@ def progression_graph_skill_profile( "artifact_type": "progression_graph", "artifact_id": graph_id, "artifact_title": row.get("name"), - "reference_scale": reference_scale_meta(corpus), + "reference_scale": reference_scale_meta( + tc, "progression_graph", effective_club_id=tenant.effective_club_id + ), "club_best_by_skill": { str(k): v for k, v in ref_by_skill.items() }, @@ -237,13 +248,13 @@ def batch_skill_profile_summaries( with get_db() as conn: cur = get_cursor(conn) - corpus = compute_club_corpus_reference( + bundle = compute_planning_corpus_by_type( cur, profile_id=tenant.profile_id, + role=role, effective_club_id=tenant.effective_club_id, include_artifact_summaries=True, ) - ref_by_skill = corpus["ref_by_skill"] allowed_fp: List[int] = [] if fp_ids: @@ -265,10 +276,13 @@ def batch_skill_profile_summaries( summaries = _merge_batch_summaries( cur, - corpus=corpus, + bundle=bundle, allowed_fp=allowed_fp, allowed_mod=allowed_mod, ) + ref_by_skill = {} + for t in ("framework_program", "training_module", "progression_graph"): + ref_by_skill.update(corpus_for_artifact_type(bundle, t).get("ref_by_skill") or {}) skill_ids_seen: set[int] = set() for summary in summaries.values(): @@ -283,7 +297,14 @@ def batch_skill_profile_summaries( } return { - "reference_scale": reference_scale_meta(corpus), + "reference_scale_by_type": { + t: reference_scale_meta( + corpus_for_artifact_type(bundle, t), + t, + effective_club_id=tenant.effective_club_id, + ) + for t in ("framework_program", "training_module", "progression_graph") + }, "club_best_by_skill": club_best_subset, "summaries": summaries, } @@ -313,6 +334,10 @@ def skill_discovery_suggestions( with get_db() as conn: cur = get_cursor(conn) + planning_bundle = _load_planning_corpus(cur, tenant) + fw_ref = corpus_for_artifact_type(planning_bundle, "framework_program")["max_by_skill"] + mod_ref = corpus_for_artifact_type(planning_bundle, "training_module")["max_by_skill"] + graph_ref = corpus_for_artifact_type(planning_bundle, "progression_graph")["max_by_skill"] if "framework_program" in type_set: vis_clause, vis_params = library_content_visibility_sql( @@ -351,7 +376,7 @@ def skill_discovery_suggestions( occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) if not occ: continue - prof = profile_for_occurrences(cur, occ) + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=fw_ref) match = match_score_for_skill_ids(prof, wanted) if match["match_weight"] <= 0: continue @@ -395,7 +420,7 @@ def skill_discovery_suggestions( occ = collect_module_exercise_occurrences(cur, mid) if not occ: continue - prof = profile_for_occurrences(cur, occ) + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=mod_ref) match = match_score_for_skill_ids(prof, wanted) if match["match_weight"] <= 0: continue @@ -440,7 +465,8 @@ def skill_discovery_suggestions( if not occ: continue prof = profile_for_occurrences( - cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES, + reference_max_by_skill=graph_ref, ) match = match_score_for_skill_ids(prof, wanted) if match["match_weight"] <= 0: @@ -509,10 +535,11 @@ def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]: return out -def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]: - return compute_club_corpus_reference( +def _load_planning_corpus(cur, tenant: TenantContext) -> Dict[str, Any]: + return compute_planning_corpus_by_type( cur, profile_id=tenant.profile_id, + role=tenant.global_role, effective_club_id=tenant.effective_club_id, ) @@ -602,52 +629,61 @@ def _summarize_training_module( def _merge_batch_summaries( cur, *, - corpus: Dict[str, Any], + bundle: Dict[str, Any], allowed_fp: List[int], allowed_mod: List[int], ) -> Dict[str, Dict[str, Any]]: - """Summaries für angeforderte IDs — auch official/private (nicht nur Vereins-Corpus-Cache).""" - ref_max = corpus["max_by_skill"] - ref_by_skill = corpus["ref_by_skill"] - cached = corpus.get("artifact_summaries") or {} + """Summaries für angeforderte IDs — Referenz je Planungs-Kontext (Typ getrennt).""" + fw_tc = corpus_for_artifact_type(bundle, "framework_program") + mod_tc = corpus_for_artifact_type(bundle, "training_module") + fw_cached = fw_tc.get("artifact_summaries") or {} + mod_cached = mod_tc.get("artifact_summaries") or {} out: Dict[str, Dict[str, Any]] = {} - missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in cached] + fw_ref_max = fw_tc["max_by_skill"] + fw_ref_by = fw_tc["ref_by_skill"] + missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in fw_cached] if missing_fp: occ_map = batch_framework_occurrences_by_id(cur, missing_fp) all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} - profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max) + profiles = batch_compute_profiles( + occ_map, skills_map, reference_max_by_skill=fw_ref_max + ) for fid in missing_fp: key = f"framework_program:{fid}" prof = profiles.get(fid) or _empty_profile() - _enrich_profile_club_best(prof, ref_by_skill, "framework_program", fid) - out[key] = compact_profile_summary(prof, ref_by_skill) + _enrich_profile_club_best(prof, fw_ref_by, "framework_program", fid) + out[key] = compact_profile_summary(prof, fw_ref_by) - missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in cached] + mod_ref_max = mod_tc["max_by_skill"] + mod_ref_by = mod_tc["ref_by_skill"] + missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in mod_cached] if missing_mod: occ_map = batch_module_occurrences_by_id(cur, missing_mod) all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} - profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max) + profiles = batch_compute_profiles( + occ_map, skills_map, reference_max_by_skill=mod_ref_max + ) for mid in missing_mod: key = f"training_module:{mid}" prof = profiles.get(mid) or _empty_profile() - _enrich_profile_club_best(prof, ref_by_skill, "training_module", mid) - out[key] = compact_profile_summary(prof, ref_by_skill) + _enrich_profile_club_best(prof, mod_ref_by, "training_module", mid) + out[key] = compact_profile_summary(prof, mod_ref_by) for fid in allowed_fp: key = f"framework_program:{fid}" - if key in cached: - out[key] = cached[key] + if key in fw_cached: + out[key] = fw_cached[key] elif key not in out: - out[key] = _summarize_framework_program(cur, fid, ref_max, ref_by_skill) + out[key] = _summarize_framework_program(cur, fid, fw_ref_max, fw_ref_by) for mid in allowed_mod: key = f"training_module:{mid}" - if key in cached: - out[key] = cached[key] + if key in mod_cached: + out[key] = mod_cached[key] elif key not in out: - out[key] = _summarize_training_module(cur, mid, ref_max, ref_by_skill) + out[key] = _summarize_training_module(cur, mid, mod_ref_max, mod_ref_by) return out diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 6bf5ce0..972b55f 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -686,34 +686,41 @@ def batch_compute_profiles( } -def compute_club_corpus_reference( +def _empty_type_corpus() -> Dict[str, Any]: + return { + "max_by_skill": {}, + "ref_by_skill": {}, + "artifact_count": 0, + "artifact_summaries": {}, + } + + +def corpus_for_artifact_type( + bundle: Dict[str, Any], + artifact_type: str, +) -> Dict[str, Any]: + by_type = bundle.get("by_type") or {} + return by_type.get(artifact_type) or _empty_type_corpus() + + +def _scan_artifact_type_corpus( cur, *, + artifact_type: str, profile_id: int, + role: Optional[str], effective_club_id: Optional[int], - include_artifact_summaries: bool = False, + include_artifact_summaries: bool, ) -> Dict[str, Any]: - """ - Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein). - Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen). - """ - from tenant_context import club_library_visibility_sql + """Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek).""" + from tenant_context import library_content_visibility_sql max_by_skill: Dict[int, float] = {} ref_by_skill: Dict[int, Dict[str, Any]] = {} artifact_count = 0 raw_profiles: Dict[str, Dict[str, Any]] = {} - if effective_club_id is None: - return { - "club_id": None, - "max_by_skill": max_by_skill, - "ref_by_skill": ref_by_skill, - "artifact_count": 0, - "artifact_summaries": {}, - } - - def ingest(artifact_type: str, artifact_id: int, title: Optional[str], prof: Dict[str, Any]) -> None: + def ingest(aid: int, title: Optional[str], prof: Dict[str, Any]) -> None: nonlocal artifact_count if not prof.get("skills"): return @@ -723,85 +730,91 @@ def compute_club_corpus_reference( ref_by_skill, prof, artifact_type=artifact_type, - artifact_id=artifact_id, + artifact_id=aid, artifact_title=title, ) if include_artifact_summaries: - raw_profiles[f"{artifact_type}:{artifact_id}"] = prof + raw_profiles[f"{artifact_type}:{aid}"] = prof - vis_clause, vis_params = club_library_visibility_sql( - alias="fp", - profile_id=profile_id, - effective_club_id=effective_club_id, - ) - cur.execute( - f""" - SELECT fp.id, fp.title - FROM training_framework_programs fp - WHERE ({vis_clause}) - ORDER BY fp.updated_at DESC NULLS LAST - """, - vis_params, - ) - fw_rows = cur.fetchall() - if fw_rows: - fw_ids = [int(r["id"]) for r in fw_rows] - titles = {int(r["id"]): r.get("title") for r in fw_rows} - occ_map = batch_framework_occurrences_by_id(cur, fw_ids) - all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} - skills_map = fetch_exercise_skills_bulk(cur, all_eids) - profiles = batch_compute_profiles(occ_map, skills_map) - for fid, prof in profiles.items(): - ingest("framework_program", fid, titles.get(fid), prof) - - vis_clause, vis_params = club_library_visibility_sql( - alias="m", - profile_id=profile_id, - effective_club_id=effective_club_id, - ) - cur.execute( - f""" - SELECT m.id, m.title - FROM training_modules m - WHERE ({vis_clause}) - ORDER BY m.updated_at DESC NULLS LAST - """, - vis_params, - ) - mod_rows = cur.fetchall() - if mod_rows: - mod_ids = [int(r["id"]) for r in mod_rows] - titles = {int(r["id"]): r.get("title") for r in mod_rows} - occ_map = batch_module_occurrences_by_id(cur, mod_ids) - all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} - skills_map = fetch_exercise_skills_bulk(cur, all_eids) - profiles = batch_compute_profiles(occ_map, skills_map) - for mid, prof in profiles.items(): - ingest("training_module", mid, titles.get(mid), prof) - - vis_clause, vis_params = club_library_visibility_sql( - alias="g", - profile_id=profile_id, - effective_club_id=effective_club_id, - ) - cur.execute( - f""" - SELECT g.id, g.name - FROM exercise_progression_graphs g - WHERE ({vis_clause}) - ORDER BY g.updated_at DESC NULLS LAST - """, - vis_params, - ) - for row in cur.fetchall(): - gid = int(row["id"]) - occ = collect_progression_graph_exercise_occurrences(cur, gid) - if not occ: - continue - prof = profile_for_occurrences( - cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + if artifact_type == "framework_program": + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role or "", + effective_club_id=effective_club_id, ) - ingest("progression_graph", gid, row.get("name"), prof) + cur.execute( + f""" + SELECT fp.id, fp.title + FROM training_framework_programs fp + WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST + """, + vis_params, + ) + rows = cur.fetchall() + if rows: + ids = [int(r["id"]) for r in rows] + titles = {int(r["id"]): r.get("title") for r in rows} + occ_map = batch_framework_occurrences_by_id(cur, ids) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles(occ_map, skills_map) + for aid, prof in profiles.items(): + ingest(aid, titles.get(aid), prof) + + elif artifact_type == "training_module": + vis_clause, vis_params = library_content_visibility_sql( + alias="m", + profile_id=profile_id, + role=role or "", + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT m.id, m.title + FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST + """, + vis_params, + ) + rows = cur.fetchall() + if rows: + ids = [int(r["id"]) for r in rows] + titles = {int(r["id"]): r.get("title") for r in rows} + occ_map = batch_module_occurrences_by_id(cur, ids) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles(occ_map, skills_map) + for aid, prof in profiles.items(): + ingest(aid, titles.get(aid), prof) + + elif artifact_type == "progression_graph": + vis_clause, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role or "", + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT g.id, g.name + FROM exercise_progression_graphs g + WHERE ({vis_clause}) + ORDER BY g.updated_at DESC NULLS LAST + """, + vis_params, + ) + for row in cur.fetchall(): + gid = int(row["id"]) + occ = collect_progression_graph_exercise_occurrences(cur, gid) + if not occ: + continue + prof = profile_for_occurrences( + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + ) + ingest(gid, row.get("name"), prof) artifact_summaries: Dict[str, Dict[str, Any]] = {} if include_artifact_summaries and raw_profiles: @@ -810,7 +823,6 @@ def compute_club_corpus_reference( artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill) return { - "club_id": effective_club_id, "max_by_skill": max_by_skill, "ref_by_skill": ref_by_skill, "artifact_count": artifact_count, @@ -818,15 +830,106 @@ def compute_club_corpus_reference( } -def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]: +def compute_planning_corpus_by_type( + cur, + *, + profile_id: int, + role: Optional[str], + effective_club_id: Optional[int], + include_artifact_summaries: bool = False, +) -> Dict[str, Any]: + """ + Referenz je Fähigkeit getrennt nach Planungs-Kontext: + Rahmenprogramme, Trainingsmodule und Regressionspfade jeweils für sich, + jeweils über die sichtbare Bibliothek (library_content_visibility_sql). + """ + by_type = { + "framework_program": _scan_artifact_type_corpus( + cur, + artifact_type="framework_program", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ), + "training_module": _scan_artifact_type_corpus( + cur, + artifact_type="training_module", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ), + "progression_graph": _scan_artifact_type_corpus( + cur, + artifact_type="progression_graph", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ), + } return { - "scope": "club", - "club_id": corpus.get("club_id"), - "skills_in_corpus": len(corpus.get("max_by_skill") or {}), - "artifacts_scanned": corpus.get("artifact_count") or 0, + "effective_club_id": effective_club_id, + "by_type": by_type, + } + + +def compute_club_corpus_reference( + cur, + *, + profile_id: int, + effective_club_id: Optional[int], + include_artifact_summaries: bool = False, + role: Optional[str] = None, +) -> Dict[str, Any]: + """Legacy-Hülle — merged summaries über alle Typen (vermeiden für neue Aufrufer).""" + bundle = compute_planning_corpus_by_type( + cur, + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ) + tc = corpus_for_artifact_type(bundle, "framework_program") + merged_summaries: Dict[str, Dict[str, Any]] = {} + for t in ("framework_program", "training_module", "progression_graph"): + merged_summaries.update((bundle["by_type"][t].get("artifact_summaries") or {})) + return { + "club_id": effective_club_id, + "max_by_skill": tc["max_by_skill"], + "ref_by_skill": tc["ref_by_skill"], + "artifact_count": sum( + (bundle["by_type"][t].get("artifact_count") or 0) + for t in bundle["by_type"] + ), + "artifact_summaries": merged_summaries, + } + + +_ARTIFACT_TYPE_LABELS = { + "framework_program": "Rahmenprogrammen", + "training_module": "Trainingsmodulen", + "progression_graph": "Regressionspfaden", +} + + +def reference_scale_meta( + type_corpus: Dict[str, Any], + artifact_type: str, + *, + effective_club_id: Optional[int] = None, +) -> Dict[str, Any]: + label = _ARTIFACT_TYPE_LABELS.get(artifact_type, artifact_type) + return { + "scope": "planning_peer", + "artifact_type": artifact_type, + "effective_club_id": effective_club_id, + "skills_in_corpus": len(type_corpus.get("max_by_skill") or {}), + "artifacts_scanned": type_corpus.get("artifact_count") or 0, "description": ( - "universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit " - "(nur visibility=club im aktiven Verein)" + f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit " + f"(nicht gemischt mit anderen Planungs-Artefakttypen)" ), } @@ -838,18 +941,17 @@ def compute_corpus_skill_max_weights( role: Optional[str], effective_club_id: Optional[int], limit_per_type: int = 50, + artifact_type: str = "framework_program", ) -> Dict[int, float]: - """ - Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference). - role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte). - """ - del role, limit_per_type - corpus = compute_club_corpus_reference( + """Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle).""" + del limit_per_type + bundle = compute_planning_corpus_by_type( cur, profile_id=profile_id, + role=role, effective_club_id=effective_club_id, ) - return corpus["max_by_skill"] + return corpus_for_artifact_type(bundle, artifact_type)["max_by_skill"] def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 51c1cf8..3dceb6c 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -689,6 +689,7 @@ export default function ExerciseProgressionGraphPanel({ loading={skillProfileLoading} error={skillProfileError} defaultExpanded + artifactType="progression_graph" />

      Sequenz / Reihe anlegen

      diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx index 7743774..1fbf549 100644 --- a/frontend/src/components/planning/FrameworkProgramListCard.jsx +++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx @@ -137,6 +137,7 @@ export default function FrameworkProgramListCard({
      0 ? (
      - Fähigkeiten (Vereinsvergleich) + Fähigkeiten (Rahmenprogramme)

      - Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit — ohne - Punktewerte eingeben. + Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit — nur + unter Rahmenprogrammen, nicht gegen Module.

      {catalogSkills.map((sk) => ( @@ -312,7 +312,7 @@ export default function FrameworkProgramsFilterBlock({ {(filters.skillIds || []).length > 0 ? (
      - + onFiltersChange((prev) => ({ ...prev, query: e.target.value }))} + placeholder={searchPlaceholder} + disabled={disabled} + enterKeyHint="search" + /> +
      +
      + + {filterChips.length > 0 ? ( + + ) : null} +
      +
      + {filterChips.length > 0 ? ( +
      + {filterChips.map((c) => ( + + ))} +
      + ) : null} + {showHint ? ( +

      + Fachliche Filter über „Filter“ — zwischen Feldern UND. Fähigkeiten vergleichen nur unter + Rahmenprogrammen. +

      + ) : null} +
      +
      {matchCount} {' '} - von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'} + von {totalCount} {resultPlural} {matchCount === 0 && totalCount > 0 ? ( — kein Treffer ) : null}
      -
      - {filterActive ? ( - - Filter aktiv - - ) : null} - {filterActive ? ( - - ) : null} - {onPanelOpenChange ? ( - - ) : null} -
      + {filterActive ? ( +
      + Filter aktiv +
      + ) : null}
      - {!panelOpen && filterActive && filterSummaryParts.length > 0 ? ( -
        - {filterSummaryParts.map((part) => ( -
      • - {part} -
      • - ))} -
      - ) : null} - - {panelOpen ? ( -
      -
      -
      - - updateFilter({ query: e.target.value })} - placeholder="z. B. Gürtel, Koordination …" - disabled={disabled} - /> -
      - -
      - Ziel-Session-Dauer -
      - {[ - { id: 'any', label: 'Alle' }, - { id: 'range', label: 'Zeitspanne' }, - { id: 'preset', label: 'Vorhandene Zeiten' }, - ].map((opt) => ( - - ))} -
      - - {filters.durationMode === 'range' ? ( -
      -
      - - - updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value }) - } - placeholder="z. B. 60" - disabled={disabled} - /> -
      -
      - - - updateFilter({ durationMode: 'range', durationRangeTo: e.target.value }) - } - placeholder="z. B. 90" - disabled={disabled} - /> -
      -
      - ) : null} - - {filters.durationMode === 'preset' ? ( - distinctDurations.length === 0 ? ( -

      - In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege - Dauer pro Session im Rahmenprogramm fest. -

      - ) : ( -
      - {distinctDurations.map((min) => { - const on = filters.durationPresetMin === min - return ( - - ) - })} -
      - ) - ) : null} -
      - - {catalogFocusAreas.length > 0 ? ( -
      - Fokusbereich -
      - {catalogFocusAreas.map((fa) => ( - - ))} -
      -
      - ) : null} - - {catalogTrainingTypes.length > 0 ? ( -
      - Trainingsart -
      - {catalogTrainingTypes.map((t) => ( - - ))} -
      -
      - ) : null} - - {catalogTargetGroups.length > 0 ? ( -
      - Zielgruppe -
      - {catalogTargetGroups.map((tg) => ( - - ))} -
      -
      - ) : null} - - {catalogSkills.length > 0 ? ( -
      - Fähigkeiten (Rahmenprogramme) -

      - Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit — nur - unter Rahmenprogrammen, nicht gegen Module. -

      -
      - {catalogSkills.map((sk) => ( - - ))} -
      - {(filters.skillIds || []).length > 0 ? ( -
      -
      - - -
      -
      - - -
      -
      - ) : null} -
      - ) : null} -
      - {showHint ? ( -

      - Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur - Programme mit hinterlegter Session-Dauer berücksichtigt. -

      - ) : null} -
      - ) : null} + setFilterModalOpen(false)} + filters={filters} + onFiltersChange={onFiltersChange} + artifactType="framework_program" + programs={programs} + catalogFocusAreas={catalogFocusAreas} + catalogTrainingTypes={catalogTrainingTypes} + catalogTargetGroups={catalogTargetGroups} + catalogSkills={catalogSkills} + durationRadioName={durationRadioName} + onResetAll={clearFilters} + disabled={disabled} + title={filterModalTitle} + showCatalogFilters + showDurationFilters + />
      ) } diff --git a/frontend/src/components/planning/PlanningArtifactFilterModal.jsx b/frontend/src/components/planning/PlanningArtifactFilterModal.jsx new file mode 100644 index 0000000..d2eadc5 --- /dev/null +++ b/frontend/src/components/planning/PlanningArtifactFilterModal.jsx @@ -0,0 +1,275 @@ +import React, { useMemo } from 'react' +import { collectDistinctSessionDurationsMinutes } from '../../utils/frameworkProgramListHelpers' +import { formatDurationDisplay } from '../../utils/trainingDurationUtils' +import PlanningSkillFilterSection from './PlanningSkillFilterSection' + +/** + * Filter-Modal für Rahmenprogramme / Trainingsmodule (UX wie ExerciseListFilterModal). + */ +export default function PlanningArtifactFilterModal({ + open, + onClose, + filters, + onFiltersChange, + artifactType = 'framework_program', + programs = [], + catalogFocusAreas = [], + catalogTrainingTypes = [], + catalogTargetGroups = [], + catalogSkills = [], + durationRadioName = 'planning-duration-mode', + onResetAll, + disabled = false, + showCatalogFilters = true, + showDurationFilters = true, + title = 'Filtern', +}) { + const distinctDurations = useMemo( + () => (showDurationFilters ? collectDistinctSessionDurationsMinutes(programs) : []), + [programs, showDurationFilters] + ) + + if (!open) return null + + const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch })) + + const toggleId = (key, id) => { + const s = String(id) + onFiltersChange((prev) => { + const cur = prev[key] || [] + const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s] + return { ...prev, [key]: next } + }) + } + + const artifactLabel = + artifactType === 'training_module' + ? 'Trainingsmodule' + : artifactType === 'framework_program' + ? 'Rahmenprogramme' + : 'Einträge' + + return ( +
      { + if (e.target === e.currentTarget) onClose() + }} + > +
      e.stopPropagation()} + > +
      +

      + {title} +

      + +
      +
      +

      + Zwischen den Bereichen gilt UND. Mehrere Katalog-Werte innerhalb eines Feldes + bedeuten ODER. Fähigkeiten filtern nach Trainingsgewicht — nur unter sichtbaren{' '} + {artifactLabel}. +

      + + {showCatalogFilters ? ( +
      +

      Katalog

      +
      + {catalogFocusAreas.length > 0 ? ( +
      + Fokusbereich +
      + {catalogFocusAreas.map((fa) => ( + + ))} +
      +
      + ) : null} + + {catalogTrainingTypes.length > 0 ? ( +
      + Trainingsart +
      + {catalogTrainingTypes.map((t) => ( + + ))} +
      +
      + ) : null} + + {catalogTargetGroups.length > 0 ? ( +
      + Zielgruppe +
      + {catalogTargetGroups.map((tg) => ( + + ))} +
      +
      + ) : null} +
      +
      + ) : null} + + {showDurationFilters ? ( +
      +

      Session-Dauer

      +
      +
      + {[ + { id: 'any', label: 'Alle' }, + { id: 'range', label: 'Zeitspanne' }, + { id: 'preset', label: 'Vorhandene Zeiten' }, + ].map((opt) => ( + + ))} +
      + + {filters.durationMode === 'range' ? ( +
      +
      + + + updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value }) + } + placeholder="z. B. 60" + disabled={disabled} + /> +
      +
      + + + updateFilter({ durationMode: 'range', durationRangeTo: e.target.value }) + } + placeholder="z. B. 90" + disabled={disabled} + /> +
      +
      + ) : null} + + {filters.durationMode === 'preset' ? ( + distinctDurations.length === 0 ? ( +

      + Noch keine Session-Dauern hinterlegt. +

      + ) : ( +
      + {distinctDurations.map((min) => { + const on = filters.durationPresetMin === min + return ( + + ) + })} +
      + ) + ) : null} +
      +
      + ) : null} + + {catalogSkills.length > 0 ? ( +
      +

      Fähigkeit und Trainingsgewicht

      + updateFilter({ skillIds: v })} + skillMinClubPercent={filters.skillMinClubPercent ?? 0} + onSkillMinClubPercentChange={(v) => updateFilter({ skillMinClubPercent: v })} + skillSort={filters.skillSort || 'title'} + onSkillSortChange={(v) => updateFilter({ skillSort: v })} + skillsCatalog={catalogSkills} + disabled={disabled} + /> +
      + ) : null} +
      +
      + + +
      +
      +
      + ) +} diff --git a/frontend/src/components/planning/PlanningSkillFilterSection.jsx b/frontend/src/components/planning/PlanningSkillFilterSection.jsx new file mode 100644 index 0000000..16fb796 --- /dev/null +++ b/frontend/src/components/planning/PlanningSkillFilterSection.jsx @@ -0,0 +1,69 @@ +import React from 'react' +import SkillTreeMultiSelect from '../SkillTreeMultiSelect' +import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers' + +/** + * Fähigkeiten-Filter für Planungsartefakte (Rahmenprogramme, Module). + * Semantik wie Übungsliste (SkillTreeMultiSelect), aber mit Peer-Prozent statt Stufen. + */ +export default function PlanningSkillFilterSection({ + artifactType = 'framework_program', + skillIds = [], + onSkillIdsChange, + skillMinClubPercent = 0, + onSkillMinClubPercentChange, + skillSort = 'title', + onSkillSortChange, + skillsCatalog = [], + disabled = false, +}) { + const peerLabel = peerCorpusCountLabel(artifactType) + const peerMaxLabel = + artifactType === 'framework_program' ? 'Rahmenprogramm-Maximum' : `${peerLabel}-Maximum` + + return ( +
      + + +

      + Trainingsgewicht relativ zum stärksten sichtbaren Eintrag unter {peerLabel} — nicht gemischt mit + anderen Planungs-Artefakttypen. +

      + {(skillIds || []).length > 0 ? ( +
      +
      + + +
      +
      + + +
      +
      + ) : null} +
      + ) +} diff --git a/frontend/src/components/planning/TrainingModulesFilterBlock.jsx b/frontend/src/components/planning/TrainingModulesFilterBlock.jsx new file mode 100644 index 0000000..d5a9daa --- /dev/null +++ b/frontend/src/components/planning/TrainingModulesFilterBlock.jsx @@ -0,0 +1,148 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { + EMPTY_TRAINING_MODULE_FILTERS, + filterTrainingModules, + hasActiveTrainingModuleFilters, +} from '../../utils/trainingModuleListHelpers' +import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips' +import PlanningArtifactFilterModal from './PlanningArtifactFilterModal' + +/** + * Filter-Leiste für Trainingsmodule (UX wie Übungsliste / Rahmenprogramme). + */ +export default function TrainingModulesFilterBlock({ + modules = [], + filters, + onFiltersChange, + catalogSkills = [], + skillSummaries = null, + disabled = false, + className = '', +}) { + const [filterModalOpen, setFilterModalOpen] = useState(false) + + const matchCount = useMemo( + () => filterTrainingModules(modules, filters, skillSummaries).length, + [modules, filters, skillSummaries] + ) + + const totalCount = (modules || []).length + const filterActive = hasActiveTrainingModuleFilters(filters) + + const filterChips = useMemo( + () => + buildPlanningArtifactFilterChips({ + filters, + setFilters: onFiltersChange, + catalogs: { skills: catalogSkills }, + artifactType: 'training_module', + emptyFilters: EMPTY_TRAINING_MODULE_FILTERS, + }), + [filters, onFiltersChange, catalogSkills] + ) + + const clearFilters = () => onFiltersChange({ ...EMPTY_TRAINING_MODULE_FILTERS }) + + useEffect(() => { + if (!filterModalOpen) return undefined + const onKey = (e) => { + if (e.key === 'Escape') setFilterModalOpen(false) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [filterModalOpen]) + + return ( +
      +
      + + onFiltersChange((prev) => ({ ...prev, query: e.target.value }))} + placeholder="Titel oder Kurzbeschreibung …" + disabled={disabled} + enterKeyHint="search" + /> +
      +
      + + {filterChips.length > 0 ? ( + + ) : null} +
      +
      + {filterChips.length > 0 ? ( +
      + {filterChips.map((c) => ( + + ))} +
      + ) : null} +

      + Fachliche Filter über „Filter“. Fähigkeiten vergleichen nur unter sichtbaren Modulen. +

      +
      + +
      +
      + {matchCount} + + {' '} + von {totalCount} Modul{totalCount === 1 ? '' : 'en'} + + {matchCount === 0 && totalCount > 0 ? ( + — kein Treffer + ) : null} +
      + {filterActive ? ( +
      + Filter aktiv +
      + ) : null} +
      + + setFilterModalOpen(false)} + filters={filters} + onFiltersChange={onFiltersChange} + artifactType="training_module" + catalogSkills={catalogSkills} + onResetAll={clearFilters} + disabled={disabled} + title="Trainingsmodule filtern" + showCatalogFilters={false} + showDurationFilters={false} + /> +
      + ) +} diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx index cbadef2..1eaa060 100644 --- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx @@ -35,12 +35,10 @@ export default function TrainingPlanningFrameworkImportModal({ onClose, }) { const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) - const [filterPanelOpen, setFilterPanelOpen] = useState(true) useEffect(() => { if (!open) { setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }) - setFilterPanelOpen(true) } }, [open]) @@ -80,13 +78,14 @@ export default function TrainingPlanningFrameworkImportModal({ programs={frameworkProgramsList} filters={filters} onFiltersChange={setFilters} - panelOpen={filterPanelOpen} - onPanelOpenChange={setFilterPanelOpen} catalogFocusAreas={catalogFocusAreas} catalogTrainingTypes={catalogTrainingTypes} catalogTargetGroups={catalogTargetGroups} disabled={fwImportSubmitting} durationRadioName="fw-duration-mode" + showHint={false} + searchPlaceholder="Rahmenprogramm suchen …" + filterModalTitle="Rahmenprogramme filtern" />
      diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index 048e2a6..34e5e04 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -25,7 +25,6 @@ export default function TrainingFrameworkProgramsListPage() { const [catalogTargetGroups, setCatalogTargetGroups] = useState([]) const [catalogSkills, setCatalogSkills] = useState([]) const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) - const [filterPanelOpen, setFilterPanelOpen] = useState(true) const [skillSummaries, setSkillSummaries] = useState({}) const [summariesLoading, setSummariesLoading] = useState(false) const [profileModal, setProfileModal] = useState(null) @@ -45,7 +44,7 @@ export default function TrainingFrameworkProgramsListPage() { api.listFocusAreas({ status: 'active' }), api.listTrainingTypes({ status: 'active' }), api.listTargetGroups({ status: 'active' }), - api.listSkills({ status: 'active' }), + api.listSkillsCatalog({ status: 'active' }), ]) setRows(Array.isArray(list) ? list : []) setCatalogFocusAreas(Array.isArray(fa) ? fa : []) @@ -162,8 +161,6 @@ export default function TrainingFrameworkProgramsListPage() { programs={rows} filters={filters} onFiltersChange={setFilters} - panelOpen={filterPanelOpen} - onPanelOpenChange={setFilterPanelOpen} catalogFocusAreas={catalogFocusAreas} catalogTrainingTypes={catalogTrainingTypes} catalogTargetGroups={catalogTargetGroups} diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index a5b5e60..4f0492e 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -1,11 +1,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import api from '../utils/api' import NavStateLink from '../components/NavStateLink' +import TrainingModulesFilterBlock from '../components/planning/TrainingModulesFilterBlock' import SkillProfileCompact from '../components/skills/SkillProfileCompact' import SkillProfileFullModal from '../components/skills/SkillProfileFullModal' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext' +import { + EMPTY_TRAINING_MODULE_FILTERS, + filterTrainingModules, + hasActiveTrainingModuleFilters, +} from '../utils/trainingModuleListHelpers' export default function TrainingModulesListPage() { const { user } = useAuth() @@ -14,19 +20,32 @@ export default function TrainingModulesListPage() { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [catalogSkills, setCatalogSkills] = useState([]) + const [filters, setFilters] = useState(() => ({ ...EMPTY_TRAINING_MODULE_FILTERS })) const [skillSummaries, setSkillSummaries] = useState({}) const [summariesLoading, setSummariesLoading] = useState(false) const [profileModal, setProfileModal] = useState(null) + const filteredRows = useMemo( + () => filterTrainingModules(rows, filters, skillSummaries), + [rows, filters, skillSummaries] + ) + const filterActive = hasActiveTrainingModuleFilters(filters) + const load = useCallback(async () => { setLoading(true) setError('') try { - const list = await api.listTrainingModules() + const [list, skills] = await Promise.all([ + api.listTrainingModules(), + api.listSkillsCatalog({ status: 'active' }), + ]) setRows(Array.isArray(list) ? list : []) + setCatalogSkills(Array.isArray(skills) ? skills : []) } catch (e) { setError(e.message || 'Laden fehlgeschlagen') setRows([]) + setCatalogSkills([]) } finally { setLoading(false) } @@ -86,7 +105,8 @@ export default function TrainingModulesListPage() { Trainingsmodule

      - Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter sichtbaren Modulen. + Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter + sichtbaren Modulen.

      Noch keine Module angelegt.

      ) : ( -
        - {rows.map((r) => ( -
      • -
        -
        - + + + {filteredRows.length === 0 ? ( +
        +

        Kein Treffer

        +

        + {filterActive + ? 'Kein Modul passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.' + : 'Keine Einträge.'} +

        +
        + ) : ( +
          + {filteredRows.map((r) => ( +
        • +
          - {(r.title || '').trim() || `Modul #${r.id}`} - -

          - {(r.summary || '').trim() || '—'}{' '} - - ({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'}) - -

          -
          - +
          + + {(r.title || '').trim() || `Modul #${r.id}`} + +

          + {(r.summary || '').trim() || '—'}{' '} + + ({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'}) + +

          +
          + +
          +
          +
          + + + Bearbeiten + + +
          -
          -
          - - - Bearbeiten - - -
          -
        -
      • - ))} -
      + + ))} +
    + )} + )} String(x.id) === String(id))?.name || id +} + +function peerPercentLabel(artifactType) { + const label = peerCorpusCountLabel(artifactType) + return label === 'Rahmenprogramme' ? 'Rahmenprogramm-Maximum' : `${label}-Maximum` +} + +/** + * Entfernbare Filter-Chips (UX wie Übungsliste). + */ +export function buildPlanningArtifactFilterChips({ + filters, + setFilters, + catalogs = {}, + artifactType = 'framework_program', + emptyFilters = null, +}) { + const base = + emptyFilters || + (artifactType === 'training_module' ? EMPTY_TRAINING_MODULE_FILTERS : EMPTY_FRAMEWORK_IMPORT_FILTERS) + const f = { ...base, ...filters } + const chips = [] + const peerMaxLabel = peerPercentLabel(artifactType) + + const q = (f.query || '').trim() + if (q) { + chips.push({ + key: 'query', + label: `Suche: „${q}"`, + onRemove: () => setFilters((prev) => ({ ...prev, query: '' })), + }) + } + + ;(f.skillIds || []).forEach((id) => { + chips.push({ + key: `skill-${id}`, + label: `Fähigkeit: ${nameById(catalogs.skills, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + skillIds: (prev.skillIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (Number(f.skillMinClubPercent) > 0) { + chips.push({ + key: 'skill-min-pct', + label: `mind. ${f.skillMinClubPercent}% vom ${peerMaxLabel}`, + onRemove: () => setFilters((prev) => ({ ...prev, skillMinClubPercent: 0 })), + }) + } + + if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) { + chips.push({ + key: 'skill-sort', + label: 'Sortierung: Fähigkeiten-Stärke', + onRemove: () => setFilters((prev) => ({ ...prev, skillSort: 'title' })), + }) + } + + ;(f.focusAreaIds || []).forEach((id) => { + chips.push({ + key: `focus-${id}`, + label: `Fokus: ${nameById(catalogs.focusAreas, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + focusAreaIds: (prev.focusAreaIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + ;(f.trainingTypeIds || []).forEach((id) => { + chips.push({ + key: `type-${id}`, + label: `Trainingsart: ${nameById(catalogs.trainingTypes, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + trainingTypeIds: (prev.trainingTypeIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + ;(f.targetGroupIds || []).forEach((id) => { + chips.push({ + key: `tg-${id}`, + label: `Zielgruppe: ${nameById(catalogs.targetGroups, id)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + targetGroupIds: (prev.targetGroupIds || []).filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (f.durationMode === 'range') { + const a = String(f.durationRangeFrom || '').trim() + const b = String(f.durationRangeTo || '').trim() + if (a || b) { + const fromLbl = a ? formatDurationDisplay(Number(a), { empty: a }) : '—' + const toLbl = b ? formatDurationDisplay(Number(b), { empty: b }) : '—' + chips.push({ + key: 'duration-range', + label: `Dauer: ${fromLbl} – ${toLbl}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + durationMode: 'any', + durationRangeFrom: '', + durationRangeTo: '', + })), + }) + } + } else if (f.durationMode === 'preset' && f.durationPresetMin != null) { + chips.push({ + key: 'duration-preset', + label: `Dauer: ${formatDurationDisplay(f.durationPresetMin)}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + durationMode: 'any', + durationPresetMin: null, + })), + }) + } + + return chips +} diff --git a/frontend/src/utils/trainingModuleListHelpers.js b/frontend/src/utils/trainingModuleListHelpers.js new file mode 100644 index 0000000..68a054a --- /dev/null +++ b/frontend/src/utils/trainingModuleListHelpers.js @@ -0,0 +1,57 @@ +import { + maxSelectedSkillClubPercent, + moduleSkillSummaryKey, + summaryHasSkill, +} from './skillProfileListHelpers' + +export const EMPTY_TRAINING_MODULE_FILTERS = { + query: '', + skillIds: [], + skillSort: 'title', + skillMinClubPercent: 0, + skillDisplayLimit: 12, +} + +export function hasActiveTrainingModuleFilters(filters = {}) { + const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters } + if ((f.query || '').trim()) return true + if ((f.skillIds || []).length) return true + if (Number(f.skillMinClubPercent) > 0) return true + if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true + return false +} + +export function filterTrainingModules(rows, filters = {}, skillSummaries = null) { + const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters } + const q = (f.query || '').trim().toLowerCase() + const skillIds = f.skillIds || [] + const minClubPct = Number(f.skillMinClubPercent) || 0 + + let list = (rows || []).filter((r) => { + if (q) { + const blob = [r.title, r.summary].filter(Boolean).join(' ').toLowerCase() + if (!blob.includes(q)) return false + } + return true + }) + + if (skillIds.length && skillSummaries) { + list = list.filter((r) => { + const summary = skillSummaries[moduleSkillSummaryKey(r.id)] + if (!summary) return false + return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct)) + }) + } + + if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) { + list = [...list].sort((a, b) => { + const sa = skillSummaries[moduleSkillSummaryKey(a.id)] + const sb = skillSummaries[moduleSkillSummaryKey(b.id)] + const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1 + const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1 + return pb - pa + }) + } + + return list +} From a7a428745f46fa35abb4c5ee81b02ecd6b8d802d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 10:31:21 +0200 Subject: [PATCH 10/10] Enhance SkillTreeMultiSelect Component and CSS Styles - Updated the SkillTreeMultiSelect component to support dynamic positioning and improved accessibility through the use of portals. - Refactored the dropdown panel rendering logic to enhance user experience when selecting skills. - Added CSS styles for the exercise filter modal to improve layout and responsiveness. - Introduced new styles for the skill tree multiselect panel, ensuring better visual integration and usability. --- frontend/src/app.css | 24 +++ .../src/components/SkillTreeMultiSelect.jsx | 141 ++++++++++++------ 2 files changed, 123 insertions(+), 42 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index bd4581c..9feb71f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -4086,6 +4086,12 @@ html.modal-scroll-locked .app-main { .exercise-filter-modal.admin-modal-sheet { max-width: min(920px, calc(100dvw - 16px)); + max-height: min(92vh, 920px); +} +@media (min-width: 640px) { + .exercise-filter-modal.admin-modal-sheet { + max-height: min(90vh, 920px); + } } .exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll { flex: 1; @@ -6418,6 +6424,24 @@ html.modal-scroll-locked .app-main { max-height: min(360px, 55vh); overflow: auto; } +.skill-tree-multiselect__panel--portal { + position: fixed; + right: auto; + top: auto; + overflow: auto; + -webkit-overflow-scrolling: touch; +} +.skill-tree-multiselect__panel--portal .multiselect-combo__list { + position: static; + left: auto; + right: auto; + top: auto; + margin: 0; + max-height: none; + box-shadow: none; + border: none; + border-radius: 0; +} .skill-tree { list-style: none; margin: 0; diff --git a/frontend/src/components/SkillTreeMultiSelect.jsx b/frontend/src/components/SkillTreeMultiSelect.jsx index 98dc4c0..8e0187a 100644 --- a/frontend/src/components/SkillTreeMultiSelect.jsx +++ b/frontend/src/components/SkillTreeMultiSelect.jsx @@ -1,7 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree' import SkillTreePickerPanel from './SkillTreePickerPanel' +const PANEL_MAX_HEIGHT = 360 +const PANEL_MIN_HEIGHT = 140 +const PANEL_Z_INDEX = 1100 + function normId(id) { return String(id) } @@ -17,11 +22,15 @@ export default function SkillTreeMultiSelect({ browseLabel = '▼ Katalog', emptyHint = 'Keine Treffer', className = '', + usePortal = true, }) { const [query, setQuery] = useState('') const [open, setOpen] = useState(false) const [browseTree, setBrowseTree] = useState(false) + const [panelStyle, setPanelStyle] = useState(null) const rootRef = useRef(null) + const fieldRef = useRef(null) + const panelRef = useRef(null) const tree = useMemo(() => buildSkillCatalogTree(skills), [skills]) const selectedSet = useMemo(() => new Set(value.map(normId)), [value]) @@ -56,17 +65,99 @@ export default function SkillTreeMultiSelect({ useEffect(() => { const onDoc = (e) => { - if (!rootRef.current?.contains(e.target)) { - setOpen(false) - setBrowseTree(false) - } + const t = e.target + if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return + setOpen(false) + setBrowseTree(false) } document.addEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc) }, []) + const updatePanelPosition = useCallback(() => { + const anchor = fieldRef.current + if (!anchor) return + const rect = anchor.getBoundingClientRect() + const gap = 4 + const margin = 12 + const spaceBelow = window.innerHeight - rect.bottom - gap - margin + const spaceAbove = rect.top - gap - margin + const openUp = spaceBelow < PANEL_MIN_HEIGHT && spaceAbove > spaceBelow + const available = Math.max(0, openUp ? spaceAbove : spaceBelow) + const maxHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, available)) + const top = openUp ? Math.max(margin, rect.top - gap - maxHeight) : rect.bottom + gap + + setPanelStyle({ + position: 'fixed', + left: rect.left, + width: rect.width, + top, + maxHeight, + zIndex: PANEL_Z_INDEX, + }) + }, []) + + useLayoutEffect(() => { + if (!open || !usePortal) { + setPanelStyle(null) + return undefined + } + updatePanelPosition() + window.addEventListener('resize', updatePanelPosition) + window.addEventListener('scroll', updatePanelPosition, true) + return () => { + window.removeEventListener('resize', updatePanelPosition) + window.removeEventListener('scroll', updatePanelPosition, true) + } + }, [open, usePortal, updatePanelPosition, query, browseTree, value.length]) + const showTree = browseTree || !query.trim() + const panelContent = showTree ? ( + addId(id)} + pickMode="multi" + /> + ) : ( +
      + {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === 0 ? ( +
    • {emptyHint}
    • + ) : ( + leaves + .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())) + .map((l) => ( +
    • + +
    • + )) + )} +
    + ) + + const dropdownPanel = + open && (usePortal ? panelStyle : true) ? ( +
    + {panelContent} +
    + ) : null + return (
    @@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({ ))}
    -
    +
    - {open ? ( -
    - {showTree ? ( - addId(id)} - pickMode="multi" - /> - ) : ( -
      - {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === - 0 ? ( -
    • {emptyHint}
    • - ) : ( - leaves - .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())) - .map((l) => ( -
    • - -
    • - )) - )} -
    - )} -
    - ) : null} + {!usePortal && dropdownPanel} + {usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
    ) }