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
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:
parent
e382b6ed35
commit
732b322c52
65
.claude/docs/technical/SKILL_SCORING_SPEC.md
Normal file
65
.claude/docs/technical/SKILL_SCORING_SPEC.md
Normal 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
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
354
backend/routers/skill_profiles.py
Normal file
354
backend/routers/skill_profiles.py
Normal 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
345
backend/skill_scoring.py
Normal 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,
|
||||
}
|
||||
60
backend/tests/test_skill_scoring.py
Normal file
60
backend/tests/test_skill_scoring.py
Normal 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]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
26
frontend/src/api/skillProfiles.js
Normal file
26
frontend/src/api/skillProfiles.js
Normal 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()}`)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
163
frontend/src/components/skills/SkillDiscoveryPanel.jsx
Normal file
163
frontend/src/components/skills/SkillDiscoveryPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/skills/SkillProfilePanel.jsx
Normal file
189
frontend/src/components/skills/SkillProfilePanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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' +
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user