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)
|
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
|
||||||
|
|
||||||
**Stand:** 2026-05-20
|
**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)
|
## 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.
|
**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)
|
### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
|
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
|
||||||
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
|
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.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_rights_router)
|
||||||
app.include_router(media_assets.admin_legal_hold_router)
|
app.include_router(media_assets.admin_legal_hold_router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
|
app.include_router(skill_profiles.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(training_modules.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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.150"
|
APP_VERSION = "0.8.151"
|
||||||
BUILD_DATE = "2026-05-20"
|
BUILD_DATE = "2026-05-20"
|
||||||
DB_SCHEMA_VERSION = "20260520066"
|
DB_SCHEMA_VERSION = "20260520066"
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ MODULE_VERSIONS = {
|
||||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"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",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
"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
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
|
|
@ -36,6 +37,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.149",
|
||||||
"date": "2026-05-19",
|
"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) */
|
/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */
|
||||||
.page-form-editor__body .framework-edit {
|
.page-form-editor__body .framework-edit {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import ExercisePickerModal from './ExercisePickerModal'
|
import ExercisePickerModal from './ExercisePickerModal'
|
||||||
|
|
@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
||||||
const [notesDraft, setNotesDraft] = useState('')
|
const [notesDraft, setNotesDraft] = useState('')
|
||||||
const [uiTab, setUiTab] = useState('overview')
|
const [uiTab, setUiTab] = useState('overview')
|
||||||
|
const [skillProfileData, setSkillProfileData] = useState(null)
|
||||||
|
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
||||||
|
const [skillProfileError, setSkillProfileError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedGraphId(null)
|
setSelectedGraphId(null)
|
||||||
|
|
@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
}
|
}
|
||||||
}, [refreshGraphs, tenantClubDepKey])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!selectedGraphId) {
|
if (!selectedGraphId) {
|
||||||
setEdges([])
|
setEdges([])
|
||||||
|
|
@ -652,6 +682,14 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
|
|
||||||
{selectedGraphId && uiTab === 'overview' && (
|
{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' }}>
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
<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 api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
|
import SkillDiscoveryPanel from '../components/skills/SkillDiscoveryPanel'
|
||||||
|
|
||||||
const SKILLS_SECTION_TABS = [
|
const SKILLS_SECTION_TABS = [
|
||||||
{ id: 'skills', label: 'Fähigkeiten' },
|
{ id: 'skills', label: 'Fähigkeiten' },
|
||||||
{ id: 'methods', label: 'Trainingsmethoden' },
|
{ id: 'methods', label: 'Trainingsmethoden' },
|
||||||
|
{ id: 'discovery', label: 'Planungs-Vorschläge' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function SkillsPage() {
|
function SkillsPage() {
|
||||||
|
|
@ -243,6 +245,8 @@ function SkillsPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'discovery' && <SkillDiscoveryPanel skills={skills} />}
|
||||||
|
|
||||||
{/* Methods Tab */}
|
{/* Methods Tab */}
|
||||||
{activeTab === 'methods' && (
|
{activeTab === 'methods' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
||||||
|
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { useNavReturn } from '../hooks/useNavReturn'
|
import { useNavReturn } from '../hooks/useNavReturn'
|
||||||
import {
|
import {
|
||||||
|
|
@ -248,6 +249,10 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
)
|
)
|
||||||
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
|
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
|
||||||
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
|
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 toast = useToast()
|
||||||
const baselineRef = useRef(null)
|
const baselineRef = useRef(null)
|
||||||
|
|
@ -301,6 +306,32 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
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 () => {
|
const loadMeta = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [cl, fa, sd, tt, tg] = await Promise.all([
|
const [cl, fa, sd, tt, tg] = await Promise.all([
|
||||||
|
|
@ -480,6 +511,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
setBypassDirty(false)
|
setBypassDirty(false)
|
||||||
setBaselineReady(true)
|
setBaselineReady(true)
|
||||||
toast.success('Gespeichert.')
|
toast.success('Gespeichert.')
|
||||||
|
setSkillProfileTick((t) => t + 1)
|
||||||
if (closeAfter) goBack()
|
if (closeAfter) goBack()
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -940,6 +972,17 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={
|
className={
|
||||||
'framework-edit__panel framework-edit__panel--meta card' +
|
'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 api from '../utils/api'
|
||||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import FormActionBar from '../components/FormActionBar'
|
import FormActionBar from '../components/FormActionBar'
|
||||||
|
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
|
||||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
@ -76,6 +77,10 @@ export default function TrainingModuleEditPage() {
|
||||||
const [methods, setMethods] = useState([])
|
const [methods, setMethods] = useState([])
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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 [title, setTitle] = useState('')
|
||||||
const [summary, setSummary] = useState('')
|
const [summary, setSummary] = useState('')
|
||||||
|
|
@ -130,6 +135,32 @@ export default function TrainingModuleEditPage() {
|
||||||
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
|
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
|
||||||
useBeforeUnloadWhen(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 { user } = useAuth()
|
||||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||||
|
|
@ -339,6 +370,7 @@ export default function TrainingModuleEditPage() {
|
||||||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||||||
setBypassDirty(false)
|
setBypassDirty(false)
|
||||||
toast.success('Gespeichert.')
|
toast.success('Gespeichert.')
|
||||||
|
setSkillProfileTick((t) => t + 1)
|
||||||
if (closeAfter) goBack()
|
if (closeAfter) goBack()
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -391,6 +423,14 @@ export default function TrainingModuleEditPage() {
|
||||||
onSubmit={handleSave}
|
onSubmit={handleSave}
|
||||||
>
|
>
|
||||||
<div className="page-form-shell__scroll">
|
<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">
|
<div className="form-row">
|
||||||
<label className="form-label">Titel *</label>
|
<label className="form-label">Titel *</label>
|
||||||
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<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 { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
|
||||||
import * as exercises from '../api/exercises.js'
|
import * as exercises from '../api/exercises.js'
|
||||||
import * as planning from '../api/planning.js'
|
import * as planning from '../api/planning.js'
|
||||||
|
import * as skillProfiles from '../api/skillProfiles.js'
|
||||||
|
|
||||||
export { ACTIVE_CLUB_STORAGE_KEY }
|
export { ACTIVE_CLUB_STORAGE_KEY }
|
||||||
export * from '../api/exercises.js'
|
export * from '../api/exercises.js'
|
||||||
export * from '../api/planning.js'
|
export * from '../api/planning.js'
|
||||||
|
export * from '../api/skillProfiles.js'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Auth
|
// Auth
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user