KPI-Scroing, Filter, etc, #43
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
displayMode === 'full' ? (
|
||||||
|
<FullSkillsProfile
|
||||||
|
profile={sl.profile}
|
||||||
|
ariaLabel={`Alle Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<CategoryGroupedProfile
|
<CategoryGroupedProfile
|
||||||
profile={sl.profile}
|
profile={sl.profile}
|
||||||
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
|
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user