Update Skill Scoring Specification and Implementation to v1.2
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 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
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 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m27s
- Enhanced the skill scoring system with category grouping and a universal scale for improved comparability across programs. - Introduced new calculations for artifact share percentage and universal percent, allowing for a more nuanced understanding of skill contributions. - Updated the API to reflect changes in the skill profile structure, including main category and top skill details. - Improved frontend components to display skills by main category, enhancing user experience in skill discovery and profile visualization. - Adjusted tests to validate the new scoring logic and ensure accurate representation of skills and their weights.
This commit is contained in:
parent
8f8bdf6d8b
commit
5200895a73
|
|
@ -1,7 +1,7 @@
|
|||
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
|
||||
|
||||
**Stand:** 2026-05-20
|
||||
**Status:** Variante A (regelbasiert) umgesetzt — **v1.1** (Intensität + Stufen-Spanne, ohne Primär)
|
||||
**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
|
||||
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
|
||||
|
||||
## Ziel
|
||||
|
|
@ -55,8 +55,12 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
|
|||
|
||||
Aggregation:
|
||||
|
||||
- Summe pro `skill_id` → `weight`
|
||||
- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil)
|
||||
- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
|
||||
- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
|
||||
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
|
||||
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ)
|
||||
|
||||
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
|
||||
|
||||
## API
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from skill_scoring import (
|
|||
collect_module_exercise_occurrences,
|
||||
collect_progression_graph_exercise_occurrences,
|
||||
collect_unit_exercise_occurrences,
|
||||
compute_corpus_skill_max_weights,
|
||||
compute_skill_profile,
|
||||
match_score_for_skill_ids,
|
||||
profile_for_occurrences,
|
||||
|
|
@ -69,6 +70,13 @@ def framework_program_skill_profile(
|
|||
)
|
||||
slots_raw = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
ref_max = compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
|
||||
all_occurrences: List[ExerciseOccurrence] = []
|
||||
slot_profiles: List[Dict[str, Any]] = []
|
||||
|
||||
|
|
@ -89,7 +97,11 @@ def framework_program_skill_profile(
|
|||
all_occurrences.extend(slot_occ)
|
||||
else:
|
||||
slot_occ = []
|
||||
slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile()
|
||||
slot_profile = (
|
||||
profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max)
|
||||
if slot_occ
|
||||
else _empty_profile()
|
||||
)
|
||||
slot_profiles.append(
|
||||
{
|
||||
"slot_id": slot["id"],
|
||||
|
|
@ -101,12 +113,20 @@ def framework_program_skill_profile(
|
|||
}
|
||||
)
|
||||
|
||||
overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile()
|
||||
overall = (
|
||||
profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max)
|
||||
if all_occurrences
|
||||
else _empty_profile()
|
||||
)
|
||||
|
||||
return {
|
||||
"artifact_type": "framework_program",
|
||||
"artifact_id": framework_id,
|
||||
"artifact_title": row.get("title"),
|
||||
"reference_scale": {
|
||||
"skills_in_corpus": len(ref_max),
|
||||
"description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
|
||||
},
|
||||
"overall": overall,
|
||||
"slots": slot_profiles,
|
||||
}
|
||||
|
|
@ -122,12 +142,25 @@ def training_module_skill_profile(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _module_access(cur, module_id, profile_id, role)
|
||||
ref_max = compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
occurrences = collect_module_exercise_occurrences(cur, module_id)
|
||||
overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile()
|
||||
overall = (
|
||||
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
|
||||
if occurrences
|
||||
else _empty_profile()
|
||||
)
|
||||
return {
|
||||
"artifact_type": "training_module",
|
||||
"artifact_id": module_id,
|
||||
"artifact_title": row.get("title"),
|
||||
"reference_scale": {
|
||||
"skills_in_corpus": len(ref_max),
|
||||
},
|
||||
"overall": overall,
|
||||
}
|
||||
|
||||
|
|
@ -142,22 +175,34 @@ def progression_graph_skill_profile(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||
ref_max = compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
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()
|
||||
overall = (
|
||||
profile_for_occurrences(
|
||||
cur,
|
||||
occurrences,
|
||||
default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill=ref_max,
|
||||
)
|
||||
if occurrences
|
||||
else _empty_profile()
|
||||
)
|
||||
return {
|
||||
"artifact_type": "progression_graph",
|
||||
"artifact_id": graph_id,
|
||||
"artifact_title": row.get("name"),
|
||||
"reference_scale": {
|
||||
"skills_in_corpus": len(ref_max),
|
||||
},
|
||||
"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"),
|
||||
|
|
@ -232,11 +277,8 @@ def skill_discovery_suggestions(
|
|||
"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]
|
||||
],
|
||||
"total_score": prof.get("total_score"),
|
||||
"top_by_category": _top_categories_summary(prof),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -279,11 +321,8 @@ def skill_discovery_suggestions(
|
|||
"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]
|
||||
],
|
||||
"total_score": prof.get("total_score"),
|
||||
"top_by_category": _top_categories_summary(prof),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -328,20 +367,14 @@ def skill_discovery_suggestions(
|
|||
"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]
|
||||
],
|
||||
"total_score": prof.get("total_score"),
|
||||
"top_by_category": _top_categories_summary(prof),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(
|
||||
key=lambda x: (
|
||||
-float(x.get("match", {}).get("match_weight") or 0),
|
||||
-(float(x.get("match", {}).get("match_percent") or 0)),
|
||||
)
|
||||
key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
|
||||
)
|
||||
return {
|
||||
"skill_ids": wanted,
|
||||
|
|
@ -350,5 +383,27 @@ def skill_discovery_suggestions(
|
|||
}
|
||||
|
||||
|
||||
def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
|
||||
"""Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for mc in profile.get("by_main_category") or []:
|
||||
for cat in mc.get("categories") or []:
|
||||
top = cat.get("top_skill")
|
||||
if not top:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"main_category_name": mc.get("main_category_name"),
|
||||
"category_name": cat.get("category_name"),
|
||||
"skill_id": top.get("skill_id"),
|
||||
"skill_name": top.get("skill_name"),
|
||||
"score": top.get("score") or top.get("weight"),
|
||||
}
|
||||
)
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def _empty_profile() -> Dict[str, Any]:
|
||||
return compute_skill_profile([], {})
|
||||
|
|
|
|||
|
|
@ -116,11 +116,93 @@ def _round2(val: float) -> float:
|
|||
return round(val, 2)
|
||||
|
||||
|
||||
def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht."""
|
||||
main_map: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
for sk in skills_out:
|
||||
mc_id = int(sk.get("main_category_id") or 0)
|
||||
mc_name = (sk.get("main_category_name") or "").strip() or "—"
|
||||
cat_id = int(sk.get("category_id") or 0)
|
||||
cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or "—"
|
||||
|
||||
if mc_id not in main_map:
|
||||
main_map[mc_id] = {
|
||||
"main_category_id": mc_id if mc_id else None,
|
||||
"main_category_name": mc_name,
|
||||
"weight": 0.0,
|
||||
"categories": {},
|
||||
}
|
||||
main = main_map[mc_id]
|
||||
main["weight"] += float(sk.get("weight") or 0)
|
||||
|
||||
if cat_id not in main["categories"]:
|
||||
main["categories"][cat_id] = {
|
||||
"category_id": cat_id if cat_id else None,
|
||||
"category_name": cat_name,
|
||||
"weight": 0.0,
|
||||
"skills": [],
|
||||
}
|
||||
cat = main["categories"][cat_id]
|
||||
cat["weight"] += float(sk.get("weight") or 0)
|
||||
cat["skills"].append(sk)
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")):
|
||||
cats_out: List[Dict[str, Any]] = []
|
||||
for cat in sorted(
|
||||
mc["categories"].values(),
|
||||
key=lambda x: (-x["weight"], x.get("category_name") or ""),
|
||||
):
|
||||
cat_skills = sorted(
|
||||
cat["skills"],
|
||||
key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""),
|
||||
)
|
||||
top = cat_skills[0] if cat_skills else None
|
||||
cats_out.append(
|
||||
{
|
||||
"category_id": cat["category_id"],
|
||||
"category_name": cat["category_name"],
|
||||
"weight": _round2(cat["weight"]),
|
||||
"skills_count": len(cat_skills),
|
||||
"top_skill": top,
|
||||
}
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"main_category_id": mc["main_category_id"],
|
||||
"main_category_name": mc["main_category_name"],
|
||||
"weight": _round2(mc["weight"]),
|
||||
"categories": cats_out,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _apply_reference_universal_percent(
|
||||
skills_out: List[Dict[str, Any]],
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte).
|
||||
"""
|
||||
if not reference_max_by_skill:
|
||||
for sk in skills_out:
|
||||
sk["universal_percent"] = None
|
||||
return
|
||||
for sk in skills_out:
|
||||
sid = int(sk["skill_id"])
|
||||
ref = float(reference_max_by_skill.get(sid) or 0)
|
||||
w = float(sk.get("weight") or 0)
|
||||
sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None
|
||||
|
||||
|
||||
def compute_skill_profile(
|
||||
occurrences: Sequence[ExerciseOccurrence],
|
||||
skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
|
||||
*,
|
||||
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
|
||||
|
|
@ -168,7 +250,11 @@ def compute_skill_profile(
|
|||
skill_acc[sid] = {
|
||||
"skill_id": sid,
|
||||
"skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
|
||||
"category": link.get("category"),
|
||||
"category": link.get("category_name") or link.get("category"),
|
||||
"category_id": link.get("category_id"),
|
||||
"category_name": link.get("category_name") or link.get("category"),
|
||||
"main_category_id": link.get("main_category_id"),
|
||||
"main_category_name": link.get("main_category_name"),
|
||||
"focus_areas": link.get("focus_areas"),
|
||||
"weight": 0.0,
|
||||
"occurrence_count": 0,
|
||||
|
|
@ -205,9 +291,15 @@ def compute_skill_profile(
|
|||
{
|
||||
"skill_id": sid,
|
||||
"skill_name": acc["skill_name"],
|
||||
"category": acc.get("category"),
|
||||
"category": acc.get("category_name") or acc.get("category"),
|
||||
"category_id": acc.get("category_id"),
|
||||
"category_name": acc.get("category_name") or acc.get("category"),
|
||||
"main_category_id": acc.get("main_category_id"),
|
||||
"main_category_name": acc.get("main_category_name"),
|
||||
"focus_areas": acc.get("focus_areas"),
|
||||
"weight": _round2(acc["weight"]),
|
||||
"score": _round2(acc["weight"]),
|
||||
"artifact_share_percent": _round2(share),
|
||||
"share_percent": _round2(share),
|
||||
"occurrence_count": acc["occurrence_count"],
|
||||
"top_exercises": ex_list,
|
||||
|
|
@ -215,27 +307,32 @@ def compute_skill_profile(
|
|||
)
|
||||
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)}
|
||||
)
|
||||
_apply_reference_universal_percent(skills_out, reference_max_by_skill)
|
||||
|
||||
by_main_category = _build_by_main_category(skills_out)
|
||||
for mc in by_main_category:
|
||||
for cat in mc.get("categories") or []:
|
||||
top = cat.get("top_skill")
|
||||
if top and reference_max_by_skill:
|
||||
sid = int(top["skill_id"])
|
||||
ref = float(reference_max_by_skill.get(sid) or 0)
|
||||
if ref > 0:
|
||||
top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
|
||||
|
||||
unique_exercises = len(exercise_meta)
|
||||
return {
|
||||
"computed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"scoring_version": "1.1",
|
||||
"scoring_version": "1.2",
|
||||
"score_unit": "weighted_minutes",
|
||||
"score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)",
|
||||
"total_weight": _round2(total_weight),
|
||||
"total_score": _round2(total_weight),
|
||||
"exercise_occurrence_count": total_occurrences,
|
||||
"distinct_exercise_count": unique_exercises,
|
||||
"exercises_with_skills_count": len(exercises_with_skills),
|
||||
"skills": skills_out,
|
||||
"by_category": category_rows,
|
||||
"by_main_category": by_main_category,
|
||||
"has_reference_scale": bool(reference_max_by_skill),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -250,10 +347,15 @@ def fetch_exercise_skills_bulk(
|
|||
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,
|
||||
s.name AS skill_name, s.category,
|
||||
sc.id AS category_id, sc.name AS category_name,
|
||||
mc.id AS main_category_id, mc.name AS main_category_name,
|
||||
s.focus_areas,
|
||||
e.title AS exercise_title
|
||||
FROM exercise_skills es
|
||||
JOIN skills s ON s.id = es.skill_id
|
||||
LEFT JOIN skill_categories sc ON sc.id = s.category_id
|
||||
LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id)
|
||||
JOIN exercises e ON e.id = es.exercise_id
|
||||
WHERE es.exercise_id IN ({ph})
|
||||
AND (s.status = 'active' OR s.status IS NULL)
|
||||
|
|
@ -346,21 +448,145 @@ def profile_for_occurrences(
|
|||
occurrences: Sequence[ExerciseOccurrence],
|
||||
*,
|
||||
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
eids = [o.exercise_id for o in occurrences]
|
||||
skills_map = fetch_exercise_skills_bulk(cur, eids)
|
||||
return compute_skill_profile(
|
||||
occurrences, skills_map, default_item_minutes=default_item_minutes
|
||||
occurrences,
|
||||
skills_map,
|
||||
default_item_minutes=default_item_minutes,
|
||||
reference_max_by_skill=reference_max_by_skill,
|
||||
)
|
||||
|
||||
|
||||
def merge_skill_weights_into_max(
|
||||
target: Dict[int, float],
|
||||
profile: Dict[str, Any],
|
||||
) -> None:
|
||||
for sk in profile.get("skills") or []:
|
||||
sid = int(sk["skill_id"])
|
||||
w = float(sk.get("weight") or 0)
|
||||
if w > target.get(sid, 0.0):
|
||||
target[sid] = w
|
||||
|
||||
|
||||
def compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
*,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
effective_club_id: Optional[int],
|
||||
limit_per_type: int = 50,
|
||||
) -> Dict[int, float]:
|
||||
"""
|
||||
Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte.
|
||||
Basis für universal_percent (Skala über alle Programme).
|
||||
"""
|
||||
from tenant_context import library_content_visibility_sql
|
||||
|
||||
max_by_skill: Dict[int, float] = {}
|
||||
|
||||
def scan_frameworks():
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="fp",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT fp.id FROM training_framework_programs fp
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY fp.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(*vis_params, limit_per_type),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
fid = int(row["id"])
|
||||
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)
|
||||
merge_skill_weights_into_max(max_by_skill, prof)
|
||||
|
||||
def scan_modules():
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="m",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT m.id FROM training_modules m
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY m.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(*vis_params, limit_per_type),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
mid = int(row["id"])
|
||||
occ = collect_module_exercise_occurrences(cur, mid)
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(cur, occ)
|
||||
merge_skill_weights_into_max(max_by_skill, prof)
|
||||
|
||||
def scan_graphs():
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="g",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT g.id FROM exercise_progression_graphs g
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY g.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(*vis_params, limit_per_type),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
gid = int(row["id"])
|
||||
occ = collect_progression_graph_exercise_occurrences(cur, gid)
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(
|
||||
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
|
||||
)
|
||||
merge_skill_weights_into_max(max_by_skill, prof)
|
||||
|
||||
scan_frameworks()
|
||||
scan_modules()
|
||||
scan_graphs()
|
||||
return max_by_skill
|
||||
|
||||
|
||||
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
|
||||
"""Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge)."""
|
||||
wanted = {int(x) for x in skill_ids if x is not None}
|
||||
if not wanted:
|
||||
return {
|
||||
"match_weight": 0.0,
|
||||
"match_score": 0.0,
|
||||
"match_percent": 0.0,
|
||||
"artifact_focus_percent": 0.0,
|
||||
"matched_skill_ids": [],
|
||||
"matched_skills": [],
|
||||
}
|
||||
|
|
@ -372,10 +598,12 @@ def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int])
|
|||
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
|
||||
artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0
|
||||
return {
|
||||
"match_weight": _round2(match_weight),
|
||||
"match_percent": _round2(match_percent),
|
||||
"match_score": _round2(match_weight),
|
||||
"match_percent": _round2(artifact_focus),
|
||||
"artifact_focus_percent": _round2(artifact_focus),
|
||||
"matched_skill_ids": [int(m["skill_id"]) for m in matched],
|
||||
"matched_skills": matched,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ def test_compute_skill_profile_aggregates_weights():
|
|||
"skill_id": 10,
|
||||
"skill_name": "Distanz",
|
||||
"category": "kihon",
|
||||
"category_id": 1,
|
||||
"category_name": "kihon",
|
||||
"main_category_id": 100,
|
||||
"main_category_name": "Technik",
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
|
|
@ -49,6 +53,10 @@ def test_compute_skill_profile_aggregates_weights():
|
|||
"skill_id": 11,
|
||||
"skill_name": "Balance",
|
||||
"category": "kihon",
|
||||
"category_id": 1,
|
||||
"category_name": "kihon",
|
||||
"main_category_id": 100,
|
||||
"main_category_name": "Technik",
|
||||
"intensity": "niedrig",
|
||||
"required_level": "basis",
|
||||
"target_level": "basis",
|
||||
|
|
@ -57,13 +65,40 @@ def test_compute_skill_profile_aggregates_weights():
|
|||
],
|
||||
}
|
||||
profile = compute_skill_profile(occurrences, skills_map)
|
||||
assert profile["scoring_version"] == "1.1"
|
||||
assert profile["scoring_version"] == "1.2"
|
||||
assert profile["exercise_occurrence_count"] == 2
|
||||
assert profile["distinct_exercise_count"] == 1
|
||||
assert len(profile["skills"]) == 2
|
||||
assert profile["skills"][0]["skill_id"] == 10
|
||||
assert profile["total_weight"] > profile["skills"][1]["weight"]
|
||||
assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1
|
||||
assert len(profile["by_main_category"]) == 1
|
||||
assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10
|
||||
|
||||
|
||||
def test_universal_percent_against_corpus_max():
|
||||
occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)]
|
||||
skills_map = {
|
||||
1: [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"skill_name": "Koordination",
|
||||
"category_name": "Koordination",
|
||||
"category_id": 2,
|
||||
"main_category_id": 200,
|
||||
"main_category_name": "Körper",
|
||||
},
|
||||
],
|
||||
}
|
||||
profile = compute_skill_profile(
|
||||
occurrences,
|
||||
skills_map,
|
||||
reference_max_by_skill={10: 100.0},
|
||||
)
|
||||
assert profile["has_reference_scale"] is True
|
||||
assert profile["skills"][0]["universal_percent"] == 50.0
|
||||
top = profile["by_main_category"][0]["categories"][0]["top_skill"]
|
||||
assert top["universal_percent"] == 50.0
|
||||
|
||||
|
||||
def test_match_score_for_skill_ids():
|
||||
|
|
@ -76,5 +111,7 @@ def test_match_score_for_skill_ids():
|
|||
}
|
||||
m = match_score_for_skill_ids(profile, [1])
|
||||
assert m["match_weight"] == 40.0
|
||||
assert m["match_score"] == 40.0
|
||||
assert m["match_percent"] == 40.0
|
||||
assert m["artifact_focus_percent"] == 40.0
|
||||
assert m["matched_skill_ids"] == [1]
|
||||
|
|
|
|||
|
|
@ -2591,6 +2591,46 @@ html.modal-scroll-locked .app-main {
|
|||
color: var(--text3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.skill-profile__by-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.skill-profile__main-cat {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.skill-profile__main-cat-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.skill-profile__cat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.skill-profile__cat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.skill-profile__cat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-profile__cat-row {
|
||||
list-style: none;
|
||||
}
|
||||
.skill-profile__categories {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
|
@ -2744,6 +2784,25 @@ html.modal-scroll-locked .app-main {
|
|||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
}
|
||||
.skill-discovery__result-cats {
|
||||
list-style: none;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
}
|
||||
.skill-discovery__result-cats li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.skill-discovery__result-cat-name {
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-discovery__result-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ const ARTIFACT_LABELS = {
|
|||
progression_graph: 'Regressionspfad',
|
||||
}
|
||||
|
||||
function formatScore(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
|
||||
*/
|
||||
|
|
@ -60,8 +66,8 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
|
|||
<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).
|
||||
Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
|
||||
Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
|
|
@ -122,35 +128,51 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
|
|||
|
||||
{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>
|
||||
))}
|
||||
{result.suggestions.map((item) => {
|
||||
const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
|
||||
const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
|
||||
const topByCat = item.skill_profile_summary?.top_by_category || []
|
||||
return (
|
||||
<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" title="Summe der Trainingsgewichte der gewählten Fähigkeiten">
|
||||
Gewicht {formatScore(matchScore)}
|
||||
</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(' · ')}
|
||||
{focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
|
||||
</p>
|
||||
) : null}
|
||||
{topByCat.length > 0 ? (
|
||||
<ul className="skill-discovery__result-cats">
|
||||
{topByCat.slice(0, 4).map((row) => (
|
||||
<li key={`${row.category_name}-${row.skill_id}`}>
|
||||
<span className="skill-discovery__result-cat-name">{row.category_name}</span>
|
||||
<span>{row.skill_name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : 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">
|
||||
|
|
|
|||
|
|
@ -1,25 +1,107 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
function SkillBar({ skill, maxShare }) {
|
||||
const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0
|
||||
function skillWeight(skill) {
|
||||
return Number(skill?.weight ?? skill?.score ?? 0)
|
||||
}
|
||||
|
||||
function formatWeight(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
function barFillPercent(skill, maxWeight, hasReferenceScale) {
|
||||
if (hasReferenceScale && skill?.universal_percent != null) {
|
||||
return Math.min(100, Number(skill.universal_percent))
|
||||
}
|
||||
const w = skillWeight(skill)
|
||||
return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
|
||||
}
|
||||
|
||||
function metricLabel(skill, hasReferenceScale) {
|
||||
if (hasReferenceScale && skill?.universal_percent != null) {
|
||||
return `${skill.universal_percent}% Bibliothek`
|
||||
}
|
||||
return formatWeight(skillWeight(skill))
|
||||
}
|
||||
|
||||
function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
|
||||
if (!skill) return null
|
||||
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
|
||||
return (
|
||||
<li className="skill-profile__row">
|
||||
<li className="skill-profile__cat-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>
|
||||
<span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)">
|
||||
{metricLabel(skill, hasReferenceScale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="skill-profile__bar-track" aria-hidden="true">
|
||||
<div
|
||||
className="skill-profile__bar-fill"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="skill-profile__bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{hasReferenceScale ? (
|
||||
<span className="skill-profile__meta-hint">
|
||||
Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryGroupedProfile({ profile, ariaLabel }) {
|
||||
const groups = profile?.by_main_category || []
|
||||
const hasReferenceScale = Boolean(profile?.has_reference_scale)
|
||||
const maxWeight = useMemo(() => {
|
||||
let max = 1
|
||||
for (const mc of groups) {
|
||||
for (const cat of mc.categories || []) {
|
||||
const w = skillWeight(cat.top_skill)
|
||||
if (w > max) max = w
|
||||
}
|
||||
}
|
||||
return max
|
||||
}, [groups])
|
||||
|
||||
if (!groups.length) return null
|
||||
|
||||
return (
|
||||
<div className="skill-profile__by-category" aria-label={ariaLabel}>
|
||||
{groups.map((mc) => (
|
||||
<section key={mc.main_category_id ?? mc.main_category_name} className="skill-profile__main-cat">
|
||||
<h4 className="skill-profile__main-cat-title">{mc.main_category_name}</h4>
|
||||
<ul className="skill-profile__cat-list">
|
||||
{(mc.categories || []).map((cat) => (
|
||||
<li key={cat.category_id ?? cat.category_name} className="skill-profile__cat-item">
|
||||
<span className="skill-profile__cat-label">{cat.category_name}</span>
|
||||
<CategoryTopSkill
|
||||
skill={cat.top_skill}
|
||||
maxWeight={maxWeight}
|
||||
hasReferenceScale={hasReferenceScale}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function topCategoryBadge(profile) {
|
||||
const parts = []
|
||||
for (const mc of profile?.by_main_category || []) {
|
||||
for (const cat of mc.categories || []) {
|
||||
const top = cat.top_skill
|
||||
if (!top) continue
|
||||
parts.push(`${cat.category_name}: ${top.skill_name}`)
|
||||
if (parts.length >= 2) return parts.join(' · ')
|
||||
}
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
|
||||
*/
|
||||
|
|
@ -29,17 +111,13 @@ export default function SkillProfilePanel({
|
|||
loading = false,
|
||||
error = '',
|
||||
title = 'Fähigkeiten-Profil',
|
||||
hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis).',
|
||||
hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.',
|
||||
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]
|
||||
)
|
||||
const badge = useMemo(() => topCategoryBadge(profile), [profile])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -61,6 +139,11 @@ export default function SkillProfilePanel({
|
|||
!profile ||
|
||||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
|
||||
|
||||
const categoryCount = (profile?.by_main_category || []).reduce(
|
||||
(n, mc) => n + (mc.categories?.length || 0),
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="card skill-profile">
|
||||
<button
|
||||
|
|
@ -70,10 +153,8 @@ export default function SkillProfilePanel({
|
|||
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>
|
||||
{!noData && badge ? (
|
||||
<span className="skill-profile__toggle-badge">{badge}</span>
|
||||
) : null}
|
||||
<span className="skill-profile__toggle-icon" aria-hidden="true">
|
||||
{expanded ? '▾' : '▸'}
|
||||
|
|
@ -101,31 +182,20 @@ export default function SkillProfilePanel({
|
|||
<strong>{profile.distinct_exercise_count}</strong> Übungen
|
||||
</span>
|
||||
<span>
|
||||
<strong>{skills.length}</strong> Fähigkeiten
|
||||
<strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
|
||||
</span>
|
||||
<span>
|
||||
<strong>{profile.exercise_occurrence_count}</strong> Positionen
|
||||
<strong>{categoryCount}</strong> Kategorien
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatWeight(profile.total_score ?? profile.total_weight)}</strong> Gesamt-Gewicht
|
||||
</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}
|
||||
<CategoryGroupedProfile
|
||||
profile={profile}
|
||||
ariaLabel="Top-Fähigkeit je Kategorie"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -135,7 +205,7 @@ export default function SkillProfilePanel({
|
|||
<ul className="skill-profile__slot-list">
|
||||
{slots.map((sl) => {
|
||||
const open = slotOpenId === sl.slot_id
|
||||
const top = sl.profile?.skills?.[0]
|
||||
const slotBadge = topCategoryBadge(sl.profile)
|
||||
return (
|
||||
<li key={sl.slot_id} className="skill-profile__slot-item">
|
||||
<button
|
||||
|
|
@ -147,29 +217,19 @@ export default function SkillProfilePanel({
|
|||
<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>
|
||||
{slotBadge ? (
|
||||
<span className="skill-profile__slot-top">{slotBadge}</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>
|
||||
{open && sl.profile?.by_main_category?.length > 0 ? (
|
||||
<CategoryGroupedProfile
|
||||
profile={sl.profile}
|
||||
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
{!isNew ? (
|
||||
<SkillProfilePanel
|
||||
title="Fähigkeiten-Schwerpunkte (aus Übungen)"
|
||||
hint="Gewichtung nach geplanter Dauer, Häufigkeit, Intensität (niedrig/mittel/hoch) und Stufen-Spanne (von/bis). Vergleichbar mit manuell gesetzten Fokusbereichen in den Stammdaten."
|
||||
hint="Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit — Balken relativ zur Bibliothek, nicht 100 % innerhalb des Plans."
|
||||
profile={skillProfileData?.overall}
|
||||
slots={skillProfileData?.slots}
|
||||
loading={skillProfileLoading}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user