KPI-Scroing, Filter, etc, #43

Merged
Lars merged 10 commits from develop into main 2026-05-21 10:36:49 +02:00
7 changed files with 165 additions and 39 deletions
Showing only changes of commit 34966b9e84 - Show all commits

View File

@ -14,12 +14,17 @@ from tenant_context import TenantContext, get_tenant_context, library_content_vi
from skill_scoring import ( from skill_scoring import (
GRAPH_DEFAULT_ITEM_MINUTES, GRAPH_DEFAULT_ITEM_MINUTES,
ExerciseOccurrence, ExerciseOccurrence,
batch_compute_profiles,
batch_framework_occurrences_by_id,
batch_module_occurrences_by_id,
collect_module_exercise_occurrences, collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences, collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences, collect_unit_exercise_occurrences,
compact_profile_summary,
compute_club_corpus_reference, compute_club_corpus_reference,
compute_corpus_skill_max_weights, compute_corpus_skill_max_weights,
compute_skill_profile, compute_skill_profile,
fetch_exercise_skills_bulk,
match_score_for_skill_ids, match_score_for_skill_ids,
profile_for_occurrences, profile_for_occurrences,
reference_scale_meta, reference_scale_meta,
@ -239,33 +244,31 @@ def batch_skill_profile_summaries(
include_artifact_summaries=True, include_artifact_summaries=True,
) )
ref_by_skill = corpus["ref_by_skill"] ref_by_skill = corpus["ref_by_skill"]
all_summaries = corpus.get("artifact_summaries") or {}
allowed_fp: List[int] = []
if fp_ids: if fp_ids:
allowed_fp = []
for fid in fp_ids: for fid in fp_ids:
try: try:
_framework_access(cur, fid, profile_id, role) _framework_access(cur, fid, profile_id, role)
allowed_fp.append(fid) allowed_fp.append(fid)
except HTTPException: except HTTPException:
pass pass
for fid in allowed_fp:
key = f"framework_program:{fid}"
if key in all_summaries:
summaries[key] = all_summaries[key]
allowed_mod: List[int] = []
if mod_ids: if mod_ids:
allowed_mod = []
for mid in mod_ids: for mid in mod_ids:
try: try:
_module_access(cur, mid, profile_id, role) _module_access(cur, mid, profile_id, role)
allowed_mod.append(mid) allowed_mod.append(mid)
except HTTPException: except HTTPException:
pass pass
for mid in allowed_mod:
key = f"training_module:{mid}" summaries = _merge_batch_summaries(
if key in all_summaries: cur,
summaries[key] = all_summaries[key] corpus=corpus,
allowed_fp=allowed_fp,
allowed_mod=allowed_mod,
)
skill_ids_seen: set[int] = set() skill_ids_seen: set[int] = set()
for summary in summaries.values(): for summary in summaries.values():
@ -551,3 +554,100 @@ def _enrich_profile_club_best(
def _empty_profile() -> Dict[str, Any]: def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {}) return compute_skill_profile([], {})
def _summarize_framework_program(
cur,
framework_id: int,
ref_max: Dict[int, float],
ref_by_skill: Dict[int, Dict[str, Any]],
) -> Dict[str, Any]:
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
""",
(int(framework_id),),
)
occ: List[ExerciseOccurrence] = []
for u in cur.fetchall():
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
prof = (
profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
if occ
else _empty_profile()
)
_enrich_profile_club_best(prof, ref_by_skill, "framework_program", int(framework_id))
return compact_profile_summary(prof, ref_by_skill)
def _summarize_training_module(
cur,
module_id: int,
ref_max: Dict[int, float],
ref_by_skill: Dict[int, Dict[str, Any]],
) -> Dict[str, Any]:
occ = collect_module_exercise_occurrences(cur, int(module_id))
prof = (
profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
if occ
else _empty_profile()
)
_enrich_profile_club_best(prof, ref_by_skill, "training_module", int(module_id))
return compact_profile_summary(prof, ref_by_skill)
def _merge_batch_summaries(
cur,
*,
corpus: Dict[str, Any],
allowed_fp: List[int],
allowed_mod: List[int],
) -> Dict[str, Dict[str, Any]]:
"""Summaries für angeforderte IDs — auch official/private (nicht nur Vereins-Corpus-Cache)."""
ref_max = corpus["max_by_skill"]
ref_by_skill = corpus["ref_by_skill"]
cached = corpus.get("artifact_summaries") or {}
out: Dict[str, Dict[str, Any]] = {}
missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in cached]
if missing_fp:
occ_map = batch_framework_occurrences_by_id(cur, missing_fp)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max)
for fid in missing_fp:
key = f"framework_program:{fid}"
prof = profiles.get(fid) or _empty_profile()
_enrich_profile_club_best(prof, ref_by_skill, "framework_program", fid)
out[key] = compact_profile_summary(prof, ref_by_skill)
missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in cached]
if missing_mod:
occ_map = batch_module_occurrences_by_id(cur, missing_mod)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max)
for mid in missing_mod:
key = f"training_module:{mid}"
prof = profiles.get(mid) or _empty_profile()
_enrich_profile_club_best(prof, ref_by_skill, "training_module", mid)
out[key] = compact_profile_summary(prof, ref_by_skill)
for fid in allowed_fp:
key = f"framework_program:{fid}"
if key in cached:
out[key] = cached[key]
elif key not in out:
out[key] = _summarize_framework_program(cur, fid, ref_max, ref_by_skill)
for mid in allowed_mod:
key = f"training_module:{mid}"
if key in cached:
out[key] = cached[key]
elif key not in out:
out[key] = _summarize_training_module(cur, mid, ref_max, ref_by_skill)
return out

View File

@ -566,9 +566,10 @@ def compact_profile_summary(
profile: Dict[str, Any], profile: Dict[str, Any],
ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None, ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
*, *,
skills_limit: int = 20, skills_limit: int = 0,
category_limit: int = 48,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Leichtgewichtiges Profil für Listen — ohne Übungsdetails.""" """Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten."""
skills_out: List[Dict[str, Any]] = [] skills_out: List[Dict[str, Any]] = []
for s in profile.get("skills") or []: for s in profile.get("skills") or []:
sid = int(s["skill_id"]) sid = int(s["skill_id"])
@ -579,6 +580,7 @@ def compact_profile_summary(
"category_name": s.get("category_name") or s.get("category"), "category_name": s.get("category_name") or s.get("category"),
"main_category_name": s.get("main_category_name"), "main_category_name": s.get("main_category_name"),
"weight": s.get("weight"), "weight": s.get("weight"),
"score": s.get("score") or s.get("weight"),
"universal_percent": s.get("universal_percent"), "universal_percent": s.get("universal_percent"),
} }
ref = (ref_by_skill or {}).get(sid) ref = (ref_by_skill or {}).get(sid)
@ -587,14 +589,14 @@ def compact_profile_summary(
if s.get("is_club_best_for_skill"): if s.get("is_club_best_for_skill"):
entry["is_club_best_for_skill"] = True entry["is_club_best_for_skill"] = True
skills_out.append(entry) skills_out.append(entry)
if len(skills_out) >= skills_limit: if skills_limit > 0 and len(skills_out) >= skills_limit:
break break
return { return {
"total_score": profile.get("total_score"), "total_score": profile.get("total_score"),
"total_weight": profile.get("total_weight"), "total_weight": profile.get("total_weight"),
"exercise_occurrence_count": profile.get("exercise_occurrence_count"), "exercise_occurrence_count": profile.get("exercise_occurrence_count"),
"skills_count": len(profile.get("skills") or []), "skills_count": len(profile.get("skills") or []),
"top_by_category": top_categories_summary(profile), "top_by_category": top_categories_summary(profile, limit=category_limit),
"skills": skills_out, "skills": skills_out,
} }

View File

@ -2744,12 +2744,17 @@ html.modal-scroll-locked .app-main {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.skill-kpi-tile__pct { .skill-kpi-tile__score {
font-size: 0.72rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
color: var(--accent-dark); color: var(--text1);
margin-top: 2px; margin-top: 2px;
} }
.skill-kpi-tile__pct {
font-size: 0.68rem;
font-weight: 600;
color: var(--accent-dark);
}
.skill-profile__name-cat { .skill-profile__name-cat {
font-weight: 500; font-weight: 500;
color: var(--text3); color: var(--text3);

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { formatClubPercent, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers' import { formatClubPercent, formatSkillWeight, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers'
/** /**
* Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten). * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
@ -8,7 +8,7 @@ export default function SkillProfileCompact({
summary, summary,
loading = false, loading = false,
emptyText = 'Keine Fähigkeiten', emptyText = 'Keine Fähigkeiten',
displayLimit = 8, displayLimit = 24,
highlightSkillIds = [], highlightSkillIds = [],
}) { }) {
if (loading) { if (loading) {
@ -39,10 +39,11 @@ export default function SkillProfileCompact({
(highlighted ? ' skill-kpi-tile--highlight' : '') + (highlighted ? ' skill-kpi-tile--highlight' : '') +
(isBest ? ' skill-kpi-tile--best' : '') (isBest ? ' skill-kpi-tile--best' : '')
} }
title={`${row.category_name}: ${row.skill_name}`} title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} vom Vereins-Maximum`}
> >
<span className="skill-kpi-tile__cat">{row.category_name}</span> <span className="skill-kpi-tile__cat">{row.category_name}</span>
<span className="skill-kpi-tile__name">{row.skill_name}</span> <span className="skill-kpi-tile__name">{row.skill_name}</span>
<span className="skill-kpi-tile__score">{formatSkillWeight(row.weight ?? row.score)}</span>
<span className="skill-kpi-tile__pct"> <span className="skill-kpi-tile__pct">
{isBest ? '★ ' : ''} {isBest ? '★ ' : ''}
{formatClubPercent(row.universal_percent)} {formatClubPercent(row.universal_percent)}

View File

@ -244,7 +244,7 @@ export default function SkillProfilePanel({
</> </>
)} )}
{displayMode === 'summary' && slots && slots.length > 0 ? ( {slots && slots.length > 0 ? (
<div className="skill-profile__slots"> <div className="skill-profile__slots">
<span className="skill-profile__slots-label">Pro Session</span> <span className="skill-profile__slots-label">Pro Session</span>
<ul className="skill-profile__slot-list"> <ul className="skill-profile__slot-list">
@ -270,11 +270,18 @@ export default function SkillProfilePanel({
</span> </span>
)} )}
</button> </button>
{open && sl.profile?.by_main_category?.length > 0 ? ( {open && sl.profile?.skills?.length > 0 ? (
<CategoryGroupedProfile displayMode === 'full' ? (
profile={sl.profile} <FullSkillsProfile
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`} profile={sl.profile}
/> ariaLabel={`Alle Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
/>
) : (
<CategoryGroupedProfile
profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
/>
)
) : null} ) : null}
</li> </li>
) )

View File

@ -2,7 +2,7 @@ import { formatDurationDisplay, formatSessionDurationRange } from './trainingDur
import { import {
frameworkSkillSummaryKey, frameworkSkillSummaryKey,
maxSelectedSkillClubPercent, maxSelectedSkillClubPercent,
skillEntryFromSummary, summaryHasSkill,
} from './skillProfileListHelpers' } from './skillProfileListHelpers'
export function frameworkSessionDurationLabel(row) { export function frameworkSessionDurationLabel(row) {
@ -117,7 +117,7 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
skillIds: [], skillIds: [],
skillSort: 'title', skillSort: 'title',
skillMinClubPercent: 0, skillMinClubPercent: 0,
skillDisplayLimit: 10, skillDisplayLimit: 24,
} }
export function hasActiveFrameworkImportFilters(filters = {}) { export function hasActiveFrameworkImportFilters(filters = {}) {
@ -241,14 +241,8 @@ export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = nul
if (skillIds.length && skillSummaries) { if (skillIds.length && skillSummaries) {
list = list.filter((r) => { list = list.filter((r) => {
const summary = skillSummaries[frameworkSkillSummaryKey(r.id)] const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
if (!summary) return minClubPct === 0 if (!summary) return false
return skillIds.some((sid) => { return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
const sk = skillEntryFromSummary(summary, sid)
if (!sk) return false
const pct = sk.universal_percent
if (pct == null) return minClubPct === 0
return pct >= minClubPct
})
}) })
} }

View File

@ -7,8 +7,19 @@ export function moduleSkillSummaryKey(id) {
} }
export function skillEntryFromSummary(summary, skillId) { export function skillEntryFromSummary(summary, skillId) {
if (!summary?.skills) return null if (!summary) return null
return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null const sid = String(skillId)
const fromSkills = (summary.skills || []).find((s) => String(s.skill_id) === sid)
if (fromSkills) return fromSkills
return (summary.top_by_category || []).find((s) => String(s.skill_id) === sid) || null
}
export function summaryHasSkill(summary, skillId, minClubPct = 0) {
const sk = skillEntryFromSummary(summary, skillId)
if (!sk || !(Number(sk.weight) > 0)) return false
const pct = sk.universal_percent
if (pct == null) return minClubPct === 0
return pct >= minClubPct
} }
export function maxSelectedSkillClubPercent(summary, skillIds = []) { export function maxSelectedSkillClubPercent(summary, skillIds = []) {
@ -30,6 +41,12 @@ export function formatClubPercent(value) {
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%` return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
} }
export function formatSkillWeight(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '—'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */ /** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) { export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
if (!summary) return [] if (!summary) return []