Implement Phase 3 Features for Skill Profiles and Discovery
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m27s

- 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.
This commit is contained in:
Lars 2026-05-20 16:42:25 +02:00
parent e382b6ed35
commit 732b322c52
16 changed files with 1626 additions and 17 deletions

View File

@ -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

View File

@ -1,7 +1,7 @@
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
**Stand:** 2026-05-20
**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 23 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. 12 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`).

View File

@ -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)

View File

@ -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([], {})

345
backend/skill_scoring.py Normal file
View File

@ -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,
}

View File

@ -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]

View File

@ -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",

View File

@ -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()}`)
}

View File

@ -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;

View File

@ -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' && (
<>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph (ohne Einzel-Dauer — Standardgewicht pro Übung)."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
defaultExpanded
/>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>

View File

@ -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 (
<div className="skill-discovery card">
<h2 className="skill-discovery__title">Planungs-Vorschläge</h2>
<p className="form-sub skill-discovery__lead">
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).
</p>
<div className="form-row">
<label className="form-label">Fähigkeiten filtern</label>
<input
className="form-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name oder Kategorie …"
/>
</div>
<div className="skill-discovery__pick-grid" role="group" aria-label="Fähigkeiten auswählen">
{filteredSkills.length === 0 ? (
<p className="form-sub">Keine Fähigkeiten gefunden.</p>
) : (
filteredSkills.map((sk) => (
<label key={sk.id} className="skill-discovery__pick">
<input
type="checkbox"
checked={selectedIds.includes(String(sk.id))}
onChange={() => toggleSkill(sk.id)}
/>
<span className="skill-discovery__pick-name">{sk.name}</span>
{sk.category ? (
<span className="skill-discovery__pick-cat">{sk.category}</span>
) : null}
</label>
))
)}
</div>
<div className="skill-discovery__actions">
<button
type="button"
className="btn btn-primary"
disabled={loading || selectedIds.length === 0}
onClick={handleSearch}
>
{loading ? 'Suche …' : 'Bibliothek durchsuchen'}
</button>
{selectedIds.length > 0 ? (
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setSelectedIds([])
setResult(null)
setError('')
}}
>
Auswahl leeren
</button>
) : null}
</div>
{error ? <p className="skill-discovery__error">{error}</p> : null}
{result?.suggestions?.length > 0 ? (
<ul className="skill-discovery__results">
{result.suggestions.map((item) => (
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
<div className="skill-discovery__result-head">
<span className="skill-discovery__result-type">
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
</span>
<span className="skill-discovery__result-match">
Passung {item.match?.match_percent ?? 0}%
</span>
</div>
<strong className="skill-discovery__result-title">
{item.artifact_title || `#${item.artifact_id}`}
</strong>
{item.match?.matched_skills?.length > 0 ? (
<p className="skill-discovery__result-skills">
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
</p>
) : null}
{item.path ? (
<Link to={item.path} className="btn btn-secondary btn-small skill-discovery__result-link">
Öffnen
</Link>
) : item.artifact_type === 'progression_graph' ? (
<p className="form-sub" style={{ margin: '8px 0 0' }}>
Regressionspfad in der Übungsliste unter Progressionsgraph bearbeiten.
</p>
) : null}
</li>
))}
</ul>
) : result && !loading ? (
<p className="form-sub skill-discovery__no-hit">
Keine passenden Artefakte in deiner sichtbaren Bibliothek prüfe Fähigkeiten-Verknüpfungen an
den Übungen oder erweitere die Auswahl.
</p>
) : null}
</div>
)
}

View File

