diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md new file mode 100644 index 0000000..17ef945 --- /dev/null +++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md @@ -0,0 +1,96 @@ +# Gewichtetes Fähigkeiten-Scoring (Phase 3) + +**Stand:** 2026-05-20 +**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala) +**Modul:** `backend/skill_scoring.py`, Router `skill_profiles` + +## Ziel + +Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken. + +## Datenquellen + +| Artefakt | Übungen aus | +|----------|-------------| +| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` | +| Rahmenprogramm (pro Slot) | Blueprint einer Session | +| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) | +| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) | + +Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`). + +## Gewichtungsformel (v1.1) + +Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt): + +1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min). +2. Pro verknüpfte Fähigkeit der Übung: + - `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor` + - **Link-Faktor** = Intensität × Stufen-Faktor + +### Intensität (Nutzeneinschätzung, UI-Feld) + +| Wert | Faktor | +|------|--------| +| niedrig | 0,85 | +| mittel / leer | 1,0 | +| hoch | 1,2 | + +### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“) + +Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0. + +- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (1–5) +- **Mittelpunkt** = durchschnittliche Stufe +- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20 + +Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1). + +### Bewusst nicht im Scoring + +| Feld | Grund | +|------|--------| +| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein | +| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt | + +Aggregation: + +- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**) +- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär) +- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht) +- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade +- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht) +- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs + +Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil. + +## API + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` | +| GET | `/api/training-modules/{id}/skill-profile` | `overall` | +| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` | +| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` | + +Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`). + +## UI + +- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session +- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“ +- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“ +- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche + +Profil wird nach **Speichern** neu geladen (`skillProfileTick`). + +## Grenzen / später + +- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index +- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring) +- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0 +- Trainings**einheiten** (Kalender) optional als nächste Erweiterung + +## Tests + +- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score diff --git a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md index d9d8663..312f74a 100644 --- a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md +++ b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md @@ -1,7 +1,7 @@ # Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap) **Stand:** 2026-05-20 -**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen +**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen ## Phase 1 (umgesetzt) @@ -29,23 +29,16 @@ **API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek. -## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch) +## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0) -### Ziel +**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md` -Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben). +- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph +- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge +- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite +- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche -### Variante A — Regelbasiert (ohne KI) - -1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln. -2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit). -3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten. -4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail. - -**Vorteil:** reproduzierbar, offline, Governance-konform. -**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“. - -### Variante B — KI-Zusammenfassung (OpenRouter, optional) +### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen) 1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen. 2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`). diff --git a/backend/main.py b/backend/main.py index 4fa97c7..d15db0b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports app.include_router(auth.router) app.include_router(profiles.router) @@ -208,6 +208,7 @@ app.include_router(media_assets.router) app.include_router(media_assets.admin_rights_router) app.include_router(media_assets.admin_legal_hold_router) app.include_router(skills.router) +app.include_router(skill_profiles.router) app.include_router(training_planning.router) app.include_router(dashboard.router) app.include_router(training_modules.router) diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py new file mode 100644 index 0000000..890ef6a --- /dev/null +++ b/backend/routers/skill_profiles.py @@ -0,0 +1,689 @@ +""" +Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte. + +GET …/skill-profile — gewichtetes Profil aus verknüpften Übungen. +GET /api/skill-discovery/suggestions — Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql + +from skill_scoring import ( + GRAPH_DEFAULT_ITEM_MINUTES, + ExerciseOccurrence, + batch_compute_profiles, + batch_framework_occurrences_by_id, + batch_module_occurrences_by_id, + collect_module_exercise_occurrences, + collect_progression_graph_exercise_occurrences, + collect_unit_exercise_occurrences, + compact_profile_summary, + compute_planning_corpus_by_type, + compute_club_corpus_reference, + compute_corpus_skill_max_weights, + compute_skill_profile, + corpus_for_artifact_type, + fetch_exercise_skills_bulk, + match_score_for_skill_ids, + profile_for_occurrences, + reference_scale_meta, + top_categories_summary, +) + +from routers.training_framework_programs import _framework_access +from routers.training_modules import _module_access +from routers.exercise_progression_graphs import _require_graph_read + +router = APIRouter(prefix="/api", tags=["skill_profiles"]) + + +def _parse_skill_ids_param(raw: Optional[str]) -> List[int]: + if not raw or not str(raw).strip(): + return [] + out: List[int] = [] + for part in str(raw).split(","): + part = part.strip() + if not part: + continue + try: + n = int(part) + except ValueError: + raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None + if n > 0 and n not in out: + out.append(n) + return out + + +@router.get("/training-framework-programs/{framework_id}/skill-profile") +def framework_program_skill_profile( + framework_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _framework_access(cur, framework_id, profile_id, role) + cur.execute( + """ + SELECT s.id, s.sort_order, s.title, + tu.id AS blueprint_unit_id + FROM training_framework_slots s + LEFT JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + ORDER BY s.sort_order + """, + (framework_id,), + ) + slots_raw = [r2d(r) for r in cur.fetchall()] + + bundle = _load_planning_corpus(cur, tenant) + tc = corpus_for_artifact_type(bundle, "framework_program") + ref_max = tc["max_by_skill"] + ref_by_skill = tc["ref_by_skill"] + + all_occurrences: List[ExerciseOccurrence] = [] + slot_profiles: List[Dict[str, Any]] = [] + + for slot in slots_raw: + uid = slot.get("blueprint_unit_id") + slot_occ: List[ExerciseOccurrence] = [] + slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}" + if uid: + raw_occ = collect_unit_exercise_occurrences(cur, int(uid)) + slot_occ = [ + ExerciseOccurrence( + exercise_id=o.exercise_id, + planned_duration_min=o.planned_duration_min, + context_label=slot_label, + ) + for o in raw_occ + ] + all_occurrences.extend(slot_occ) + else: + slot_occ = [] + slot_profile = ( + profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max) + if slot_occ + else _empty_profile() + ) + slot_profiles.append( + { + "slot_id": slot["id"], + "slot_title": slot.get("title"), + "sort_order": slot.get("sort_order"), + "blueprint_training_unit_id": uid, + "exercise_occurrence_count": len(slot_occ), + "profile": slot_profile, + } + ) + + overall = ( + profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max) + if all_occurrences + else _empty_profile() + ) + _enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id) + for slot in slot_profiles: + _enrich_profile_club_best( + slot.get("profile") or {}, + ref_by_skill, + "framework_program", + framework_id, + ) + + return { + "artifact_type": "framework_program", + "artifact_id": framework_id, + "artifact_title": row.get("title"), + "reference_scale": reference_scale_meta( + tc, "framework_program", effective_club_id=tenant.effective_club_id + ), + "club_best_by_skill": { + str(k): v for k, v in ref_by_skill.items() + }, + "overall": overall, + "slots": slot_profiles, + } + + +@router.get("/training-modules/{module_id}/skill-profile") +def training_module_skill_profile( + module_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _module_access(cur, module_id, profile_id, role) + bundle = _load_planning_corpus(cur, tenant) + tc = corpus_for_artifact_type(bundle, "training_module") + ref_max = tc["max_by_skill"] + ref_by_skill = tc["ref_by_skill"] + occurrences = collect_module_exercise_occurrences(cur, module_id) + overall = ( + profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max) + if occurrences + else _empty_profile() + ) + _enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id) + return { + "artifact_type": "training_module", + "artifact_id": module_id, + "artifact_title": row.get("title"), + "reference_scale": reference_scale_meta( + tc, "training_module", effective_club_id=tenant.effective_club_id + ), + "club_best_by_skill": { + str(k): v for k, v in ref_by_skill.items() + }, + "overall": overall, + } + + +@router.get("/exercise-progression-graphs/{graph_id}/skill-profile") +def progression_graph_skill_profile( + graph_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _require_graph_read(cur, graph_id, profile_id, role) + bundle = _load_planning_corpus(cur, tenant) + tc = corpus_for_artifact_type(bundle, "progression_graph") + ref_max = tc["max_by_skill"] + ref_by_skill = tc["ref_by_skill"] + occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) + overall = ( + profile_for_occurrences( + cur, + occurrences, + default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES, + reference_max_by_skill=ref_max, + ) + if occurrences + else _empty_profile() + ) + _enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id) + return { + "artifact_type": "progression_graph", + "artifact_id": graph_id, + "artifact_title": row.get("name"), + "reference_scale": reference_scale_meta( + tc, "progression_graph", effective_club_id=tenant.effective_club_id + ), + "club_best_by_skill": { + str(k): v for k, v in ref_by_skill.items() + }, + "overall": overall, + } + + +@router.post("/skill-profiles/batch-summaries") +def batch_skill_profile_summaries( + data: dict, + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL). + Body: { framework_program_ids?: number[], training_module_ids?: number[] } + """ + fp_ids = _parse_id_list(data.get("framework_program_ids")) + mod_ids = _parse_id_list(data.get("training_module_ids")) + if not fp_ids and not mod_ids: + raise HTTPException( + status_code=400, + detail="framework_program_ids oder training_module_ids erforderlich", + ) + + profile_id = tenant.profile_id + role = tenant.global_role + summaries: Dict[str, Dict[str, Any]] = {} + + with get_db() as conn: + cur = get_cursor(conn) + bundle = compute_planning_corpus_by_type( + cur, + profile_id=tenant.profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + include_artifact_summaries=True, + ) + + allowed_fp: List[int] = [] + if fp_ids: + for fid in fp_ids: + try: + _framework_access(cur, fid, profile_id, role) + allowed_fp.append(fid) + except HTTPException: + pass + + allowed_mod: List[int] = [] + if mod_ids: + for mid in mod_ids: + try: + _module_access(cur, mid, profile_id, role) + allowed_mod.append(mid) + except HTTPException: + pass + + summaries = _merge_batch_summaries( + cur, + bundle=bundle, + allowed_fp=allowed_fp, + allowed_mod=allowed_mod, + ) + ref_by_skill = {} + for t in ("framework_program", "training_module", "progression_graph"): + ref_by_skill.update(corpus_for_artifact_type(bundle, t).get("ref_by_skill") or {}) + + skill_ids_seen: set[int] = set() + for summary in summaries.values(): + for sk in summary.get("skills") or []: + if sk.get("skill_id") is not None: + skill_ids_seen.add(int(sk["skill_id"])) + + club_best_subset = { + str(sid): ref_by_skill[sid] + for sid in skill_ids_seen + if sid in ref_by_skill + } + + return { + "reference_scale_by_type": { + t: reference_scale_meta( + corpus_for_artifact_type(bundle, t), + t, + effective_club_id=tenant.effective_club_id, + ) + for t in ("framework_program", "training_module", "progression_graph") + }, + "club_best_by_skill": club_best_subset, + "summaries": summaries, + } + + +@router.get("/skill-discovery/suggestions") +def skill_discovery_suggestions( + skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"), + types: Optional[str] = Query( + default="framework_program,training_module,progression_graph", + description="Artefakttypen, komma-getrennt", + ), + limit: int = Query(default=20, ge=1, le=50), + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt. + """ + wanted = _parse_skill_ids_param(skill_ids) + if not wanted: + raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)") + + type_set = {t.strip() for t in (types or "").split(",") if t.strip()} + profile_id = tenant.profile_id + role = tenant.global_role + results: List[Dict[str, Any]] = [] + + with get_db() as conn: + cur = get_cursor(conn) + planning_bundle = _load_planning_corpus(cur, tenant) + fw_ref = corpus_for_artifact_type(planning_bundle, "framework_program")["max_by_skill"] + mod_ref = corpus_for_artifact_type(planning_bundle, "training_module")["max_by_skill"] + graph_ref = corpus_for_artifact_type(planning_bundle, "progression_graph")["max_by_skill"] + + if "framework_program" in type_set: + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT fp.id, fp.title + FROM training_framework_programs fp + WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST + LIMIT 80 + """, + vis_params, + ) + for fp_row in cur.fetchall(): + fid = int(fp_row["id"]) + try: + _framework_access(cur, fid, profile_id, role) + except HTTPException: + continue + cur.execute( + """ + SELECT tu.id + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + """, + (fid,), + ) + occ: List[ExerciseOccurrence] = [] + for u in cur.fetchall(): + occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) + if not occ: + continue + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=fw_ref) + match = match_score_for_skill_ids(prof, wanted) + if match["match_weight"] <= 0: + continue + results.append( + { + "artifact_type": "framework_program", + "artifact_id": fid, + "artifact_title": fp_row["title"], + "path": f"/planning/framework-programs/{fid}", + "match": match, + "skill_profile_summary": { + "total_score": prof.get("total_score"), + "top_by_category": top_categories_summary(prof), + }, + } + ) + + if "training_module" in type_set: + vis_clause, vis_params = library_content_visibility_sql( + alias="m", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT m.id, m.title + FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST + LIMIT 80 + """, + vis_params, + ) + for m_row in cur.fetchall(): + mid = int(m_row["id"]) + try: + _module_access(cur, mid, profile_id, role) + except HTTPException: + continue + occ = collect_module_exercise_occurrences(cur, mid) + if not occ: + continue + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=mod_ref) + match = match_score_for_skill_ids(prof, wanted) + if match["match_weight"] <= 0: + continue + results.append( + { + "artifact_type": "training_module", + "artifact_id": mid, + "artifact_title": m_row["title"], + "path": f"/planning/training-modules/{mid}", + "match": match, + "skill_profile_summary": { + "total_score": prof.get("total_score"), + "top_by_category": top_categories_summary(prof), + }, + } + ) + + if "progression_graph" in type_set: + vis_clause, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT g.id, g.name + FROM exercise_progression_graphs g + WHERE ({vis_clause}) + ORDER BY g.updated_at DESC NULLS LAST + LIMIT 80 + """, + vis_params, + ) + for g_row in cur.fetchall(): + gid = int(g_row["id"]) + try: + _require_graph_read(cur, gid, profile_id, role) + except HTTPException: + continue + occ = collect_progression_graph_exercise_occurrences(cur, gid) + if not occ: + continue + prof = profile_for_occurrences( + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES, + reference_max_by_skill=graph_ref, + ) + match = match_score_for_skill_ids(prof, wanted) + if match["match_weight"] <= 0: + continue + results.append( + { + "artifact_type": "progression_graph", + "artifact_id": gid, + "artifact_title": g_row["name"], + "path": None, + "match": match, + "skill_profile_summary": { + "total_score": prof.get("total_score"), + "top_by_category": top_categories_summary(prof), + }, + } + ) + + results.sort( + key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0), + ) + return { + "skill_ids": wanted, + "types": sorted(type_set), + "suggestions": results[:limit], + } + + +def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]: + """Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer.""" + out: List[Dict[str, Any]] = [] + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if not top: + continue + out.append( + { + "main_category_name": mc.get("main_category_name"), + "category_name": cat.get("category_name"), + "skill_id": top.get("skill_id"), + "skill_name": top.get("skill_name"), + "score": top.get("score") or top.get("weight"), + } + ) + if len(out) >= limit: + return out + return out + + +def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]: + if not raw: + return [] + if not isinstance(raw, list): + raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein") + out: List[int] = [] + for item in raw: + try: + n = int(item) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None + if n > 0 and n not in out: + out.append(n) + if len(out) >= max_count: + break + return out + + +def _load_planning_corpus(cur, tenant: TenantContext) -> Dict[str, Any]: + return compute_planning_corpus_by_type( + cur, + profile_id=tenant.profile_id, + role=tenant.global_role, + effective_club_id=tenant.effective_club_id, + ) + + +def _enrich_profile_club_best( + profile: Dict[str, Any], + ref_by_skill: Dict[int, Dict[str, Any]], + artifact_type: Optional[str] = None, + artifact_id: Optional[int] = None, +) -> None: + """Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze).""" + if not profile or not ref_by_skill: + return + + def attach(sk: Optional[Dict[str, Any]]) -> None: + if not sk or sk.get("skill_id") is None: + return + sid = int(sk["skill_id"]) + ref = ref_by_skill.get(sid) + if not ref: + return + if ( + artifact_type + and artifact_id is not None + and ref.get("artifact_type") == artifact_type + and int(ref.get("artifact_id") or 0) == int(artifact_id) + ): + return + w = float(sk.get("weight") or 0) + if w < float(ref.get("weight") or 0) - 0.01: + sk["club_best"] = ref + + for sk in profile.get("skills") or []: + attach(sk) + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + attach(cat.get("top_skill")) + + +def _empty_profile() -> Dict[str, Any]: + return compute_skill_profile([], {}) + + +def _summarize_framework_program( + cur, + framework_id: int, + ref_max: Dict[int, float], + ref_by_skill: Dict[int, Dict[str, Any]], +) -> Dict[str, Any]: + cur.execute( + """ + SELECT tu.id + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s + """, + (int(framework_id),), + ) + occ: List[ExerciseOccurrence] = [] + for u in cur.fetchall(): + occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) + prof = ( + profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max) + if occ + else _empty_profile() + ) + _enrich_profile_club_best(prof, ref_by_skill, "framework_program", int(framework_id)) + return compact_profile_summary(prof, ref_by_skill) + + +def _summarize_training_module( + cur, + module_id: int, + ref_max: Dict[int, float], + ref_by_skill: Dict[int, Dict[str, Any]], +) -> Dict[str, Any]: + occ = collect_module_exercise_occurrences(cur, int(module_id)) + prof = ( + profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max) + if occ + else _empty_profile() + ) + _enrich_profile_club_best(prof, ref_by_skill, "training_module", int(module_id)) + return compact_profile_summary(prof, ref_by_skill) + + +def _merge_batch_summaries( + cur, + *, + bundle: Dict[str, Any], + allowed_fp: List[int], + allowed_mod: List[int], +) -> Dict[str, Dict[str, Any]]: + """Summaries für angeforderte IDs — Referenz je Planungs-Kontext (Typ getrennt).""" + fw_tc = corpus_for_artifact_type(bundle, "framework_program") + mod_tc = corpus_for_artifact_type(bundle, "training_module") + fw_cached = fw_tc.get("artifact_summaries") or {} + mod_cached = mod_tc.get("artifact_summaries") or {} + out: Dict[str, Dict[str, Any]] = {} + + fw_ref_max = fw_tc["max_by_skill"] + fw_ref_by = fw_tc["ref_by_skill"] + missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in fw_cached] + if missing_fp: + occ_map = batch_framework_occurrences_by_id(cur, missing_fp) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles( + occ_map, skills_map, reference_max_by_skill=fw_ref_max + ) + for fid in missing_fp: + key = f"framework_program:{fid}" + prof = profiles.get(fid) or _empty_profile() + _enrich_profile_club_best(prof, fw_ref_by, "framework_program", fid) + out[key] = compact_profile_summary(prof, fw_ref_by) + + mod_ref_max = mod_tc["max_by_skill"] + mod_ref_by = mod_tc["ref_by_skill"] + missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in mod_cached] + if missing_mod: + occ_map = batch_module_occurrences_by_id(cur, missing_mod) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles( + occ_map, skills_map, reference_max_by_skill=mod_ref_max + ) + for mid in missing_mod: + key = f"training_module:{mid}" + prof = profiles.get(mid) or _empty_profile() + _enrich_profile_club_best(prof, mod_ref_by, "training_module", mid) + out[key] = compact_profile_summary(prof, mod_ref_by) + + for fid in allowed_fp: + key = f"framework_program:{fid}" + if key in fw_cached: + out[key] = fw_cached[key] + elif key not in out: + out[key] = _summarize_framework_program(cur, fid, fw_ref_max, fw_ref_by) + + for mid in allowed_mod: + key = f"training_module:{mid}" + if key in mod_cached: + out[key] = mod_cached[key] + elif key not in out: + out[key] = _summarize_training_module(cur, mid, mod_ref_max, mod_ref_by) + + return out diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py new file mode 100644 index 0000000..972b55f --- /dev/null +++ b/backend/skill_scoring.py @@ -0,0 +1,985 @@ +""" +Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert). + +Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus: +geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis). + +is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist +in der UI nicht gepflegt und wird ignoriert. +""" +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +DEFAULT_ITEM_MINUTES = 8 +GRAPH_DEFAULT_ITEM_MINUTES = 10 + +_INTENSITY_MULT = { + "niedrig": 0.85, + "low": 0.85, + "mittel": 1.0, + "medium": 1.0, + "hoch": 1.2, + "high": 1.2, +} + +# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js +_LEVEL_RANK = { + "basis": 1, + "grundlagen": 2, + "aufbau": 3, + "fortgeschritten": 4, + "optimierung": 5, + "einsteiger": 1, + "experte": 5, + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, +} + + +def _level_rank(value: Optional[str]) -> Optional[int]: + if value is None: + return None + key = str(value).strip().lower() + if not key: + return None + rank = _LEVEL_RANK.get(key) + return rank if rank is not None else None + + +def _level_range_multiplier( + required_level: Optional[str] = None, + target_level: Optional[str] = None, +) -> float: + """ + Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen → etwas höheres Gewicht. + Fehlen beide Angaben: neutral (1.0). + """ + rr = _level_rank(required_level) + rt = _level_rank(target_level) + if rr is None and rt is None: + return 1.0 + if rr is None: + rr = rt + if rt is None: + rt = rr + if rr > rt: + rr, rt = rt, rr + span = max(1, min(5, rt - rr + 1)) + midpoint = (rr + rt) / 2.0 + span_mult = 0.92 + 0.04 * span + depth_mult = 0.95 + 0.025 * midpoint + return span_mult * depth_mult + + +@dataclass(frozen=True) +class ExerciseOccurrence: + exercise_id: int + planned_duration_min: Optional[int] = None + """Optional label for UI (e.g. slot title).""" + context_label: Optional[str] = None + + +def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUTES) -> float: + if planned is not None: + try: + m = int(planned) + if m > 0: + return float(m) + except (TypeError, ValueError): + pass + return float(default) + + +def _skill_link_multiplier( + *, + intensity: Optional[str] = None, + required_level: Optional[str] = None, + target_level: Optional[str] = None, +) -> float: + mult = 1.0 + if intensity: + key = str(intensity).strip().lower() + mult *= _INTENSITY_MULT.get(key, 1.0) + mult *= _level_range_multiplier(required_level, target_level) + return mult + + +def _round2(val: float) -> float: + return round(val, 2) + + +def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht.""" + main_map: Dict[int, Dict[str, Any]] = {} + + for sk in skills_out: + mc_id = int(sk.get("main_category_id") or 0) + mc_name = (sk.get("main_category_name") or "").strip() or "—" + cat_id = int(sk.get("category_id") or 0) + cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or "—" + + if mc_id not in main_map: + main_map[mc_id] = { + "main_category_id": mc_id if mc_id else None, + "main_category_name": mc_name, + "weight": 0.0, + "categories": {}, + } + main = main_map[mc_id] + main["weight"] += float(sk.get("weight") or 0) + + if cat_id not in main["categories"]: + main["categories"][cat_id] = { + "category_id": cat_id if cat_id else None, + "category_name": cat_name, + "weight": 0.0, + "skills": [], + } + cat = main["categories"][cat_id] + cat["weight"] += float(sk.get("weight") or 0) + cat["skills"].append(sk) + + result: List[Dict[str, Any]] = [] + for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")): + cats_out: List[Dict[str, Any]] = [] + for cat in sorted( + mc["categories"].values(), + key=lambda x: (-x["weight"], x.get("category_name") or ""), + ): + cat_skills = sorted( + cat["skills"], + key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""), + ) + top = cat_skills[0] if cat_skills else None + cats_out.append( + { + "category_id": cat["category_id"], + "category_name": cat["category_name"], + "weight": _round2(cat["weight"]), + "skills_count": len(cat_skills), + "top_skill": top, + } + ) + result.append( + { + "main_category_id": mc["main_category_id"], + "main_category_name": mc["main_category_name"], + "weight": _round2(mc["weight"]), + "categories": cats_out, + } + ) + return result + + +def _club_universal_percent( + weight: float, + corpus_ref: float, +) -> tuple[Optional[float], bool]: + """ + Anteil am Vereins-Maximum (max. 100 %). + effective_ref = max(Korpus-Max, eigenes Gewicht) — verhindert Werte >100 %, + wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official). + """ + w = float(weight or 0) + ref = float(corpus_ref or 0) + if w <= 0: + return None, False + effective_ref = max(ref, w) + pct = min(100.0, w / effective_ref * 100.0) + is_best = ref <= 0 or w >= ref - 0.01 + return _round2(pct), is_best + + +def _apply_reference_universal_percent( + skills_out: List[Dict[str, Any]], + reference_max_by_skill: Optional[Dict[int, float]] = None, +) -> None: + """ + Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %). + """ + if not reference_max_by_skill: + for sk in skills_out: + sk["universal_percent"] = None + sk["is_club_best_for_skill"] = False + return + for sk in skills_out: + sid = int(sk["skill_id"]) + ref = float(reference_max_by_skill.get(sid) or 0) + w = float(sk.get("weight") or 0) + pct, is_best = _club_universal_percent(w, ref) + sk["universal_percent"] = pct + sk["is_club_best_for_skill"] = is_best + + +def compute_skill_profile( + occurrences: Sequence[ExerciseOccurrence], + skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]], + *, + default_item_minutes: int = DEFAULT_ITEM_MINUTES, + reference_max_by_skill: Optional[Dict[int, float]] = None, +) -> Dict[str, Any]: + """ + Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills. + """ + exercise_meta: Dict[int, Dict[str, Any]] = defaultdict( + lambda: {"occurrence_count": 0, "minutes": 0.0, "context_labels": []} + ) + total_occurrences = 0 + for occ in occurrences or []: + eid = int(occ.exercise_id) + mins = _item_base_minutes(occ.planned_duration_min, default_item_minutes) + exercise_meta[eid]["occurrence_count"] += 1 + exercise_meta[eid]["minutes"] += mins + total_occurrences += 1 + if occ.context_label and occ.context_label not in exercise_meta[eid]["context_labels"]: + exercise_meta[eid]["context_labels"].append(occ.context_label) + + skill_acc: Dict[int, Dict[str, Any]] = {} + total_weight = 0.0 + exercises_with_skills: set[int] = set() + + for eid, meta in exercise_meta.items(): + links = skill_rows_by_exercise.get(eid) or [] + if not links: + continue + exercises_with_skills.add(eid) + occ_count = meta["occurrence_count"] + minutes_per_occ = meta["minutes"] / occ_count if occ_count else float(default_item_minutes) + + for link in links: + sid = link.get("skill_id") + if sid is None: + continue + sid = int(sid) + link_mult = _skill_link_multiplier( + intensity=link.get("intensity"), + required_level=link.get("required_level"), + target_level=link.get("target_level"), + ) + contribution = minutes_per_occ * occ_count * link_mult + if contribution <= 0: + continue + + if sid not in skill_acc: + skill_acc[sid] = { + "skill_id": sid, + "skill_name": link.get("skill_name") or f"Fähigkeit #{sid}", + "category": link.get("category_name") or link.get("category"), + "category_id": link.get("category_id"), + "category_name": link.get("category_name") or link.get("category"), + "main_category_id": link.get("main_category_id"), + "main_category_name": link.get("main_category_name"), + "focus_areas": link.get("focus_areas"), + "weight": 0.0, + "occurrence_count": 0, + "exercises": {}, + } + acc = skill_acc[sid] + acc["weight"] += contribution + acc["occurrence_count"] += occ_count + ex_key = str(eid) + if ex_key not in acc["exercises"]: + acc["exercises"][ex_key] = { + "exercise_id": eid, + "title": link.get("exercise_title") or f"Übung #{eid}", + "weight": 0.0, + "occurrence_count": occ_count, + } + acc["exercises"][ex_key]["weight"] += contribution + total_weight += contribution + + skills_out: List[Dict[str, Any]] = [] + for sid, acc in skill_acc.items(): + share = (acc["weight"] / total_weight * 100.0) if total_weight > 0 else 0.0 + ex_list = sorted( + acc["exercises"].values(), + key=lambda x: (-x["weight"], x.get("title") or ""), + )[:8] + for ex in ex_list: + ex["weight"] = _round2(ex["weight"]) + if total_weight > 0: + ex["share_percent"] = _round2(ex["weight"] / total_weight * 100.0) + else: + ex["share_percent"] = 0.0 + skills_out.append( + { + "skill_id": sid, + "skill_name": acc["skill_name"], + "category": acc.get("category_name") or acc.get("category"), + "category_id": acc.get("category_id"), + "category_name": acc.get("category_name") or acc.get("category"), + "main_category_id": acc.get("main_category_id"), + "main_category_name": acc.get("main_category_name"), + "focus_areas": acc.get("focus_areas"), + "weight": _round2(acc["weight"]), + "score": _round2(acc["weight"]), + "artifact_share_percent": _round2(share), + "share_percent": _round2(share), + "occurrence_count": acc["occurrence_count"], + "top_exercises": ex_list, + } + ) + skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or "")) + + _apply_reference_universal_percent(skills_out, reference_max_by_skill) + + by_main_category = _build_by_main_category(skills_out) + for mc in by_main_category: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if top and reference_max_by_skill: + sid = int(top["skill_id"]) + ref = float(reference_max_by_skill.get(sid) or 0) + pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref) + top["universal_percent"] = pct + top["is_club_best_for_skill"] = is_best + + unique_exercises = len(exercise_meta) + return { + "computed_at": datetime.now(timezone.utc).isoformat(), + "scoring_version": "1.2", + "score_unit": "weighted_minutes", + "score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)", + "total_weight": _round2(total_weight), + "total_score": _round2(total_weight), + "exercise_occurrence_count": total_occurrences, + "distinct_exercise_count": unique_exercises, + "exercises_with_skills_count": len(exercises_with_skills), + "skills": skills_out, + "by_main_category": by_main_category, + "has_reference_scale": bool(reference_max_by_skill), + } + + +def fetch_exercise_skills_bulk( + cur, exercise_ids: Iterable[int] +) -> Dict[int, List[Dict[str, Any]]]: + ids = sorted({int(x) for x in exercise_ids if x}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity, + es.development_contribution, es.required_level, es.target_level, + s.name AS skill_name, s.category, + sc.id AS category_id, sc.name AS category_name, + mc.id AS main_category_id, mc.name AS main_category_name, + s.focus_areas, + e.title AS exercise_title + FROM exercise_skills es + JOIN skills s ON s.id = es.skill_id + LEFT JOIN skill_categories sc ON sc.id = s.category_id + LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id) + JOIN exercises e ON e.id = es.exercise_id + WHERE es.exercise_id IN ({ph}) + AND (s.status = 'active' OR s.status IS NULL) + ORDER BY es.exercise_id, s.name, es.skill_id + """, + ids, + ) + out: Dict[int, List[Dict[str, Any]]] = defaultdict(list) + for row in cur.fetchall(): + d = dict(row) + eid = int(d["exercise_id"]) + fa = d.get("focus_areas") + if fa is not None and not isinstance(fa, list): + try: + import json + + fa = json.loads(fa) if isinstance(fa, str) else fa + except Exception: + fa = [] + d["focus_areas"] = fa if isinstance(fa, list) else [] + out[eid].append(d) + return dict(out) + + +def collect_unit_exercise_occurrences(cur, unit_id: int) -> List[ExerciseOccurrence]: + cur.execute( + """ + SELECT tusi.exercise_id, tusi.planned_duration_min + FROM training_unit_section_items tusi + INNER JOIN training_unit_sections tus ON tus.id = tusi.section_id + WHERE tus.training_unit_id = %s + AND tusi.item_type = 'exercise' + AND tusi.exercise_id IS NOT NULL + ORDER BY tus.order_index, tusi.order_index + """, + (int(unit_id),), + ) + return [ + ExerciseOccurrence( + exercise_id=int(r["exercise_id"]), + planned_duration_min=r.get("planned_duration_min"), + ) + for r in cur.fetchall() + ] + + +def collect_module_exercise_occurrences(cur, module_id: int) -> List[ExerciseOccurrence]: + cur.execute( + """ + SELECT exercise_id, planned_duration_min + FROM training_module_items + WHERE module_id = %s + AND item_type = 'exercise' + AND exercise_id IS NOT NULL + ORDER BY order_index + """, + (int(module_id),), + ) + return [ + ExerciseOccurrence( + exercise_id=int(r["exercise_id"]), + planned_duration_min=r.get("planned_duration_min"), + ) + for r in cur.fetchall() + ] + + +def collect_progression_graph_exercise_occurrences(cur, graph_id: int) -> List[ExerciseOccurrence]: + """Jedes Vorkommen als from- oder to-Endpunkt einer Kante zählt (ohne Dauer → Default).""" + cur.execute( + """ + SELECT from_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s + UNION ALL + SELECT to_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s + """, + (int(graph_id), int(graph_id)), + ) + return [ + ExerciseOccurrence( + exercise_id=int(r["exercise_id"]), + planned_duration_min=None, + context_label=None, + ) + for r in cur.fetchall() + ] + + +def profile_for_occurrences( + cur, + occurrences: Sequence[ExerciseOccurrence], + *, + default_item_minutes: int = DEFAULT_ITEM_MINUTES, + reference_max_by_skill: Optional[Dict[int, float]] = None, +) -> Dict[str, Any]: + eids = [o.exercise_id for o in occurrences] + skills_map = fetch_exercise_skills_bulk(cur, eids) + return compute_skill_profile( + occurrences, + skills_map, + default_item_minutes=default_item_minutes, + reference_max_by_skill=reference_max_by_skill, + ) + + +def merge_skill_weights_into_max( + target: Dict[int, float], + profile: Dict[str, Any], +) -> None: + for sk in profile.get("skills") or []: + sid = int(sk["skill_id"]) + w = float(sk.get("weight") or 0) + if w > target.get(sid, 0.0): + target[sid] = w + + +def merge_skill_weights_with_reference( + max_by_skill: Dict[int, float], + ref_by_skill: Dict[int, Dict[str, Any]], + profile: Dict[str, Any], + *, + artifact_type: str, + artifact_id: int, + artifact_title: Optional[str] = None, +) -> None: + """Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt.""" + for sk in profile.get("skills") or []: + sid = int(sk["skill_id"]) + w = float(sk.get("weight") or 0) + if w <= 0: + continue + if w > max_by_skill.get(sid, 0.0): + max_by_skill[sid] = w + ref_by_skill[sid] = { + "artifact_type": artifact_type, + "artifact_id": int(artifact_id), + "artifact_title": (artifact_title or "").strip() or None, + "weight": _round2(w), + } + + +def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]: + """Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery).""" + out: List[Dict[str, Any]] = [] + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if not top: + continue + out.append( + { + "main_category_name": mc.get("main_category_name"), + "category_name": cat.get("category_name"), + "skill_id": top.get("skill_id"), + "skill_name": top.get("skill_name"), + "score": top.get("score") or top.get("weight"), + "weight": top.get("weight"), + "universal_percent": top.get("universal_percent"), + "is_club_best_for_skill": top.get("is_club_best_for_skill"), + } + ) + if len(out) >= limit: + return out + return out + + +def _apply_reference_to_profile( + profile: Dict[str, Any], + reference_max_by_skill: Optional[Dict[int, float]], +) -> None: + _apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill) + profile["by_main_category"] = _build_by_main_category(profile.get("skills") or []) + for mc in profile.get("by_main_category") or []: + for cat in mc.get("categories") or []: + top = cat.get("top_skill") + if top and reference_max_by_skill: + sid = int(top["skill_id"]) + ref = float(reference_max_by_skill.get(sid) or 0) + pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref) + top["universal_percent"] = pct + top["is_club_best_for_skill"] = is_best + profile["has_reference_scale"] = bool(reference_max_by_skill) + + +def compact_profile_summary( + profile: Dict[str, Any], + ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None, + *, + skills_limit: int = 0, + category_limit: int = 48, +) -> Dict[str, Any]: + """Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten.""" + skills_out: List[Dict[str, Any]] = [] + for s in profile.get("skills") or []: + sid = int(s["skill_id"]) + w = float(s.get("weight") or 0) + entry: Dict[str, Any] = { + "skill_id": sid, + "skill_name": s.get("skill_name"), + "category_name": s.get("category_name") or s.get("category"), + "main_category_name": s.get("main_category_name"), + "weight": s.get("weight"), + "score": s.get("score") or s.get("weight"), + "universal_percent": s.get("universal_percent"), + } + ref = (ref_by_skill or {}).get(sid) + if ref and w < float(ref.get("weight") or 0) - 0.01: + entry["club_best"] = ref + if s.get("is_club_best_for_skill"): + entry["is_club_best_for_skill"] = True + skills_out.append(entry) + if skills_limit > 0 and len(skills_out) >= skills_limit: + break + return { + "total_score": profile.get("total_score"), + "total_weight": profile.get("total_weight"), + "exercise_occurrence_count": profile.get("exercise_occurrence_count"), + "skills_count": len(profile.get("skills") or []), + "top_by_category": top_categories_summary(profile, limit=category_limit), + "skills": skills_out, + } + + +def batch_framework_occurrences_by_id( + cur, framework_ids: Sequence[int] +) -> Dict[int, List[ExerciseOccurrence]]: + ids = sorted({int(x) for x in framework_ids if x}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT s.framework_program_id, + COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label, + tusi.exercise_id, + tusi.planned_duration_min + FROM training_framework_slots s + INNER JOIN training_units tu ON tu.framework_slot_id = s.id + INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id + INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id + WHERE s.framework_program_id IN ({ph}) + AND tusi.item_type = 'exercise' + AND tusi.exercise_id IS NOT NULL + ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index + """, + ids, + ) + out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list) + for row in cur.fetchall(): + fid = int(row["framework_program_id"]) + out[fid].append( + ExerciseOccurrence( + exercise_id=int(row["exercise_id"]), + planned_duration_min=row.get("planned_duration_min"), + context_label=row.get("slot_label"), + ) + ) + return dict(out) + + +def batch_module_occurrences_by_id( + cur, module_ids: Sequence[int] +) -> Dict[int, List[ExerciseOccurrence]]: + ids = sorted({int(x) for x in module_ids if x}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT module_id, exercise_id, planned_duration_min + FROM training_module_items + WHERE module_id IN ({ph}) + AND item_type = 'exercise' + AND exercise_id IS NOT NULL + ORDER BY module_id, order_index + """, + ids, + ) + out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list) + for row in cur.fetchall(): + mid = int(row["module_id"]) + out[mid].append( + ExerciseOccurrence( + exercise_id=int(row["exercise_id"]), + planned_duration_min=row.get("planned_duration_min"), + ) + ) + return dict(out) + + +def batch_compute_profiles( + occ_by_artifact: Dict[int, List[ExerciseOccurrence]], + skills_map: Dict[int, List[Dict[str, Any]]], + *, + reference_max_by_skill: Optional[Dict[int, float]] = None, + default_item_minutes: int = DEFAULT_ITEM_MINUTES, +) -> Dict[int, Dict[str, Any]]: + return { + aid: compute_skill_profile( + occ, + skills_map, + default_item_minutes=default_item_minutes, + reference_max_by_skill=reference_max_by_skill, + ) + for aid, occ in occ_by_artifact.items() + } + + +def _empty_type_corpus() -> Dict[str, Any]: + return { + "max_by_skill": {}, + "ref_by_skill": {}, + "artifact_count": 0, + "artifact_summaries": {}, + } + + +def corpus_for_artifact_type( + bundle: Dict[str, Any], + artifact_type: str, +) -> Dict[str, Any]: + by_type = bundle.get("by_type") or {} + return by_type.get(artifact_type) or _empty_type_corpus() + + +def _scan_artifact_type_corpus( + cur, + *, + artifact_type: str, + profile_id: int, + role: Optional[str], + effective_club_id: Optional[int], + include_artifact_summaries: bool, +) -> Dict[str, Any]: + """Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek).""" + from tenant_context import library_content_visibility_sql + + max_by_skill: Dict[int, float] = {} + ref_by_skill: Dict[int, Dict[str, Any]] = {} + artifact_count = 0 + raw_profiles: Dict[str, Dict[str, Any]] = {} + + def ingest(aid: int, title: Optional[str], prof: Dict[str, Any]) -> None: + nonlocal artifact_count + if not prof.get("skills"): + return + artifact_count += 1 + merge_skill_weights_with_reference( + max_by_skill, + ref_by_skill, + prof, + artifact_type=artifact_type, + artifact_id=aid, + artifact_title=title, + ) + if include_artifact_summaries: + raw_profiles[f"{artifact_type}:{aid}"] = prof + + if artifact_type == "framework_program": + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role or "", + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT fp.id, fp.title + FROM training_framework_programs fp + WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST + """, + vis_params, + ) + rows = cur.fetchall() + if rows: + ids = [int(r["id"]) for r in rows] + titles = {int(r["id"]): r.get("title") for r in rows} + occ_map = batch_framework_occurrences_by_id(cur, ids) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles(occ_map, skills_map) + for aid, prof in profiles.items(): + ingest(aid, titles.get(aid), prof) + + elif artifact_type == "training_module": + vis_clause, vis_params = library_content_visibility_sql( + alias="m", + profile_id=profile_id, + role=role or "", + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT m.id, m.title + FROM training_modules m + WHERE ({vis_clause}) + ORDER BY m.updated_at DESC NULLS LAST + """, + vis_params, + ) + rows = cur.fetchall() + if rows: + ids = [int(r["id"]) for r in rows] + titles = {int(r["id"]): r.get("title") for r in rows} + occ_map = batch_module_occurrences_by_id(cur, ids) + all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} + skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} + profiles = batch_compute_profiles(occ_map, skills_map) + for aid, prof in profiles.items(): + ingest(aid, titles.get(aid), prof) + + elif artifact_type == "progression_graph": + vis_clause, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role or "", + effective_club_id=effective_club_id, + ) + cur.execute( + f""" + SELECT g.id, g.name + FROM exercise_progression_graphs g + WHERE ({vis_clause}) + ORDER BY g.updated_at DESC NULLS LAST + """, + vis_params, + ) + for row in cur.fetchall(): + gid = int(row["id"]) + occ = collect_progression_graph_exercise_occurrences(cur, gid) + if not occ: + continue + prof = profile_for_occurrences( + cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES + ) + ingest(gid, row.get("name"), prof) + + artifact_summaries: Dict[str, Dict[str, Any]] = {} + if include_artifact_summaries and raw_profiles: + for key, prof in raw_profiles.items(): + _apply_reference_to_profile(prof, max_by_skill) + artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill) + + return { + "max_by_skill": max_by_skill, + "ref_by_skill": ref_by_skill, + "artifact_count": artifact_count, + "artifact_summaries": artifact_summaries, + } + + +def compute_planning_corpus_by_type( + cur, + *, + profile_id: int, + role: Optional[str], + effective_club_id: Optional[int], + include_artifact_summaries: bool = False, +) -> Dict[str, Any]: + """ + Referenz je Fähigkeit getrennt nach Planungs-Kontext: + Rahmenprogramme, Trainingsmodule und Regressionspfade jeweils für sich, + jeweils über die sichtbare Bibliothek (library_content_visibility_sql). + """ + by_type = { + "framework_program": _scan_artifact_type_corpus( + cur, + artifact_type="framework_program", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ), + "training_module": _scan_artifact_type_corpus( + cur, + artifact_type="training_module", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ), + "progression_graph": _scan_artifact_type_corpus( + cur, + artifact_type="progression_graph", + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ), + } + return { + "effective_club_id": effective_club_id, + "by_type": by_type, + } + + +def compute_club_corpus_reference( + cur, + *, + profile_id: int, + effective_club_id: Optional[int], + include_artifact_summaries: bool = False, + role: Optional[str] = None, +) -> Dict[str, Any]: + """Legacy-Hülle — merged summaries über alle Typen (vermeiden für neue Aufrufer).""" + bundle = compute_planning_corpus_by_type( + cur, + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + include_artifact_summaries=include_artifact_summaries, + ) + tc = corpus_for_artifact_type(bundle, "framework_program") + merged_summaries: Dict[str, Dict[str, Any]] = {} + for t in ("framework_program", "training_module", "progression_graph"): + merged_summaries.update((bundle["by_type"][t].get("artifact_summaries") or {})) + return { + "club_id": effective_club_id, + "max_by_skill": tc["max_by_skill"], + "ref_by_skill": tc["ref_by_skill"], + "artifact_count": sum( + (bundle["by_type"][t].get("artifact_count") or 0) + for t in bundle["by_type"] + ), + "artifact_summaries": merged_summaries, + } + + +_ARTIFACT_TYPE_LABELS = { + "framework_program": "Rahmenprogrammen", + "training_module": "Trainingsmodulen", + "progression_graph": "Regressionspfaden", +} + + +def reference_scale_meta( + type_corpus: Dict[str, Any], + artifact_type: str, + *, + effective_club_id: Optional[int] = None, +) -> Dict[str, Any]: + label = _ARTIFACT_TYPE_LABELS.get(artifact_type, artifact_type) + return { + "scope": "planning_peer", + "artifact_type": artifact_type, + "effective_club_id": effective_club_id, + "skills_in_corpus": len(type_corpus.get("max_by_skill") or {}), + "artifacts_scanned": type_corpus.get("artifact_count") or 0, + "description": ( + f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit " + f"(nicht gemischt mit anderen Planungs-Artefakttypen)" + ), + } + + +def compute_corpus_skill_max_weights( + cur, + *, + profile_id: int, + role: Optional[str], + effective_club_id: Optional[int], + limit_per_type: int = 50, + artifact_type: str = "framework_program", +) -> Dict[int, float]: + """Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle).""" + del limit_per_type + bundle = compute_planning_corpus_by_type( + cur, + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + ) + return corpus_for_artifact_type(bundle, artifact_type)["max_by_skill"] + + +def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: + """Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge).""" + wanted = {int(x) for x in skill_ids if x is not None} + if not wanted: + return { + "match_weight": 0.0, + "match_score": 0.0, + "match_percent": 0.0, + "artifact_focus_percent": 0.0, + "matched_skill_ids": [], + "matched_skills": [], + } + matched = [] + match_weight = 0.0 + total = float(profile.get("total_weight") or 0) + for sk in profile.get("skills") or []: + sid = int(sk["skill_id"]) + if sid in wanted: + matched.append(sk) + match_weight += float(sk.get("weight") or 0) + artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0 + return { + "match_weight": _round2(match_weight), + "match_score": _round2(match_weight), + "match_percent": _round2(artifact_focus), + "artifact_focus_percent": _round2(artifact_focus), + "matched_skill_ids": [int(m["skill_id"]) for m in matched], + "matched_skills": matched, + } diff --git a/backend/tenant_context.py b/backend/tenant_context.py index e1c9309..fe76393 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -107,6 +107,33 @@ def library_content_visibility_sql( return "(" + " OR ".join(parts) + ")", params +def club_library_visibility_sql( + *, + alias: str, + profile_id: int, + effective_club_id: Optional[int], +) -> tuple[str, List[Any]]: + """ + Nur Inhalte des aktiven Vereins (visibility=club, club_id=active). + Für Skill-Vergleiche im Vereinskontext — ohne official/private anderer Mandanten. + """ + if effective_club_id is None: + return "(1=0)", [] + return ( + f"""( + {alias}.visibility = 'club' + AND {alias}.club_id = %s + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s + AND cm.club_id = {alias}.club_id + AND cm.status = 'active' + ) + )""", + [effective_club_id, profile_id], + ) + + @dataclass class TenantContext: profile_id: int diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py new file mode 100644 index 0000000..dbe4531 --- /dev/null +++ b/backend/tests/test_skill_scoring.py @@ -0,0 +1,125 @@ +"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3).""" +from skill_scoring import ( + ExerciseOccurrence, + compute_skill_profile, + match_score_for_skill_ids, + _club_universal_percent, + _level_range_multiplier, + _skill_link_multiplier, +) + + +def test_skill_link_multiplier_intensity_and_levels(): + assert _skill_link_multiplier(intensity="hoch") == 1.2 + assert _skill_link_multiplier(intensity="niedrig") == 0.85 + wide = _skill_link_multiplier( + intensity="mittel", + required_level="basis", + target_level="optimierung", + ) + narrow = _skill_link_multiplier( + intensity="mittel", + required_level="grundlagen", + target_level="grundlagen", + ) + assert wide > narrow + + +def test_level_range_multiplier_span(): + assert _level_range_multiplier(None, None) == 1.0 + assert _level_range_multiplier("aufbau", "fortgeschritten") > _level_range_multiplier("basis", "basis") + + +def test_compute_skill_profile_aggregates_weights(): + occurrences = [ + ExerciseOccurrence(exercise_id=1, planned_duration_min=60), + ExerciseOccurrence(exercise_id=1, planned_duration_min=30), + ] + skills_map = { + 1: [ + { + "skill_id": 10, + "skill_name": "Distanz", + "category": "kihon", + "category_id": 1, + "category_name": "kihon", + "main_category_id": 100, + "main_category_name": "Technik", + "intensity": "hoch", + "required_level": "grundlagen", + "target_level": "aufbau", + "exercise_title": "Übung A", + }, + { + "skill_id": 11, + "skill_name": "Balance", + "category": "kihon", + "category_id": 1, + "category_name": "kihon", + "main_category_id": 100, + "main_category_name": "Technik", + "intensity": "niedrig", + "required_level": "basis", + "target_level": "basis", + "exercise_title": "Übung A", + }, + ], + } + profile = compute_skill_profile(occurrences, skills_map) + assert profile["scoring_version"] == "1.2" + assert profile["exercise_occurrence_count"] == 2 + assert profile["distinct_exercise_count"] == 1 + assert len(profile["skills"]) == 2 + assert profile["skills"][0]["skill_id"] == 10 + assert profile["total_weight"] > profile["skills"][1]["weight"] + assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1 + assert len(profile["by_main_category"]) == 1 + assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10 + + +def test_universal_percent_against_corpus_max(): + occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)] + skills_map = { + 1: [ + { + "skill_id": 10, + "skill_name": "Koordination", + "category_name": "Koordination", + "category_id": 2, + "main_category_id": 200, + "main_category_name": "Körper", + }, + ], + } + profile = compute_skill_profile( + occurrences, + skills_map, + reference_max_by_skill={10: 100.0}, + ) + assert profile["has_reference_scale"] is True + assert profile["skills"][0]["universal_percent"] == 50.0 + assert profile["skills"][0]["is_club_best_for_skill"] is False + + +def test_club_universal_percent_capped_at_100(): + pct, is_best = _club_universal_percent(150.0, 100.0) + assert pct == 100.0 + assert is_best is True + pct2, _ = _club_universal_percent(72.6, 100.0) + assert pct2 == 72.6 + + +def test_match_score_for_skill_ids(): + profile = { + "total_weight": 100.0, + "skills": [ + {"skill_id": 1, "skill_name": "A", "weight": 40.0}, + {"skill_id": 2, "skill_name": "B", "weight": 60.0}, + ], + } + m = match_score_for_skill_ids(profile, [1]) + assert m["match_weight"] == 40.0 + assert m["match_score"] == 40.0 + assert m["match_percent"] == 40.0 + assert m["artifact_focus_percent"] == 40.0 + assert m["matched_skill_ids"] == [1] diff --git a/backend/version.py b/backend/version.py index 529fd57..2752721 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.150" +APP_VERSION = "0.8.151" BUILD_DATE = "2026-05-20" DB_SCHEMA_VERSION = "20260520066" @@ -20,6 +20,7 @@ MODULE_VERSIONS = { "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "groups": "0.1.0", "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder + "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint @@ -36,6 +37,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.151", + "date": "2026-05-20", + "changes": [ + "Phase 3 Fähigkeiten-Scoring: skill_scoring.py (Dauer × Vorkommen × Primär/Intensität/Beitrag)", + "GET skill-profile für Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph", + "GET /api/skill-discovery/suggestions nach skill_ids", + ], + }, { "version": "0.8.149", "date": "2026-05-19", diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js new file mode 100644 index 0000000..db67033 --- /dev/null +++ b/frontend/src/api/skillProfiles.js @@ -0,0 +1,40 @@ +import { request } from './client.js' + +export async function getFrameworkProgramSkillProfile(frameworkId) { + return request(`/api/training-framework-programs/${frameworkId}/skill-profile`) +} + +export async function getTrainingModuleSkillProfile(moduleId) { + return request(`/api/training-modules/${moduleId}/skill-profile`) +} + +export async function getProgressionGraphSkillProfile(graphId) { + return request(`/api/exercise-progression-graphs/${graphId}/skill-profile`) +} + +/** + * @param {number[]} skillIds + * @param {{ types?: string, limit?: number }} opts + */ +export async function getSkillDiscoverySuggestions(skillIds, opts = {}) { + const ids = (skillIds || []).filter((x) => x != null && Number(x) > 0).join(',') + if (!ids) return { skill_ids: [], suggestions: [] } + const params = new URLSearchParams({ skill_ids: ids }) + if (opts.types) params.set('types', opts.types) + if (opts.limit != null) params.set('limit', String(opts.limit)) + return request(`/api/skill-discovery/suggestions?${params.toString()}`) +} + +/** + * Batch-Summaries für Listen (ein Vereins-Corpus-Scan). + * @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload + */ +export async function batchSkillProfileSummaries(payload = {}) { + return request('/api/skill-profiles/batch-summaries', { + method: 'POST', + body: JSON.stringify({ + framework_program_ids: payload.frameworkProgramIds || [], + training_module_ids: payload.trainingModuleIds || [], + }), + }) +} diff --git a/frontend/src/app.css b/frontend/src/app.css index 958b06a..9feb71f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2475,6 +2475,474 @@ html.modal-scroll-locked .app-main { } } +/* Phase 3: Fähigkeiten-Profil & Planungs-Vorschläge */ +.skill-profile { + margin-bottom: 1rem; + padding: 0; + overflow: hidden; +} +.skill-profile--loading, +.skill-profile--error { + padding: 1rem 1.15rem; +} +.skill-profile__status { + margin: 0; + color: var(--text2); + font-size: 0.9rem; +} +.skill-profile__toggle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + width: 100%; + padding: 0.85rem 1.1rem; + border: none; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} +.skill-profile__toggle-title { + font-weight: 700; + color: var(--text1); +} +.skill-profile__toggle-badge { + font-size: 0.78rem; + color: var(--accent-dark); + background: var(--accent-light); + padding: 3px 8px; + border-radius: 999px; +} +.skill-profile__toggle-icon { + margin-left: auto; + color: var(--text3); +} +.skill-profile__body { + padding: 0 1.1rem 1rem; + border-top: 1px solid var(--border); +} +.skill-profile__hint { + margin: 0.65rem 0 0.75rem; +} +.skill-profile__empty { + margin: 0; + color: var(--text2); + font-size: 0.88rem; + line-height: 1.5; +} +.skill-profile__stats { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + font-size: 0.82rem; + color: var(--text2); + margin-bottom: 0.75rem; +} +.skill-profile__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.skill-profile__list--nested { + margin: 8px 0 0; + padding-left: 8px; + border-left: 2px solid var(--border); +} +.skill-profile__row-head { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.skill-profile__name { + font-weight: 600; + font-size: 0.88rem; + color: var(--text1); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.skill-profile__pct { + font-size: 0.82rem; + font-weight: 700; + color: var(--accent-dark); + flex-shrink: 0; +} +.skill-profile__bar-track { + height: 8px; + border-radius: 4px; + background: var(--surface2); + overflow: hidden; +} +.skill-profile__bar-fill { + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, var(--accent) 0%, var(--accent-dark) 100%); + min-width: 2px; +} +.skill-profile__meta-hint { + display: block; + font-size: 0.72rem; + color: var(--text3); + margin-top: 3px; +} +.skill-profile__by-category { + display: flex; + flex-direction: column; + gap: 14px; +} +.skill-profile__main-cat { + padding: 10px 12px; + border-radius: 10px; + background: var(--surface2); + border: 1px solid var(--border); +} +.skill-profile__main-cat-title { + margin: 0 0 8px; + font-size: 0.82rem; + font-weight: 700; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.skill-profile__cat-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.skill-profile__cat-item { + display: flex; + flex-direction: column; + gap: 4px; +} +.skill-profile__cat-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text3); +} +.skill-profile__cat-row { + list-style: none; +} +.skill-profile__categories { + margin-top: 0.85rem; +} +.skill-profile__categories-label, +.skill-profile__slots-label { + display: block; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin-bottom: 6px; +} +.skill-profile__category-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.skill-profile__category-chip { + font-size: 0.78rem; + padding: 3px 8px; + border-radius: 6px; + background: var(--surface2); + color: var(--text2); +} +.skill-profile__slots { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} +.skill-profile__slot-list { + list-style: none; + margin: 0; + padding: 0; +} +.skill-profile__slot-btn { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px; + width: 100%; + padding: 8px 0; + border: none; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; + color: var(--text1); +} +.skill-profile__slot-top { + font-size: 0.82rem; + color: var(--accent-dark); + font-weight: 600; +} +.skill-profile__slot-top--muted { + color: var(--text3); + font-weight: 500; +} + +.skill-profile-compact { + margin-top: 4px; +} +.skill-kpi-grid { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.skill-kpi-grid--loading, +.skill-kpi-grid--empty { + margin: 0; +} +.skill-kpi-tile { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 5.5rem; + max-width: 8.5rem; + padding: 5px 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface2); + flex: 0 1 auto; +} +.skill-kpi-tile--highlight { + border-color: var(--accent); + background: var(--accent-light); +} +.skill-kpi-tile--best { + box-shadow: inset 0 0 0 1px var(--accent-dark); +} +.skill-kpi-tile__cat { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.skill-kpi-tile__name { + font-size: 0.76rem; + font-weight: 600; + color: var(--text1); + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.skill-kpi-tile__score { + font-size: 0.78rem; + font-weight: 700; + color: var(--text1); + margin-top: 2px; +} +.skill-kpi-tile__pct { + font-size: 0.68rem; + font-weight: 600; + color: var(--accent-dark); +} +.skill-profile__name-cat { + font-weight: 500; + color: var(--text3); +} +.skill-profile--embedded .skill-profile__body { + padding: 0; + border-top: none; +} +.skill-profile--embedded .skill-profile__hint { + margin-top: 0; +} +.fw-prog-card__section-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} +.fw-prog-card__section--skills { + margin-top: 0.5rem; +} +.fw-import-skill-grid { + max-height: 180px; + overflow-y: auto; +} +.skill-profile-modal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px 12px; + overflow-y: auto; +} +.skill-profile-modal__backdrop { + position: fixed; + inset: 0; + border: none; + background: rgba(0, 0, 0, 0.45); + cursor: pointer; +} +.skill-profile-modal__panel { + position: relative; + z-index: 1; + width: min(42rem, 100%); + max-height: calc(100vh - 48px); + overflow-y: auto; + padding: 1rem 1.1rem 1.25rem; + margin-top: 2vh; +} +.skill-profile-modal__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 0.75rem; +} +.skill-profile-modal__title { + margin: 0; + font-size: 1.05rem; +} +.skill-profile-modal__scale { + margin: 0.5rem 0 0; +} + +.skill-discovery { + padding: 1.15rem 1.25rem; + max-width: 52rem; +} +.skill-discovery__title { + margin: 0 0 0.35rem; + font-size: 1.1rem; +} +.skill-discovery__lead { + margin: 0 0 1rem; + max-width: 40rem; +} +.skill-discovery__pick-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 8px; + max-height: 220px; + overflow-y: auto; + padding: 8px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface2); + margin-bottom: 1rem; +} +.skill-discovery__pick { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + font-size: 0.85rem; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; +} +.skill-discovery__pick:hover { + background: var(--surface); +} +.skill-discovery__pick-name { + font-weight: 600; + color: var(--text1); +} +.skill-discovery__pick-cat { + font-size: 0.72rem; + color: var(--text3); +} +.skill-discovery__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 0.75rem; +} +.skill-discovery__error { + color: var(--danger); + margin: 0 0 0.75rem; +} +.skill-discovery__results { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.skill-discovery__result { + padding: 0.85rem 1rem; + margin-bottom: 0; +} +.skill-discovery__result-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px; + margin-bottom: 4px; +} +.skill-discovery__result-type { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + color: var(--text3); +} +.skill-discovery__result-match { + font-size: 0.82rem; + font-weight: 700; + color: var(--accent-dark); +} +.skill-discovery__result-title { + display: block; + margin-bottom: 4px; +} +.skill-discovery__result-skills { + margin: 0 0 8px; + font-size: 0.85rem; + color: var(--text2); +} +.skill-discovery__result-cats { + list-style: none; + margin: 0 0 8px; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + color: var(--text2); +} +.skill-discovery__result-cats li { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.skill-discovery__result-cat-name { + font-weight: 600; + color: var(--text3); +} +.skill-discovery__result-link { + text-decoration: none; +} +.skill-discovery__no-hit { + margin: 0; +} + /* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */ .page-form-editor__body .framework-edit { min-width: 0; @@ -3618,6 +4086,12 @@ html.modal-scroll-locked .app-main { .exercise-filter-modal.admin-modal-sheet { max-width: min(920px, calc(100dvw - 16px)); + max-height: min(92vh, 920px); +} +@media (min-width: 640px) { + .exercise-filter-modal.admin-modal-sheet { + max-height: min(90vh, 920px); + } } .exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll { flex: 1; @@ -5386,12 +5860,16 @@ html.modal-scroll-locked .app-main { border-color: var(--border2); } -/* Rahmenprogramm-Filter auf Übersichtsseite */ -.fw-prog-filter-block--list { +/* Rahmenprogramm-/Modul-Filter auf Übersichtsseite */ +.fw-prog-filter-block--list, +.planning-list-filter-bar { margin-bottom: 1rem; } -.fw-prog-filter-block--list .fw-import-filter-panel { - margin-top: 0.75rem; +.planning-list-filter-bar__search { + margin-bottom: 0.75rem; +} +.planning-list-filter-bar .fw-import-results-bar { + margin-top: 0; } /* —— Rahmenprogramm-Bibliothek (Liste) —— */ @@ -5946,6 +6424,24 @@ html.modal-scroll-locked .app-main { max-height: min(360px, 55vh); overflow: auto; } +.skill-tree-multiselect__panel--portal { + position: fixed; + right: auto; + top: auto; + overflow: auto; + -webkit-overflow-scrolling: touch; +} +.skill-tree-multiselect__panel--portal .multiselect-combo__list { + position: static; + left: auto; + right: auto; + top: auto; + margin: 0; + max-height: none; + box-shadow: none; + border: none; + border-radius: 0; +} .skill-tree { list-style: none; margin: 0; diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index dfd8661..3dceb6c 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' @@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({ const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') const [uiTab, setUiTab] = useState('overview') + const [skillProfileData, setSkillProfileData] = useState(null) + const [skillProfileLoading, setSkillProfileLoading] = useState(false) + const [skillProfileError, setSkillProfileError] = useState('') useEffect(() => { setSelectedGraphId(null) @@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({ } }, [refreshGraphs, tenantClubDepKey]) + useEffect(() => { + if (!selectedGraphId) { + setSkillProfileData(null) + return undefined + } + let cancelled = false + ;(async () => { + setSkillProfileLoading(true) + setSkillProfileError('') + try { + const data = await api.getProgressionGraphSkillProfile(selectedGraphId) + if (!cancelled) setSkillProfileData(data) + } catch (e) { + if (!cancelled) { + setSkillProfileData(null) + setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') + } + } finally { + if (!cancelled) setSkillProfileLoading(false) + } + })() + return () => { + cancelled = true + } + }, [selectedGraphId, edges.length]) + useEffect(() => { if (!selectedGraphId) { setEdges([]) @@ -652,6 +682,15 @@ export default function ExerciseProgressionGraphPanel({ {selectedGraphId && uiTab === 'overview' && ( <> +

