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 []