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

Sequenz / Reihe anlegen

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

+

Planungs-Vorschläge

+

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

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

Keine Fähigkeiten gefunden.

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

{error}

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

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

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

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

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

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

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

    Fähigkeiten-Profil wird berechnet…

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

    {error}

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

    {hint}

    + + {noData ? ( +

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

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

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

    + ) : ( + <> +
    + + {profile.distinct_exercise_count} Übungen + + + {skills.length} Fähigkeiten + + + {profile.exercise_occurrence_count} Positionen + +
    + +
      + {skills.slice(0, 12).map((sk) => ( + + ))} +
    + + {profile.by_category?.length > 1 ? ( +
    + Nach Kategorie +
    + {profile.by_category.slice(0, 6).map((c) => ( + + {c.category} {c.share_percent}% + + ))} +
    +
    + ) : null} + + )} + + {slots && slots.length > 0 ? ( +
    + Pro Session +
      + {slots.map((sl) => { + const open = slotOpenId === sl.slot_id + const top = sl.profile?.skills?.[0] + return ( +
    • + + {open && sl.profile?.skills?.length > 0 ? ( +
        + {sl.profile.skills.slice(0, 6).map((sk) => ( + x.share_percent || 0), + 1 + )} + /> + ))} +
      + ) : null} +
    • + ) + })} +
    +
    + ) : null} +
    + ) : null} +
    + ) +} diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx index 60c93d1..691aed9 100644 --- a/frontend/src/pages/SkillsPage.jsx +++ b/frontend/src/pages/SkillsPage.jsx @@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import PageSectionNav from '../components/PageSectionNav' +import SkillDiscoveryPanel from '../components/skills/SkillDiscoveryPanel' const SKILLS_SECTION_TABS = [ { id: 'skills', label: 'Fähigkeiten' }, { id: 'methods', label: 'Trainingsmethoden' }, + { id: 'discovery', label: 'Planungs-Vorschläge' }, ] function SkillsPage() { @@ -243,6 +245,8 @@ function SkillsPage() { )} + {activeTab === 'discovery' && } + {/* Methods Tab */} {activeTab === 'methods' && ( <> diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 6f10322..322660f 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import PageSectionNav from '../components/PageSectionNav' import PageFormEditorChrome from '../components/PageFormEditorChrome' +import SkillProfilePanel from '../components/skills/SkillProfilePanel' import { useToast } from '../context/ToastContext' import { useNavReturn } from '../hooks/useNavReturn' import { @@ -248,6 +249,10 @@ export default function TrainingFrameworkProgramEditPage() { ) /** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */ const [mobileSlotIdx, setMobileSlotIdx] = useState(0) + const [skillProfileData, setSkillProfileData] = useState(null) + const [skillProfileLoading, setSkillProfileLoading] = useState(false) + const [skillProfileError, setSkillProfileError] = useState('') + const [skillProfileTick, setSkillProfileTick] = useState(0) const toast = useToast() const baselineRef = useRef(null) @@ -301,6 +306,32 @@ export default function TrainingFrameworkProgramEditPage() { return () => document.removeEventListener('pointerdown', onPointerDown, true) }, []) + useEffect(() => { + if (isNew || !idParam) { + setSkillProfileData(null) + return undefined + } + let cancelled = false + ;(async () => { + setSkillProfileLoading(true) + setSkillProfileError('') + try { + const data = await api.getFrameworkProgramSkillProfile(idParam) + if (!cancelled) setSkillProfileData(data) + } catch (e) { + if (!cancelled) { + setSkillProfileData(null) + setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') + } + } finally { + if (!cancelled) setSkillProfileLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isNew, idParam, skillProfileTick]) + const loadMeta = useCallback(async () => { try { const [cl, fa, sd, tt, tg] = await Promise.all([ @@ -480,6 +511,7 @@ export default function TrainingFrameworkProgramEditPage() { setBypassDirty(false) setBaselineReady(true) toast.success('Gespeichert.') + setSkillProfileTick((t) => t + 1) if (closeAfter) goBack() return true } catch (e) { @@ -940,6 +972,17 @@ export default function TrainingFrameworkProgramEditPage() { />
    + {!isNew ? ( + + ) : null} +
    { + if (isNew || !Number.isFinite(moduleId)) { + setSkillProfileData(null) + return undefined + } + let cancelled = false + ;(async () => { + setSkillProfileLoading(true) + setSkillProfileError('') + try { + const data = await api.getTrainingModuleSkillProfile(moduleId) + if (!cancelled) setSkillProfileData(data) + } catch (e) { + if (!cancelled) { + setSkillProfileData(null) + setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') + } + } finally { + if (!cancelled) setSkillProfileLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isNew, moduleId, skillProfileTick]) + const { user } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) @@ -339,6 +370,7 @@ export default function TrainingModuleEditPage() { baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBypassDirty(false) toast.success('Gespeichert.') + setSkillProfileTick((t) => t + 1) if (closeAfter) goBack() return true } catch (err) { @@ -391,6 +423,14 @@ export default function TrainingModuleEditPage() { onSubmit={handleSave} >
    + {!isNew ? ( + + ) : null}
    setTitle(e.target.value)} /> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 4d999ad..9528ba1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -7,10 +7,12 @@ import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js' import * as exercises from '../api/exercises.js' import * as planning from '../api/planning.js' +import * as skillProfiles from '../api/skillProfiles.js' export { ACTIVE_CLUB_STORAGE_KEY } export * from '../api/exercises.js' export * from '../api/planning.js' +export * from '../api/skillProfiles.js' // ============================================================================ // Auth