Enhance Skill Scoring and Profile Features
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s

- Updated the skill scoring specification to include club-specific metrics and improved aggregation methods for skill profiles.
- Introduced new API endpoints for batch skill profile summaries, allowing for efficient retrieval of compact skill data.
- Enhanced frontend components to display skill profiles with club comparisons, improving user interaction and visibility of skill strengths.
- Added filtering options for skills in the framework programs, enabling users to refine selections based on training weight relative to club maximums.
- Improved CSS styles for skill profile displays, ensuring a cohesive and user-friendly interface across the application.
This commit is contained in:
Lars 2026-05-21 09:05:13 +02:00
parent 5200895a73
commit 78c6c51520
15 changed files with 1131 additions and 141 deletions

View File

@ -58,7 +58,9 @@ Aggregation:
- 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)
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.

View File

@ -17,10 +17,13 @@ from skill_scoring import (
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
match_score_for_skill_ids,
profile_for_occurrences,
reference_scale_meta,
top_categories_summary,
)
from routers.training_framework_programs import _framework_access
@ -70,12 +73,9 @@ 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,
)
corpus = _load_club_corpus(cur, tenant)
ref_max = corpus["max_by_skill"]
ref_by_skill = corpus["ref_by_skill"]
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
@ -118,14 +118,22 @@ def framework_program_skill_profile(
if all_occurrences
else _empty_profile()
)
_enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id)
for slot in slot_profiles:
_enrich_profile_club_best(
slot.get("profile") or {},
ref_by_skill,
"framework_program",
framework_id,
)
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",
"reference_scale": reference_scale_meta(corpus),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
"slots": slot_profiles,
@ -142,24 +150,23 @@ 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,
)
corpus = _load_club_corpus(cur, tenant)
ref_max = corpus["max_by_skill"]
ref_by_skill = corpus["ref_by_skill"]
occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
if occurrences
else _empty_profile()
)
_enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id)
return {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
"reference_scale": reference_scale_meta(corpus),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
@ -175,12 +182,9 @@ 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,
)
corpus = _load_club_corpus(cur, tenant)
ref_max = corpus["max_by_skill"]
ref_by_skill = corpus["ref_by_skill"]
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = (
profile_for_occurrences(
@ -192,17 +196,96 @@ def progression_graph_skill_profile(
if occurrences
else _empty_profile()
)
_enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id)
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
"reference_scale": reference_scale_meta(corpus),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
@router.post("/skill-profiles/batch-summaries")
def batch_skill_profile_summaries(
data: dict,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL).
Body: { framework_program_ids?: number[], training_module_ids?: number[] }
"""
fp_ids = _parse_id_list(data.get("framework_program_ids"))
mod_ids = _parse_id_list(data.get("training_module_ids"))
if not fp_ids and not mod_ids:
raise HTTPException(
status_code=400,
detail="framework_program_ids oder training_module_ids erforderlich",
)
profile_id = tenant.profile_id
role = tenant.global_role
summaries: Dict[str, Dict[str, Any]] = {}
with get_db() as conn:
cur = get_cursor(conn)
corpus = compute_club_corpus_reference(
cur,
profile_id=tenant.profile_id,
effective_club_id=tenant.effective_club_id,
include_artifact_summaries=True,
)
ref_by_skill = corpus["ref_by_skill"]
all_summaries = corpus.get("artifact_summaries") or {}
if fp_ids:
allowed_fp = []
for fid in fp_ids:
try:
_framework_access(cur, fid, profile_id, role)
allowed_fp.append(fid)
except HTTPException:
pass
for fid in allowed_fp:
key = f"framework_program:{fid}"
if key in all_summaries:
summaries[key] = all_summaries[key]
if mod_ids:
allowed_mod = []
for mid in mod_ids:
try:
_module_access(cur, mid, profile_id, role)
allowed_mod.append(mid)
except HTTPException:
pass
for mid in allowed_mod:
key = f"training_module:{mid}"
if key in all_summaries:
summaries[key] = all_summaries[key]
skill_ids_seen: set[int] = set()
for summary in summaries.values():
for sk in summary.get("skills") or []:
if sk.get("skill_id") is not None:
skill_ids_seen.add(int(sk["skill_id"]))
club_best_subset = {
str(sid): ref_by_skill[sid]
for sid in skill_ids_seen
if sid in ref_by_skill
}
return {
"reference_scale": reference_scale_meta(corpus),
"club_best_by_skill": club_best_subset,
"summaries": summaries,
}
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
@ -278,7 +361,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
"top_by_category": top_categories_summary(prof),
},
}
)
@ -322,7 +405,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
"top_by_category": top_categories_summary(prof),
},
}
)
@ -368,7 +451,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
"top_by_category": top_categories_summary(prof),
},
}
)
@ -405,5 +488,66 @@ def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dic
return out
def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
if not raw:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein")
out: List[int] = []
for item in raw:
try:
n = int(item)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None
if n > 0 and n not in out:
out.append(n)
if len(out) >= max_count:
break
return out
def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
return compute_club_corpus_reference(
cur,
profile_id=tenant.profile_id,
effective_club_id=tenant.effective_club_id,
)
def _enrich_profile_club_best(
profile: Dict[str, Any],
ref_by_skill: Dict[int, Dict[str, Any]],
artifact_type: Optional[str] = None,
artifact_id: Optional[int] = None,
) -> None:
"""Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze)."""
if not profile or not ref_by_skill:
return
def attach(sk: Optional[Dict[str, Any]]) -> None:
if not sk or sk.get("skill_id") is None:
return
sid = int(sk["skill_id"])
ref = ref_by_skill.get(sid)
if not ref:
return
if (
artifact_type
and artifact_id is not None
and ref.get("artifact_type") == artifact_type
and int(ref.get("artifact_id") or 0) == int(artifact_id)
):
return
w = float(sk.get("weight") or 0)
if w < float(ref.get("weight") or 0) - 0.01:
sk["club_best"] = ref
for sk in profile.get("skills") or []:
attach(sk)
for mc in profile.get("by_main_category") or []:
for cat in mc.get("categories") or []:
attach(cat.get("top_skill"))
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})

View File

@ -471,6 +471,337 @@ def merge_skill_weights_into_max(
target[sid] = w
def merge_skill_weights_with_reference(
max_by_skill: Dict[int, float],
ref_by_skill: Dict[int, Dict[str, Any]],
profile: Dict[str, Any],
*,
artifact_type: str,
artifact_id: int,
artifact_title: Optional[str] = None,
) -> None:
"""Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt."""
for sk in profile.get("skills") or []:
sid = int(sk["skill_id"])
w = float(sk.get("weight") or 0)
if w <= 0:
continue
if w > max_by_skill.get(sid, 0.0):
max_by_skill[sid] = w
ref_by_skill[sid] = {
"artifact_type": artifact_type,
"artifact_id": int(artifact_id),
"artifact_title": (artifact_title or "").strip() or None,
"weight": _round2(w),
}
def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
"""Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery)."""
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"),
"weight": top.get("weight"),
"universal_percent": top.get("universal_percent"),
}
)
if len(out) >= limit:
return out
return out
def _apply_reference_to_profile(
profile: Dict[str, Any],
reference_max_by_skill: Optional[Dict[int, float]],
) -> None:
_apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill)
profile["by_main_category"] = _build_by_main_category(profile.get("skills") or [])
for mc in profile.get("by_main_category") or []:
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)
profile["has_reference_scale"] = bool(reference_max_by_skill)
def compact_profile_summary(
profile: Dict[str, Any],
ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
*,
skills_limit: int = 20,
) -> Dict[str, Any]:
"""Leichtgewichtiges Profil für Listen — ohne Übungsdetails."""
skills_out: List[Dict[str, Any]] = []
for s in profile.get("skills") or []:
sid = int(s["skill_id"])
w = float(s.get("weight") or 0)
entry: Dict[str, Any] = {
"skill_id": sid,
"skill_name": s.get("skill_name"),
"category_name": s.get("category_name") or s.get("category"),
"main_category_name": s.get("main_category_name"),
"weight": s.get("weight"),
"universal_percent": s.get("universal_percent"),
}
ref = (ref_by_skill or {}).get(sid)
if ref and w < float(ref.get("weight") or 0) - 0.01:
entry["club_best"] = ref
skills_out.append(entry)
if len(skills_out) >= skills_limit:
break
return {
"total_score": profile.get("total_score"),
"total_weight": profile.get("total_weight"),
"exercise_occurrence_count": profile.get("exercise_occurrence_count"),
"skills_count": len(profile.get("skills") or []),
"top_by_category": top_categories_summary(profile),
"skills": skills_out,
}
def batch_framework_occurrences_by_id(
cur, framework_ids: Sequence[int]
) -> Dict[int, List[ExerciseOccurrence]]:
ids = sorted({int(x) for x in framework_ids if x})
if not ids:
return {}
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"""
SELECT s.framework_program_id,
COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label,
tusi.exercise_id,
tusi.planned_duration_min
FROM training_framework_slots s
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
WHERE s.framework_program_id IN ({ph})
AND tusi.item_type = 'exercise'
AND tusi.exercise_id IS NOT NULL
ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index
""",
ids,
)
out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
for row in cur.fetchall():
fid = int(row["framework_program_id"])
out[fid].append(
ExerciseOccurrence(
exercise_id=int(row["exercise_id"]),
planned_duration_min=row.get("planned_duration_min"),
context_label=row.get("slot_label"),
)
)
return dict(out)
def batch_module_occurrences_by_id(
cur, module_ids: Sequence[int]
) -> Dict[int, List[ExerciseOccurrence]]:
ids = sorted({int(x) for x in module_ids if x})
if not ids:
return {}
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"""
SELECT module_id, exercise_id, planned_duration_min
FROM training_module_items
WHERE module_id IN ({ph})
AND item_type = 'exercise'
AND exercise_id IS NOT NULL
ORDER BY module_id, order_index
""",
ids,
)
out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
for row in cur.fetchall():
mid = int(row["module_id"])
out[mid].append(
ExerciseOccurrence(
exercise_id=int(row["exercise_id"]),
planned_duration_min=row.get("planned_duration_min"),
)
)
return dict(out)
def batch_compute_profiles(
occ_by_artifact: Dict[int, List[ExerciseOccurrence]],
skills_map: Dict[int, List[Dict[str, Any]]],
*,
reference_max_by_skill: Optional[Dict[int, float]] = None,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
) -> Dict[int, Dict[str, Any]]:
return {
aid: compute_skill_profile(
occ,
skills_map,
default_item_minutes=default_item_minutes,
reference_max_by_skill=reference_max_by_skill,
)
for aid, occ in occ_by_artifact.items()
}
def compute_club_corpus_reference(
cur,
*,
profile_id: int,
effective_club_id: Optional[int],
include_artifact_summaries: bool = False,
) -> Dict[str, Any]:
"""
Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein).
Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen).
"""
from tenant_context import club_library_visibility_sql
max_by_skill: Dict[int, float] = {}
ref_by_skill: Dict[int, Dict[str, Any]] = {}
artifact_count = 0
raw_profiles: Dict[str, Dict[str, Any]] = {}
if effective_club_id is None:
return {
"club_id": None,
"max_by_skill": max_by_skill,
"ref_by_skill": ref_by_skill,
"artifact_count": 0,
"artifact_summaries": {},
}
def ingest(artifact_type: str, artifact_id: int, title: Optional[str], prof: Dict[str, Any]) -> None:
nonlocal artifact_count
if not prof.get("skills"):
return
artifact_count += 1
merge_skill_weights_with_reference(
max_by_skill,
ref_by_skill,
prof,
artifact_type=artifact_type,
artifact_id=artifact_id,
artifact_title=title,
)
if include_artifact_summaries:
raw_profiles[f"{artifact_type}:{artifact_id}"] = prof
vis_clause, vis_params = club_library_visibility_sql(
alias="fp",
profile_id=profile_id,
effective_club_id=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
""",
vis_params,
)
fw_rows = cur.fetchall()
if fw_rows:
fw_ids = [int(r["id"]) for r in fw_rows]
titles = {int(r["id"]): r.get("title") for r in fw_rows}
occ_map = batch_framework_occurrences_by_id(cur, fw_ids)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids)
profiles = batch_compute_profiles(occ_map, skills_map)
for fid, prof in profiles.items():
ingest("framework_program", fid, titles.get(fid), prof)
vis_clause, vis_params = club_library_visibility_sql(
alias="m",
profile_id=profile_id,
effective_club_id=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
""",
vis_params,
)
mod_rows = cur.fetchall()
if mod_rows:
mod_ids = [int(r["id"]) for r in mod_rows]
titles = {int(r["id"]): r.get("title") for r in mod_rows}
occ_map = batch_module_occurrences_by_id(cur, mod_ids)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids)
profiles = batch_compute_profiles(occ_map, skills_map)
for mid, prof in profiles.items():
ingest("training_module", mid, titles.get(mid), prof)
vis_clause, vis_params = club_library_visibility_sql(
alias="g",
profile_id=profile_id,
effective_club_id=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
""",
vis_params,
)
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
)
ingest("progression_graph", gid, row.get("name"), prof)
artifact_summaries: Dict[str, Dict[str, Any]] = {}
if include_artifact_summaries and raw_profiles:
for key, prof in raw_profiles.items():
_apply_reference_to_profile(prof, max_by_skill)
artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
return {
"club_id": effective_club_id,
"max_by_skill": max_by_skill,
"ref_by_skill": ref_by_skill,
"artifact_count": artifact_count,
"artifact_summaries": artifact_summaries,
}
def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]:
return {
"scope": "club",
"club_id": corpus.get("club_id"),
"skills_in_corpus": len(corpus.get("max_by_skill") or {}),
"artifacts_scanned": corpus.get("artifact_count") or 0,
"description": (
"universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit "
"(nur visibility=club im aktiven Verein)"
),
}
def compute_corpus_skill_max_weights(
cur,
*,
@ -480,102 +811,16 @@ def compute_corpus_skill_max_weights(
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).
Vereins-Referenz je Fähigkeit (Legacy-Hülle nutzt compute_club_corpus_reference).
role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte).
"""
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
del role, limit_per_type
corpus = compute_club_corpus_reference(
cur,
profile_id=profile_id,
effective_club_id=effective_club_id,
)
return corpus["max_by_skill"]
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:

View File

@ -107,6 +107,33 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
def club_library_visibility_sql(
*,
alias: str,
profile_id: int,
effective_club_id: Optional[int],
) -> tuple[str, List[Any]]:
"""
Nur Inhalte des aktiven Vereins (visibility=club, club_id=active).
Für Skill-Vergleiche im Vereinskontext ohne official/private anderer Mandanten.
"""
if effective_club_id is None:
return "(1=0)", []
return (
f"""(
{alias}.visibility = 'club'
AND {alias}.club_id = %s
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = {alias}.club_id
AND cm.status = 'active'
)
)""",
[effective_club_id, profile_id],
)
@dataclass
class TenantContext:
profile_id: int

View File

@ -24,3 +24,17 @@ export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
if (opts.limit != null) params.set('limit', String(opts.limit))
return request(`/api/skill-discovery/suggestions?${params.toString()}`)
}
/**
* Batch-Summaries für Listen (ein Vereins-Corpus-Scan).
* @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload
*/
export async function batchSkillProfileSummaries(payload = {}) {
return request('/api/skill-profiles/batch-summaries', {
method: 'POST',
body: JSON.stringify({
framework_program_ids: payload.frameworkProgramIds || [],
training_module_ids: payload.trainingModuleIds || [],
}),
})
}

View File

@ -2690,6 +2690,107 @@ html.modal-scroll-locked .app-main {
font-weight: 500;
}
.skill-profile-compact {
margin-top: 4px;
}
.skill-profile-compact__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-compact__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.skill-profile-compact__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-radius: 8px;
background: var(--surface2);
border: 1px solid var(--border);
}
.skill-profile-compact__name {
font-weight: 600;
font-size: 0.86rem;
color: var(--text1);
}
.skill-profile-compact__metric {
font-size: 0.78rem;
font-weight: 700;
color: var(--accent-dark);
}
.skill-profile-compact__best {
margin: 0;
font-size: 0.72rem;
line-height: 1.35;
}
.fw-prog-card__section-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.fw-prog-card__section--skills {
margin-top: 0.5rem;
}
.fw-import-skill-grid {
max-height: 180px;
overflow-y: auto;
}
.skill-profile-modal {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
overflow-y: auto;
}
.skill-profile-modal__backdrop {
position: fixed;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.skill-profile-modal__panel {
position: relative;
z-index: 1;
width: min(42rem, 100%);
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 1rem 1.1rem 1.25rem;
margin-top: 2vh;
}
.skill-profile-modal__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 0.75rem;
}
.skill-profile-modal__title {
margin: 0;
font-size: 1.05rem;
}
.skill-profile-modal__scale {
margin: 0.5rem 0 0;
}
.skill-discovery {
padding: 1.15rem 1.25rem;
max-width: 52rem;

View File

@ -1,5 +1,6 @@
import React from 'react'
import NavStateLink from '../NavStateLink'
import SkillProfileCompact from '../skills/SkillProfileCompact'
import {
frameworkSessionDurationLabel,
splitFrameworkCommaAgg,
@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) {
/**
* Einzelkarte für die Rahmenprogramm-Bibliothek.
*/
export default function FrameworkProgramListCard({ row, returnContext, onDelete }) {
export default function FrameworkProgramListCard({
row,
returnContext,
onDelete,
skillSummary = null,
skillSummaryLoading = false,
skillFilterIds = [],
skillDisplayLimit = 6,
onShowSkillProfile,
}) {
const title = (row.title || '').trim() || `Rahmen #${row.id}`
const description = (row.description || '').trim()
const durationLabel = frameworkSessionDurationLabel(row)
@ -112,6 +122,27 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete
</section>
) : null}
<section className="fw-prog-card__section fw-prog-card__section--skills">
<div className="fw-prog-card__section-head">
<h3 className="fw-prog-card__section-title">Fähigkeiten</h3>
{onShowSkillProfile ? (
<button
type="button"
className="btn btn-secondary btn-small fw-prog-card__skills-btn"
onClick={() => onShowSkillProfile(row)}
>
Vollständiges Profil
</button>
) : null}
</div>
<SkillProfileCompact
summary={skillSummary}
skillIds={skillFilterIds}
loading={skillSummaryLoading}
displayLimit={skillDisplayLimit}
/>
</section>
<footer className="fw-prog-card__actions">
<NavStateLink
to={`/planning/framework-programs/${row.id}`}

View File

@ -20,6 +20,8 @@ export default function FrameworkProgramsFilterBlock({
catalogFocusAreas = [],
catalogTrainingTypes = [],
catalogTargetGroups = [],
catalogSkills = [],
skillSummaries = null,
disabled = false,
durationRadioName = 'fw-duration-mode',
showHint = true,
@ -31,8 +33,8 @@ export default function FrameworkProgramsFilterBlock({
)
const matchCount = useMemo(
() => filterFrameworkPrograms(programs, filters).length,
[programs, filters]
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
[programs, filters, skillSummaries]
)
const totalCount = (programs || []).length
@ -43,8 +45,9 @@ export default function FrameworkProgramsFilterBlock({
focusAreas: catalogFocusAreas,
trainingTypes: catalogTrainingTypes,
targetGroups: catalogTargetGroups,
skills: catalogSkills,
}),
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills]
)
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
@ -285,6 +288,61 @@ export default function FrameworkProgramsFilterBlock({
</div>
</div>
) : null}
{catalogSkills.length > 0 ? (
<div className="fw-import-catalog-block fw-import-catalog-block--skills">
<span className="form-label">Fähigkeiten (Vereinsvergleich)</span>
<p className="form-sub" style={{ margin: '0 0 8px' }}>
Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit ohne
Punktewerte eingeben.
</p>
<div className="framework-catalog-checkgrid fw-import-skill-grid">
{catalogSkills.map((sk) => (
<label key={sk.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.skillIds || []).includes(String(sk.id))}
onChange={() => toggleId('skillIds', sk.id)}
disabled={disabled}
/>
<span>{sk.name}</span>
</label>
))}
</div>
{(filters.skillIds || []).length > 0 ? (
<div className="fw-import-skill-options">
<div className="form-row" style={{ marginBottom: 8 }}>
<label className="form-label">Mindest-Anteil am Vereins-Maximum</label>
<select
className="form-input"
value={String(filters.skillMinClubPercent ?? 0)}
disabled={disabled}
onChange={(e) =>
updateFilter({ skillMinClubPercent: Number(e.target.value) || 0 })
}
>
<option value="0">Kein Minimum (nur markieren)</option>
<option value="25">mind. 25%</option>
<option value="50">mind. 50%</option>
<option value="75">mind. 75%</option>
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Sortierung</label>
<select
className="form-input"
value={filters.skillSort || 'title'}
disabled={disabled}
onChange={(e) => updateFilter({ skillSort: e.target.value })}
>
<option value="title">Bibliotheks-Reihenfolge</option>
<option value="skill_strength">Stärkste gewählte Fähigkeit zuerst</option>
</select>
</div>
</div>
) : null}
</div>
) : null}
</div>
{showHint ? (
<p className="form-sub fw-import-filter-panel__hint">

View File

@ -0,0 +1,67 @@
import React from 'react'
import {
artifactPath,
artifactTypeLabel,
compactSkillDisplayRows,
formatClubPercent,
} from '../../utils/skillProfileListHelpers'
function formatWeight(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
/**
* Kompakte Fähigkeiten-Zeile für Listen/Kacheln.
*/
export default function SkillProfileCompact({
summary,
skillIds = [],
loading = false,
emptyText = 'Noch keine Übungen mit Fähigkeiten',
displayLimit = 6,
showClubBest = true,
}) {
if (loading) {
return <p className="skill-profile-compact skill-profile-compact--loading form-sub">Fähigkeiten werden berechnet</p>
}
const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit })
if (!rows.length) {
return summary ? (
<p className="skill-profile-compact skill-profile-compact--empty form-sub">{emptyText}</p>
) : null
}
return (
<div className="skill-profile-compact">
<span className="skill-profile-compact__label">Fähigkeiten</span>
<ul className="skill-profile-compact__list">
{rows.map((sk) => {
const best = sk.club_best
const path = showClubBest && best ? artifactPath(best) : null
return (
<li key={sk.skill_id} className="skill-profile-compact__item">
<span className="skill-profile-compact__name" title={sk.skill_name}>
{sk.skill_name}
</span>
<span className="skill-profile-compact__metric" title="Trainingsgewicht und Anteil am Vereins-Maximum">
{formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein
</span>
{showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? (
<span className="skill-profile-compact__best form-sub">
Vereins-Top: {artifactTypeLabel(best.artifact_type)} {best.artifact_title || best.artifact_id} (
{formatWeight(best.weight)})
</span>
) : sk.universal_percent >= 100 ? (
<span className="skill-profile-compact__best form-sub">Stärkste Vereins-Nutzung dieser Fähigkeit</span>
) : null}
</li>
)
})}
</ul>
</div>
)
}

View File

@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react'
import api from '../../utils/api'
import SkillProfilePanel from './SkillProfilePanel'
/**
* Vollständiges Fähigkeiten-Profil in einem Modal (Listen-Kontext).
*/
export default function SkillProfileFullModal({
open,
onClose,
artifactType = 'framework_program',
artifactId,
title = 'Fähigkeiten-Profil',
}) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [data, setData] = useState(null)
useEffect(() => {
if (!open || !artifactId) return undefined
let cancelled = false
setLoading(true)
setError('')
setData(null)
const load =
artifactType === 'training_module'
? api.getTrainingModuleSkillProfile(artifactId)
: artifactType === 'progression_graph'
? api.getProgressionGraphSkillProfile(artifactId)
: api.getFrameworkProgramSkillProfile(artifactId)
load
.then((res) => {
if (!cancelled) setData(res)
})
.catch((e) => {
if (!cancelled) setError(e.message || 'Profil konnte nicht geladen werden')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, artifactId, artifactType])
if (!open) return null
return (
<div className="skill-profile-modal" role="dialog" aria-modal="true" aria-labelledby="skill-profile-modal-title">
<button type="button" className="skill-profile-modal__backdrop" aria-label="Schließen" onClick={onClose} />
<div className="skill-profile-modal__panel card">
<header className="skill-profile-modal__head">
<h2 id="skill-profile-modal-title" className="skill-profile-modal__title">
{title}
</h2>
<button type="button" className="btn btn-secondary btn-small" onClick={onClose}>
Schließen
</button>
</header>
<SkillProfilePanel
profile={data?.overall}
slots={artifactType === 'framework_program' ? data?.slots : null}
loading={loading}
error={error}
title="Vollständiges Profil"
defaultExpanded
/>
{data?.reference_scale?.scope === 'club' && !loading ? (
<p className="form-sub skill-profile-modal__scale">
Vergleichsbasis: {data.reference_scale.artifacts_scanned ?? 0} Vereins-Artefakte (
{data.reference_scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
</p>
) : null}
</div>
</div>
)
}

View File

@ -20,7 +20,7 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
function metricLabel(skill, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return `${skill.universal_percent}% Bibliothek`
return `${skill.universal_percent}% vom Vereins-Maximum`
}
return formatWeight(skillWeight(skill))
}
@ -43,7 +43,14 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
</div>
{hasReferenceScale ? (
<span className="skill-profile__meta-hint">
Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
Trainingsgewicht {formatWeight(skillWeight(skill))}
{skill.club_best ? (
<>
{' '}
· Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
{formatWeight(skill.club_best.weight)})
</>
) : null}
</span>
) : null}
</li>
@ -111,7 +118,7 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
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.',
hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.',
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)

View File

@ -3,6 +3,7 @@ import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
@ -22,12 +23,16 @@ export default function TrainingFrameworkProgramsListPage() {
const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
const [catalogSkills, setCatalogSkills] = useState([])
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
const [skillSummaries, setSkillSummaries] = useState({})
const [summariesLoading, setSummariesLoading] = useState(false)
const [profileModal, setProfileModal] = useState(null)
const filteredRows = useMemo(
() => filterFrameworkPrograms(rows, filters),
[rows, filters]
() => filterFrameworkPrograms(rows, filters, skillSummaries),
[rows, filters, skillSummaries]
)
const filterActive = hasActiveFrameworkImportFilters(filters)
@ -35,22 +40,25 @@ export default function TrainingFrameworkProgramsListPage() {
setLoading(true)
setError('')
try {
const [list, fa, tt, tg] = await Promise.all([
const [list, fa, tt, tg, skills] = await Promise.all([
api.listTrainingFrameworkPrograms(),
api.listFocusAreas({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
api.listSkills({ status: 'active' }),
])
setRows(Array.isArray(list) ? list : [])
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
setCatalogSkills(Array.isArray(skills) ? skills : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
setCatalogFocusAreas([])
setCatalogTrainingTypes([])
setCatalogTargetGroups([])
setCatalogSkills([])
} finally {
setLoading(false)
}
@ -60,6 +68,29 @@ export default function TrainingFrameworkProgramsListPage() {
load()
}, [load, tenantClubDepKey])
useEffect(() => {
if (!rows.length) {
setSkillSummaries({})
return undefined
}
let cancelled = false
setSummariesLoading(true)
api
.batchSkillProfileSummaries({ frameworkProgramIds: rows.map((r) => r.id) })
.then((data) => {
if (!cancelled) setSkillSummaries(data?.summaries || {})
})
.catch(() => {
if (!cancelled) setSkillSummaries({})
})
.finally(() => {
if (!cancelled) setSummariesLoading(false)
})
return () => {
cancelled = true
}
}, [rows, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
try {
@ -136,6 +167,8 @@ export default function TrainingFrameworkProgramsListPage() {
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
catalogSkills={catalogSkills}
skillSummaries={skillSummaries}
durationRadioName="fw-list-duration-mode"
className="fw-prog-filter-block--list"
/>
@ -157,6 +190,17 @@ export default function TrainingFrameworkProgramsListPage() {
row={r}
returnContext={frameworkListReturn}
onDelete={handleDelete}
skillSummary={skillSummaries[`framework_program:${r.id}`]}
skillSummaryLoading={summariesLoading}
skillFilterIds={filters.skillIds || []}
skillDisplayLimit={filters.skillDisplayLimit || 10}
onShowSkillProfile={(row) =>
setProfileModal({
artifactType: 'framework_program',
artifactId: row.id,
title: (row.title || '').trim() || `Rahmen #${row.id}`,
})
}
/>
</li>
))}
@ -164,6 +208,14 @@ export default function TrainingFrameworkProgramsListPage() {
)}
</>
)}
<SkillProfileFullModal
open={Boolean(profileModal)}
onClose={() => setProfileModal(null)}
artifactType={profileModal?.artifactType}
artifactId={profileModal?.artifactId}
title={profileModal?.title}
/>
</div>
)
}

View File

@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
@ -12,6 +14,9 @@ export default function TrainingModulesListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [skillSummaries, setSkillSummaries] = useState({})
const [summariesLoading, setSummariesLoading] = useState(false)
const [profileModal, setProfileModal] = useState(null)
const load = useCallback(async () => {
setLoading(true)
@ -31,6 +36,29 @@ export default function TrainingModulesListPage() {
load()
}, [load, tenantClubDepKey])
useEffect(() => {
if (!rows.length) {
setSkillSummaries({})
return undefined
}
let cancelled = false
setSummariesLoading(true)
api
.batchSkillProfileSummaries({ trainingModuleIds: rows.map((r) => r.id) })
.then((data) => {
if (!cancelled) setSkillSummaries(data?.summaries || {})
})
.catch(() => {
if (!cancelled) setSkillSummaries({})
})
.finally(() => {
if (!cancelled) setSummariesLoading(false)
})
return () => {
cancelled = true
}
}, [rows, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
try {
@ -58,8 +86,7 @@ export default function TrainingModulesListPage() {
Trainingsmodule
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als
lokale Kopie (mit Herkunftsmarkierung).
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Fähigkeiten werden im Vereinskontext verglichen.
</p>
</div>
<NavStateLink
@ -114,11 +141,28 @@ export default function TrainingModulesListPage() {
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
</span>
</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: 'var(--text3)' }}>
Sichtbarkeit: <strong>{r.visibility || '—'}</strong>
</p>
<div style={{ marginTop: '0.65rem' }}>
<SkillProfileCompact
summary={skillSummaries[`training_module:${r.id}`]}
loading={summariesLoading}
displayLimit={4}
/>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() =>
setProfileModal({
artifactType: 'training_module',
artifactId: r.id,
title: (r.title || '').trim() || `Modul #${r.id}`,
})
}
>
Fähigkeiten-Profil
</button>
<NavStateLink
to={`/planning/training-modules/${r.id}`}
returnContext={modulesListReturn}
@ -136,6 +180,14 @@ export default function TrainingModulesListPage() {
))}
</ul>
)}
<SkillProfileFullModal
open={Boolean(profileModal)}
onClose={() => setProfileModal(null)}
artifactType={profileModal?.artifactType}
artifactId={profileModal?.artifactId}
title={profileModal?.title}
/>
</>
)
}

View File

@ -1,4 +1,9 @@
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
import {
frameworkSkillSummaryKey,
maxSelectedSkillClubPercent,
skillEntryFromSummary,
} from './skillProfileListHelpers'
export function frameworkSessionDurationLabel(row) {
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
@ -109,6 +114,10 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
durationRangeFrom: '',
durationRangeTo: '',
durationPresetMin: null,
skillIds: [],
skillSort: 'title',
skillMinClubPercent: 0,
skillDisplayLimit: 10,
}
export function hasActiveFrameworkImportFilters(filters = {}) {
@ -122,6 +131,9 @@ export function hasActiveFrameworkImportFilters(filters = {}) {
if (String(f.durationRangeTo || '').trim() !== '') return true
}
if (f.durationMode === 'preset' && f.durationPresetMin != null) return true
if ((f.skillIds || []).length) return true
if (Number(f.skillMinClubPercent) > 0) return true
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
return false
}
@ -136,6 +148,17 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
const nameById = (list, id) => list?.find((x) => String(x.id) === String(id))?.name || id
if ((f.skillIds || []).length) {
const names = f.skillIds.map((id) => nameById(catalogs.skills, id))
parts.push(`Fähigkeiten: ${names.join(', ')}`)
}
if (Number(f.skillMinClubPercent) > 0) {
parts.push(`mind. ${f.skillMinClubPercent}% vom Vereins-Maximum`)
}
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
parts.push('Sortierung: Fähigkeiten-Stärke')
}
if ((f.focusAreaIds || []).length) {
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
parts.push(`Fokus: ${names.join(', ')}`)
@ -167,14 +190,16 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
/**
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
*/
export function filterFrameworkPrograms(rows, filters = {}) {
export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = null) {
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
const q = (f.query || '').trim().toLowerCase()
const focusIds = new Set((f.focusAreaIds || []).map(String))
const typeIds = new Set((f.trainingTypeIds || []).map(String))
const tgIds = new Set((f.targetGroupIds || []).map(String))
const skillIds = f.skillIds || []
const minClubPct = Number(f.skillMinClubPercent) || 0
return (rows || []).filter((r) => {
let list = (rows || []).filter((r) => {
if (q) {
const blob = [
r.title,
@ -212,6 +237,32 @@ export function filterFrameworkPrograms(rows, filters = {}) {
return true
})
if (skillIds.length && skillSummaries) {
list = list.filter((r) => {
const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
if (!summary) return minClubPct === 0
return skillIds.some((sid) => {
const sk = skillEntryFromSummary(summary, sid)
if (!sk) return false
const pct = sk.universal_percent
if (pct == null) return minClubPct === 0
return pct >= minClubPct
})
})
}
if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
list = [...list].sort((a, b) => {
const sa = skillSummaries[frameworkSkillSummaryKey(a.id)]
const sb = skillSummaries[frameworkSkillSummaryKey(b.id)]
const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
return pb - pa
})
}
return list
}
export function frameworkProgramOptionLabel(row) {

View File

@ -0,0 +1,62 @@
export function frameworkSkillSummaryKey(id) {
return `framework_program:${id}`
}
export function moduleSkillSummaryKey(id) {
return `training_module:${id}`
}
export function skillEntryFromSummary(summary, skillId) {
if (!summary?.skills) return null
return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null
}
export function maxSelectedSkillClubPercent(summary, skillIds = []) {
if (!summary || !skillIds.length) return null
let max = null
for (const id of skillIds) {
const sk = skillEntryFromSummary(summary, id)
if (!sk) continue
const pct = sk.universal_percent
if (pct == null) continue
if (max == null || pct > max) max = pct
}
return max
}
export function formatClubPercent(value) {
if (value == null || !Number.isFinite(Number(value))) return '—'
const n = Number(value)
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
}
export function artifactTypeLabel(type) {
if (type === 'framework_program') return 'Rahmenprogramm'
if (type === 'training_module') return 'Modul'
if (type === 'progression_graph') return 'Regressionspfad'
return type || 'Artefakt'
}
export function artifactPath(ref) {
if (!ref) return null
if (ref.artifact_type === 'framework_program') {
return `/planning/framework-programs/${ref.artifact_id}`
}
if (ref.artifact_type === 'training_module') {
return `/planning/training-modules/${ref.artifact_id}`
}
return null
}
/** Zeilen für Kompakt-Anzeige: gewählte Fähigkeiten oder Top je Kategorie. */
export function compactSkillDisplayRows(summary, { skillIds = [], limit = 6 } = {}) {
if (!summary) return []
if (skillIds.length) {
return skillIds
.map((id) => skillEntryFromSummary(summary, id))
.filter(Boolean)
.sort((a, b) => (b.universal_percent ?? 0) - (a.universal_percent ?? 0))
.slice(0, limit)
}
return (summary.top_by_category || []).slice(0, limit)
}