From 34966b9e846b16777eff79202c470e7d2e72fa13 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 09:42:13 +0200 Subject: [PATCH] Update Skill Profile Summary and Frontend Components - 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. --- backend/routers/skill_profiles.py | 122 ++++++++++++++++-- backend/skill_scoring.py | 10 +- frontend/src/app.css | 11 +- .../components/skills/SkillProfileCompact.jsx | 7 +- .../components/skills/SkillProfilePanel.jsx | 19 ++- .../src/utils/frameworkProgramListHelpers.js | 14 +- frontend/src/utils/skillProfileListHelpers.js | 21 ++- 7 files changed, 165 insertions(+), 39 deletions(-) diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py index b640213..3467ceb 100644 --- a/backend/routers/skill_profiles.py +++ b/backend/routers/skill_profiles.py @@ -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 diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 9efb3e2..6bf5ce0 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -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, } diff --git a/frontend/src/app.css b/frontend/src/app.css index 1c90601..343fbee 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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); diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx index 0aaa7e2..0905752 100644 --- a/frontend/src/components/skills/SkillProfileCompact.jsx +++ b/frontend/src/components/skills/SkillProfileCompact.jsx @@ -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`} > {row.category_name} {row.skill_name} + {formatSkillWeight(row.weight ?? row.score)} {isBest ? '★ ' : ''} {formatClubPercent(row.universal_percent)} diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx index cf87dea..8a3b45c 100644 --- a/frontend/src/components/skills/SkillProfilePanel.jsx +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -244,7 +244,7 @@ export default function SkillProfilePanel({ )} - {displayMode === 'summary' && slots && slots.length > 0 ? ( + {slots && slots.length > 0 ? (
Pro Session
    @@ -270,11 +270,18 @@ export default function SkillProfilePanel({ )} - {open && sl.profile?.by_main_category?.length > 0 ? ( - + {open && sl.profile?.skills?.length > 0 ? ( + displayMode === 'full' ? ( + + ) : ( + + ) ) : null} ) diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js index d029cd2..985f7e5 100644 --- a/frontend/src/utils/frameworkProgramListHelpers.js +++ b/frontend/src/utils/frameworkProgramListHelpers.js @@ -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)) }) } diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js index 1fdf71c..dfd2e63 100644 --- a/frontend/src/utils/skillProfileListHelpers.js +++ b/frontend/src/utils/skillProfileListHelpers.js @@ -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 []