Update Skill Profile Summary and Frontend Components
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- Modified the `compact_profile_summary` function to allow for dynamic skill and category limits, enhancing flexibility in profile data retrieval.
- Updated frontend components to display skill weights and scores more effectively, improving user interaction with skill metrics.
- Adjusted CSS styles for skill KPI tiles to better differentiate between score and percentage displays, ensuring a clearer visual representation.
- Refactored utility functions to streamline skill summary handling, enhancing overall code maintainability and performance.
This commit is contained in:
Lars 2026-05-21 09:42:13 +02:00
parent 9a0cf7f823
commit 34966b9e84
7 changed files with 165 additions and 39 deletions

View File

@ -14,12 +14,17 @@ from tenant_context import TenantContext, get_tenant_context, library_content_vi
from skill_scoring import (
GRAPH_DEFAULT_ITEM_MINUTES,
ExerciseOccurrence,
batch_compute_profiles,
batch_framework_occurrences_by_id,
batch_module_occurrences_by_id,
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
compact_profile_summary,
compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
fetch_exercise_skills_bulk,
match_score_for_skill_ids,
profile_for_occurrences,
reference_scale_meta,
@ -239,33 +244,31 @@ def batch_skill_profile_summaries(
include_artifact_summaries=True,
)
ref_by_skill = corpus["ref_by_skill"]
all_summaries = corpus.get("artifact_summaries") or {}
allowed_fp: List[int] = []
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]
allowed_mod: List[int] = []
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]
summaries = _merge_batch_summaries(
cur,
corpus=corpus,
allowed_fp=allowed_fp,
allowed_mod=allowed_mod,
)
skill_ids_seen: set[int] = set()
for summary in summaries.values():
@ -551,3 +554,100 @@ def _enrich_profile_club_best(
def _empty_profile() -> Dict[str, Any]:
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],
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]:
"""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]] = []
for s in profile.get("skills") or []:
sid = int(s["skill_id"])
@ -579,6 +580,7 @@ def compact_profile_summary(
"category_name": s.get("category_name") or s.get("category"),
"main_category_name": s.get("main_category_name"),
"weight": s.get("weight"),
"score": s.get("score") or s.get("weight"),
"universal_percent": s.get("universal_percent"),
}
ref = (ref_by_skill or {}).get(sid)
@ -587,14 +589,14 @@ def compact_profile_summary(
if s.get("is_club_best_for_skill"):
entry["is_club_best_for_skill"] = True
skills_out.append(entry)
if len(skills_out) >= skills_limit:
if skills_limit > 0 and 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),
"top_by_category": top_categories_summary(profile, limit=category_limit),
"skills": skills_out,
}

View File

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

View File

@ -1,5 +1,5 @@
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).
@ -8,7 +8,7 @@ export default function SkillProfileCompact({
summary,
loading = false,
emptyText = 'Keine Fähigkeiten',
displayLimit = 8,
displayLimit = 24,
highlightSkillIds = [],
}) {
if (loading) {
@ -39,10 +39,11 @@ export default function SkillProfileCompact({
(highlighted ? ' skill-kpi-tile--highlight' : '') +
(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__name">{row.skill_name}</span>
<span className="skill-kpi-tile__score">{formatSkillWeight(row.weight ?? row.score)}</span>
<span className="skill-kpi-tile__pct">
{isBest ? '★ ' : ''}
{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">
<span className="skill-profile__slots-label">Pro Session</span>
<ul className="skill-profile__slot-list">
@ -270,11 +270,18 @@ export default function SkillProfilePanel({
</span>
)}
</button>
{open && sl.profile?.by_main_category?.length > 0 ? (
<CategoryGroupedProfile
profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
/>
{open && sl.profile?.skills?.length > 0 ? (
displayMode === 'full' ? (
<FullSkillsProfile
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}
</li>
)

View File

@ -2,7 +2,7 @@ import { formatDurationDisplay, formatSessionDurationRange } from './trainingDur
import {
frameworkSkillSummaryKey,
maxSelectedSkillClubPercent,
skillEntryFromSummary,
summaryHasSkill,
} from './skillProfileListHelpers'
export function frameworkSessionDurationLabel(row) {
@ -117,7 +117,7 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
skillIds: [],
skillSort: 'title',
skillMinClubPercent: 0,
skillDisplayLimit: 10,
skillDisplayLimit: 24,
}
export function hasActiveFrameworkImportFilters(filters = {}) {
@ -241,14 +241,8 @@ export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = nul
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 (!summary) return false
return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
})
}

View File

@ -7,8 +7,19 @@ export function moduleSkillSummaryKey(id) {
}
export function skillEntryFromSummary(summary, skillId) {
if (!summary?.skills) return null
return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null
if (!summary) return 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 = []) {
@ -30,6 +41,12 @@ export function formatClubPercent(value) {
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. */
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
if (!summary) return []