@ -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 (
<li className="skill-profile__row">
<div className="skill-profile__row-head">
<span className="skill-profile__name" title={skill.skill_name}>
{skill.skill_name}
</span>
<span className="skill-profile__pct">{skill.share_percent}%</span>
</div>
<div className="skill-profile__bar-track" aria-hidden="true">
<div
className="skill-profile__bar-fill"
style={{ width: `${pct}%` }}
/>
</div>
{skill.primary_link_count > 0 ? (
<span className="skill-profile__meta-hint">
{skill.primary_link_count}× als Primär-Fähigkeit in Übungen
</span>
) : null}
</li>
)
}
/**
* 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 (
<div className="card skill-profile skill-profile--loading">
<p className="skill-profile__status">Fähigkeiten-Profil wird berechnet</p>
</div>
)
}
if (error) {
return (
<div className="card skill-profile skill-profile--error">
<p className="skill-profile__status">{error}</p>
</div>
)
}
const noData =
!profile ||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
return (
<div className="card skill-profile">
<button
type="button"
className="skill-profile__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<span className="skill-profile__toggle-title">{title}</span>
{!noData && skills.length > 0 ? (
<span className="skill-profile__toggle-badge">
Top: {skills[0].skill_name} ({skills[0].share_percent}%)
</span>
) : null}
<span className="skill-profile__toggle-icon" aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
</button>
{expanded ? (
<div className="skill-profile__body">
<p className="form-sub skill-profile__hint">{hint}</p>
{noData ? (
<p className="skill-profile__empty">
Noch keine Übungen mit Fähigkeiten-Verknüpfung lege Übungen im Ablauf an und verknüpfe
Fähigkeiten in der Übungsbearbeitung.
</p>
) : profile.exercises_with_skills_count === 0 ? (
<p className="skill-profile__empty">
{profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
</p>
) : (
<>
<div className="skill-profile__stats">
<span>
<strong>{profile.distinct_exercise_count}</strong> Übungen
</span>
<span>
<strong>{skills.length}</strong> Fähigkeiten
</span>
<span>
<strong>{profile.exercise_occurrence_count}</strong> Positionen
</span>
</div>
<ul className="skill-profile__list" aria-label="Fähigkeiten nach Gewicht">
{skills.slice(0, 12).map((sk) => (
<SkillBar key={sk.skill_id} skill={sk} maxShare={maxShare} />
))}
</ul>
{profile.by_category?.length > 1 ? (
<div className="skill-profile__categories">
<span className="skill-profile__categories-label">Nach Kategorie</span>
<div className="skill-profile__category-chips">
{profile.by_category.slice(0, 6).map((c) => (
<span key={c.category} className="skill-profile__category-chip">
{c.category} {c.share_percent}%
</span>
))}
</div>
</div>
) : null}
</>
)}
{slots && slots.length > 0 ? (
<div className="skill-profile__slots">
<span className="skill-profile__slots-label">Pro Session</span>
<ul className="skill-profile__slot-list">
{slots.map((sl) => {
const open = slotOpenId === sl.slot_id
const top = sl.profile?.skills?.[0]
return (
<li key={sl.slot_id} className="skill-profile__slot-item">
<button
type="button"
className="skill-profile__slot-btn"
onClick={() => setSlotOpenId(open ? null : sl.slot_id)}
aria-expanded={open}
>
<span>
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
</span>
{top ? (
<span className="skill-profile__slot-top">
{top.skill_name} {top.share_percent}%
</span>
) : (
<span className="skill-profile__slot-top skill-profile__slot-top--muted">
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
</span>
)}
</button>
{open && sl.profile?.skills?.length > 0 ? (
<ul className="skill-profile__list skill-profile__list--nested">
{sl.profile.skills.slice(0, 6).map((sk) => (
<SkillBar
key={sk.skill_id}
skill={sk}
maxShare={Math.max(
...sl.profile.skills.map((x) => x.share_percent || 0),
1
)}
/>
))}
</ul>
) : null}
</li>
)
})}
</ul>
</div>
) : null}
</div>
) : null}
</div>
)
}

View File

@ -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' && <SkillDiscoveryPanel skills={skills} />}
{/* Methods Tab */}
{activeTab === 'methods' && (
<>

View File

@ -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() {
/>
</div>
{!isNew ? (
<SkillProfilePanel
title="Fähigkeiten-Schwerpunkte (aus Übungen)"
hint="Gewichtung nach geplanter Dauer, Häufigkeit der Übung im Programm, Primär-Fähigkeit und Intensität. Vergleichbar mit manuell gesetzten Fokusbereichen in den Stammdaten."
profile={skillProfileData?.overall}
slots={skillProfileData?.slots}
loading={skillProfileLoading}
error={skillProfileError}
/>
) : null}
<div
className={
'framework-edit__panel framework-edit__panel--meta card' +

View File

@ -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,14 @@ export default function TrainingModuleEditPage() {
onSubmit={handleSave}
>
<div className="page-form-shell__scroll">
{!isNew ? (
<SkillProfilePanel
title="Fähigkeiten im Modul"
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
/>
) : null}
<div className="form-row">
<label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />

View File

@ -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