Sequenz / Reihe anlegen

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" + /> + ) : ( +

+ ) + + 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}
) } diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx index 2ccd4ed..1fbf549 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,28 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete ) : null} +
+
+

Fähigkeiten

+ {onShowSkillProfile ? ( + + ) : null} +
+ +
+
collectDistinctSessionDurationsMinutes(programs), - [programs] - ) + const [filterModalOpen, setFilterModalOpen] = useState(false) const matchCount = useMemo( - () => filterFrameworkPrograms(programs, filters).length, - [programs, filters] + () => filterFrameworkPrograms(programs, filters, skillSummaries).length, + [programs, filters, skillSummaries] ) const totalCount = (programs || []).length const filterActive = hasActiveFrameworkImportFilters(filters) - const filterSummaryParts = useMemo( - () => - summarizeFrameworkImportFilters(filters, { - focusAreas: catalogFocusAreas, - trainingTypes: catalogTrainingTypes, - targetGroups: catalogTargetGroups, - }), - [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups] - ) - const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch })) + const filterChips = useMemo( + () => + buildPlanningArtifactFilterChips({ + filters, + setFilters: onFiltersChange, + catalogs: { + focusAreas: catalogFocusAreas, + trainingTypes: catalogTrainingTypes, + targetGroups: catalogTargetGroups, + skills: catalogSkills, + }, + artifactType: 'framework_program', + emptyFilters: EMPTY_FRAMEWORK_IMPORT_FILTERS, + }), + [ + filters, + onFiltersChange, + catalogFocusAreas, + catalogTrainingTypes, + catalogTargetGroups, + catalogSkills, + ] + ) const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }) - const toggleId = (key, id) => { - const s = String(id) - onFiltersChange((prev) => { - const cur = prev[key] || [] - const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s] - return { ...prev, [key]: next } - }) - } + useEffect(() => { + if (!filterModalOpen) return undefined + const onKey = (e) => { + if (e.key === 'Escape') setFilterModalOpen(false) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [filterModalOpen]) - const togglePanel = () => { - if (onPanelOpenChange) onPanelOpenChange(!panelOpen) - } + const resultPlural = totalCount === 1 ? resultLabel : `${resultLabel}e` return ( -
+
+
+ + 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} -
- {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/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx new file mode 100644 index 0000000..203fdc3 --- /dev/null +++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx @@ -0,0 +1,185 @@ +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', +} + +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). + */ +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 vor. Sortierung nach absolutem + Trainingsgewicht (nicht nach Anteil innerhalb des Plans). +

+ +
+ + 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) => { + 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 ? ( +

+ 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/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx new file mode 100644 index 0000000..fa4c11b --- /dev/null +++ b/frontend/src/components/skills/SkillProfileCompact.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { + formatClubPercent, + formatSkillWeight, + kpiRowsFromSummary, + peerPercentSuffix, +} from '../../utils/skillProfileListHelpers' + +/** + * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten). + */ +export default function SkillProfileCompact({ + summary, + artifactType = 'training_module', + loading = false, + emptyText = 'Keine Fähigkeiten', + displayLimit = 24, + highlightSkillIds = [], +}) { + if (loading) { + return ( +
+ +
+ ) + } + + const rows = kpiRowsFromSummary(summary, { limit: displayLimit }) + const highlight = new Set((highlightSkillIds || []).map(String)) + const peerLabel = peerPercentSuffix(artifactType) + + if (!rows.length) { + return summary ?

{emptyText}

: null + } + + return ( +
    + {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} + {formatSkillWeight(row.weight ?? row.score)} + + {isBest ? '★ ' : ''} + {formatClubPercent(row.universal_percent)} {peerLabel} + +
  • + ) + })} +
+ ) +} diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx new file mode 100644 index 0000000..ce306b2 --- /dev/null +++ b/frontend/src/components/skills/SkillProfileFullModal.jsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react' +import api from '../../utils/api' +import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers' +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 + + const peerCountLabel = peerCorpusCountLabel(artifactType) + const scale = data?.reference_scale + + return ( +
+ + + + {scale && !loading ? ( +

+ Vergleichsbasis: {scale.artifacts_scanned ?? 0} sichtbare {peerCountLabel} ( + {scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz). + {scale.description ? ` ${scale.description}` : null} +

+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx new file mode 100644 index 0000000..48067b2 --- /dev/null +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -0,0 +1,338 @@ +import React, { useMemo, useState } from 'react' +import { + formatClubPercent, + peerPercentSuffix, +} from '../../utils/skillProfileListHelpers' + +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, peerLabel) { + if (hasReferenceScale && skill?.universal_percent != null) { + const best = skill.is_club_best_for_skill ? ' ★' : '' + return `${formatClubPercent(skill.universal_percent)} ${peerLabel}${best}` + } + return formatWeight(skillWeight(skill)) +} + +function SkillRow({ skill, maxWeight, hasReferenceScale, peerLabel }) { + if (!skill) return null + const pct = barFillPercent(skill, maxWeight, hasReferenceScale) + return ( +
  • +
    + + {skill.category_name ? ( + {skill.category_name} · + ) : null} + {skill.skill_name} + + + {metricLabel(skill, hasReferenceScale, peerLabel)} + +
    +
  • + ) +} + +function CategoryTopSkill({ skill, maxWeight, hasReferenceScale, peerLabel }) { + if (!skill) return null + return ( + + ) +} + +function CategoryGroupedProfile({ profile, ariaLabel, peerLabel }) { + 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 FullSkillsProfile({ profile, ariaLabel, peerLabel }) { + 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 || []) { + 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. + * displayMode: 'summary' = Top je Kategorie (Editor), 'full' = alle Fähigkeiten (Modal). + */ +export default function SkillProfilePanel({ + profile, + slots = null, + loading = false, + error = '', + title = 'Fähigkeiten-Profil', + hint = '', + defaultExpanded = true, + displayMode = 'summary', + embedded = false, + artifactType = 'framework_program', +}) { + const [expanded, setExpanded] = useState(defaultExpanded) + const [slotOpenId, setSlotOpenId] = useState(null) + + const peerLabel = peerPercentSuffix(artifactType) + + const defaultHint = + displayMode === 'full' + ? `Alle verknüpften Fähigkeiten nach Trainingsgewicht. Prozent = Anteil am stärksten sichtbaren Eintrag unter ${peerLabel} je Fähigkeit.` + : `Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Prozent vergleicht nur unter ${peerLabel}, nicht mit anderen Planungs-Artefakttypen.` + + const hintText = hint || defaultHint + const badge = useMemo(() => topCategoryBadge(profile), [profile]) + + 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) + + const categoryCount = (profile?.by_main_category || []).reduce( + (n, mc) => n + (mc.categories?.length || 0), + 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' ? ( + + ) : ( + + )} + + )} + + {slots && slots.length > 0 ? ( +
    + Pro Session +
      + {slots.map((sl) => { + const open = slotOpenId === sl.slot_id + const slotBadge = topCategoryBadge(sl.profile) + return ( +
    • + + {open && sl.profile?.skills?.length > 0 ? ( + displayMode === 'full' ? ( + + ) : ( + + ) + ) : null} +
    • + ) + })} +
    +
    + ) : null} +
    + ) + + if (embedded) { + return
    {body}
    + } + + return ( +
    + + {expanded ? body : 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..dee3d3c 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} +
    ({ ...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 +39,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.listSkillsCatalog({ 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 +67,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 { @@ -131,11 +161,11 @@ export default function TrainingFrameworkProgramsListPage() { programs={rows} filters={filters} onFiltersChange={setFilters} - panelOpen={filterPanelOpen} - onPanelOpenChange={setFilterPanelOpen} catalogFocusAreas={catalogFocusAreas} catalogTrainingTypes={catalogTrainingTypes} catalogTargetGroups={catalogTargetGroups} + catalogSkills={catalogSkills} + skillSummaries={skillSummaries} durationRadioName="fw-list-duration-mode" className="fw-prog-filter-block--list" /> @@ -157,6 +187,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 +205,14 @@ export default function TrainingFrameworkProgramsListPage() { )} )} + + setProfileModal(null)} + artifactType={profileModal?.artifactType} + artifactId={profileModal?.artifactId} + title={profileModal?.title} + />
    ) } diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx index d6c429f..73cd0aa 100644 --- a/frontend/src/pages/TrainingModuleEditPage.jsx +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import FormActionBar from '../components/FormActionBar' +import SkillProfilePanel from '../components/skills/SkillProfilePanel' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' @@ -76,6 +77,10 @@ export default function TrainingModuleEditPage() { const [methods, setMethods] = useState([]) const [pickerOpen, setPickerOpen] = useState(false) const [error, setError] = useState('') + const [skillProfileData, setSkillProfileData] = useState(null) + const [skillProfileLoading, setSkillProfileLoading] = useState(false) + const [skillProfileError, setSkillProfileError] = useState('') + const [skillProfileTick, setSkillProfileTick] = useState(0) const [title, setTitle] = useState('') const [summary, setSummary] = useState('') @@ -130,6 +135,32 @@ export default function TrainingModuleEditPage() { const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving)) useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving)) + useEffect(() => { + 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,15 @@ export default function TrainingModuleEditPage() { onSubmit={handleSave} >
    + {!isNew ? ( + + ) : null}
    setTitle(e.target.value)} /> diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index ecea0db..4f0492e 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -1,9 +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() @@ -12,16 +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) } @@ -31,6 +55,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 +105,8 @@ 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. 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'}) - -

        -

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

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

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

    +
    + +
    +
    +
    + + + Bearbeiten + + +
    +
    + + ))} + + )} + )} + + setProfileModal(null)} + artifactType={profileModal?.artifactType} + artifactId={profileModal?.artifactId} + title={profileModal?.title} + /> ) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 4d999ad..64454eb 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 @@ -753,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, diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js index 4621f04..6ecea4f 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, + summaryHasSkill, +} 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: 24, } 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 Rahmenprogramm-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,26 @@ export function filterFrameworkPrograms(rows, filters = {}) { return true }) + + if (skillIds.length && skillSummaries) { + list = list.filter((r) => { + const summary = skillSummaries[frameworkSkillSummaryKey(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[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/planningArtifactFilterChips.js b/frontend/src/utils/planningArtifactFilterChips.js new file mode 100644 index 0000000..c48dac4 --- /dev/null +++ b/frontend/src/utils/planningArtifactFilterChips.js @@ -0,0 +1,137 @@ +import { formatDurationDisplay } from './trainingDurationUtils' +import { EMPTY_FRAMEWORK_IMPORT_FILTERS } from './frameworkProgramListHelpers' +import { EMPTY_TRAINING_MODULE_FILTERS } from './trainingModuleListHelpers' +import { peerCorpusCountLabel } from './skillProfileListHelpers' + +function nameById(list, id) { + return list?.find((x) => 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/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js new file mode 100644 index 0000000..d193c94 --- /dev/null +++ b/frontend/src/utils/skillProfileListHelpers.js @@ -0,0 +1,110 @@ +export function frameworkSkillSummaryKey(id) { + return `framework_program:${id}` +} + +export function moduleSkillSummaryKey(id) { + return `training_module:${id}` +} + +export function skillEntryFromSummary(summary, skillId) { + 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 = []) { + 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 = Math.min(100, Number(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) +} + +const PEER_LABELS = { + framework_program: 'Rahmenpr.', + training_module: 'Module', + progression_graph: 'Pfade', +} + +const PEER_COUNT_LABELS = { + framework_program: 'Rahmenprogramme', + training_module: 'Module', + progression_graph: 'Regressionspfade', +} + +export function peerPercentSuffix(artifactType = 'training_module') { + return PEER_LABELS[artifactType] || 'Peers' +} + +export function peerCorpusCountLabel(artifactType = 'training_module') { + return PEER_COUNT_LABELS[artifactType] || 'Planungs-Artefakte' +} + +/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */ +export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) { + if (!summary) return [] + 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' + 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 +} 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 +}