diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 77ef858..9efb3e2 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -179,22 +179,44 @@ def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str,
return result
+def _club_universal_percent(
+ weight: float,
+ corpus_ref: float,
+) -> tuple[Optional[float], bool]:
+ """
+ Anteil am Vereins-Maximum (max. 100 %).
+ effective_ref = max(Korpus-Max, eigenes Gewicht) — verhindert Werte >100 %,
+ wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official).
+ """
+ w = float(weight or 0)
+ ref = float(corpus_ref or 0)
+ if w <= 0:
+ return None, False
+ effective_ref = max(ref, w)
+ pct = min(100.0, w / effective_ref * 100.0)
+ is_best = ref <= 0 or w >= ref - 0.01
+ return _round2(pct), is_best
+
+
def _apply_reference_universal_percent(
skills_out: List[Dict[str, Any]],
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> None:
"""
- Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte).
+ Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %).
"""
if not reference_max_by_skill:
for sk in skills_out:
sk["universal_percent"] = None
+ sk["is_club_best_for_skill"] = False
return
for sk in skills_out:
sid = int(sk["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
w = float(sk.get("weight") or 0)
- sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None
+ pct, is_best = _club_universal_percent(w, ref)
+ sk["universal_percent"] = pct
+ sk["is_club_best_for_skill"] = is_best
def compute_skill_profile(
@@ -316,8 +338,9 @@ def compute_skill_profile(
if top and reference_max_by_skill:
sid = int(top["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
- if ref > 0:
- top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
+ pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
+ top["universal_percent"] = pct
+ top["is_club_best_for_skill"] = is_best
unique_exercises = len(exercise_meta)
return {
@@ -513,6 +536,7 @@ def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict
"score": top.get("score") or top.get("weight"),
"weight": top.get("weight"),
"universal_percent": top.get("universal_percent"),
+ "is_club_best_for_skill": top.get("is_club_best_for_skill"),
}
)
if len(out) >= limit:
@@ -532,8 +556,9 @@ def _apply_reference_to_profile(
if top and reference_max_by_skill:
sid = int(top["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
- if ref > 0:
- top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
+ pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
+ top["universal_percent"] = pct
+ top["is_club_best_for_skill"] = is_best
profile["has_reference_scale"] = bool(reference_max_by_skill)
@@ -559,6 +584,8 @@ def compact_profile_summary(
ref = (ref_by_skill or {}).get(sid)
if ref and w < float(ref.get("weight") or 0) - 0.01:
entry["club_best"] = ref
+ 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:
break
diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
index 4e0f0c7..dbe4531 100644
--- a/backend/tests/test_skill_scoring.py
+++ b/backend/tests/test_skill_scoring.py
@@ -3,6 +3,7 @@ from skill_scoring import (
ExerciseOccurrence,
compute_skill_profile,
match_score_for_skill_ids,
+ _club_universal_percent,
_level_range_multiplier,
_skill_link_multiplier,
)
@@ -97,8 +98,15 @@ def test_universal_percent_against_corpus_max():
)
assert profile["has_reference_scale"] is True
assert profile["skills"][0]["universal_percent"] == 50.0
- top = profile["by_main_category"][0]["categories"][0]["top_skill"]
- assert top["universal_percent"] == 50.0
+ assert profile["skills"][0]["is_club_best_for_skill"] is False
+
+
+def test_club_universal_percent_capped_at_100():
+ pct, is_best = _club_universal_percent(150.0, 100.0)
+ assert pct == 100.0
+ assert is_best is True
+ pct2, _ = _club_universal_percent(72.6, 100.0)
+ assert pct2 == 72.6
def test_match_score_for_skill_ids():
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f442a8b..1c90601 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2693,46 +2693,73 @@ html.modal-scroll-locked .app-main {
.skill-profile-compact {
margin-top: 4px;
}
-.skill-profile-compact__label {
- display: block;
- font-size: 0.72rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text3);
- margin-bottom: 6px;
-}
-.skill-profile-compact__list {
+.skill-kpi-grid {
list-style: none;
margin: 0;
padding: 0;
display: flex;
- flex-direction: column;
+ flex-wrap: wrap;
gap: 6px;
}
-.skill-profile-compact__item {
+.skill-kpi-grid--loading,
+.skill-kpi-grid--empty {
+ margin: 0;
+}
+.skill-kpi-tile {
display: flex;
flex-direction: column;
- gap: 2px;
- padding: 6px 8px;
+ gap: 1px;
+ min-width: 5.5rem;
+ max-width: 8.5rem;
+ padding: 5px 8px;
border-radius: 8px;
- background: var(--surface2);
border: 1px solid var(--border);
+ background: var(--surface2);
+ flex: 0 1 auto;
}
-.skill-profile-compact__name {
+.skill-kpi-tile--highlight {
+ border-color: var(--accent);
+ background: var(--accent-light);
+}
+.skill-kpi-tile--best {
+ box-shadow: inset 0 0 0 1px var(--accent-dark);
+}
+.skill-kpi-tile__cat {
+ font-size: 0.62rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.skill-kpi-tile__name {
+ font-size: 0.76rem;
font-weight: 600;
- font-size: 0.86rem;
color: var(--text1);
+ line-height: 1.2;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
-.skill-profile-compact__metric {
- font-size: 0.78rem;
+.skill-kpi-tile__pct {
+ font-size: 0.72rem;
font-weight: 700;
color: var(--accent-dark);
+ margin-top: 2px;
}
-.skill-profile-compact__best {
- margin: 0;
- font-size: 0.72rem;
- line-height: 1.35;
+.skill-profile__name-cat {
+ font-weight: 500;
+ color: var(--text3);
+}
+.skill-profile--embedded .skill-profile__body {
+ padding: 0;
+ border-top: none;
+}
+.skill-profile--embedded .skill-profile__hint {
+ margin-top: 0;
}
.fw-prog-card__section-head {
display: flex;
diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx
index 1d390d3..7743774 100644
--- a/frontend/src/components/planning/FrameworkProgramListCard.jsx
+++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx
@@ -131,15 +131,15 @@ export default function FrameworkProgramListCard({
className="btn btn-secondary btn-small fw-prog-card__skills-btn"
onClick={() => onShowSkillProfile(row)}
>
- Vollständiges Profil
+ Alle anzeigen
) : null}
diff --git a/frontend/src/components/skills/SkillProfileCompact.jsx b/frontend/src/components/skills/SkillProfileCompact.jsx
index 7ecc740..0aaa7e2 100644
--- a/frontend/src/components/skills/SkillProfileCompact.jsx
+++ b/frontend/src/components/skills/SkillProfileCompact.jsx
@@ -1,67 +1,55 @@
import React from 'react'
-import {
- artifactPath,
- artifactTypeLabel,
- compactSkillDisplayRows,
- formatClubPercent,
-} from '../../utils/skillProfileListHelpers'
-
-function formatWeight(value) {
- const n = Number(value)
- if (!Number.isFinite(n)) return '0'
- return n % 1 === 0 ? String(n) : n.toFixed(1)
-}
+import { formatClubPercent, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers'
/**
- * Kompakte Fähigkeiten-Zeile für Listen/Kacheln.
+ * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
*/
export default function SkillProfileCompact({
summary,
- skillIds = [],
loading = false,
- emptyText = 'Noch keine Übungen mit Fähigkeiten',
- displayLimit = 6,
- showClubBest = true,
+ emptyText = 'Keine Fähigkeiten',
+ displayLimit = 8,
+ highlightSkillIds = [],
}) {
if (loading) {
- return
Fähigkeiten werden berechnet…
+ return (
+
+ …
+
+ )
}
- const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit })
+ const rows = kpiRowsFromSummary(summary, { limit: displayLimit })
+ const highlight = new Set((highlightSkillIds || []).map(String))
if (!rows.length) {
- return summary ? (
- {emptyText}
- ) : null
+ return summary ? {emptyText}
: null
}
return (
-
-
Fähigkeiten
-
- {rows.map((sk) => {
- const best = sk.club_best
- const path = showClubBest && best ? artifactPath(best) : null
- return (
-
-
- {sk.skill_name}
-
-
- {formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein
-
- {showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? (
-
- Vereins-Top: {artifactTypeLabel(best.artifact_type)} „{best.artifact_title || best.artifact_id}“ (
- {formatWeight(best.weight)})
-
- ) : sk.universal_percent >= 100 ? (
- Stärkste Vereins-Nutzung dieser Fähigkeit
- ) : null}
-
- )
- })}
-
-
+
+ {rows.map((row) => {
+ const highlighted = highlight.has(String(row.skill_id))
+ const isBest = row.is_club_best_for_skill || row.universal_percent >= 100
+ return (
+
+ {row.category_name}
+ {row.skill_name}
+
+ {isBest ? '★ ' : ''}
+ {formatClubPercent(row.universal_percent)}
+
+
+ )
+ })}
+
)
}
diff --git a/frontend/src/components/skills/SkillProfileFullModal.jsx b/frontend/src/components/skills/SkillProfileFullModal.jsx
index 907098a..cf1d61a 100644
--- a/frontend/src/components/skills/SkillProfileFullModal.jsx
+++ b/frontend/src/components/skills/SkillProfileFullModal.jsx
@@ -63,6 +63,8 @@ export default function SkillProfileFullModal({
loading={loading}
error={error}
title="Vollständiges Profil"
+ displayMode="full"
+ embedded
defaultExpanded
/>
{data?.reference_scale?.scope === 'club' && !loading ? (
diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx
index 54c7708..cf87dea 100644
--- a/frontend/src/components/skills/SkillProfilePanel.jsx
+++ b/frontend/src/components/skills/SkillProfilePanel.jsx
@@ -10,6 +10,12 @@ function formatWeight(value) {
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
+function formatClubPercent(value) {
+ if (value == null || !Number.isFinite(Number(value))) return '—'
+ const n = Math.min(100, Number(value))
+ return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
+}
+
function barFillPercent(skill, maxWeight, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return Math.min(100, Number(skill.universal_percent))
@@ -20,18 +26,22 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
function metricLabel(skill, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
- return `${skill.universal_percent}% vom Vereins-Maximum`
+ const best = skill.is_club_best_for_skill ? ' ★' : ''
+ return `${formatClubPercent(skill.universal_percent)} Verein${best}`
}
return formatWeight(skillWeight(skill))
}
-function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
+function SkillRow({ skill, maxWeight, hasReferenceScale }) {
if (!skill) return null
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return (
-
+
+ {skill.category_name ? (
+ {skill.category_name} ·
+ ) : null}
{skill.skill_name}
@@ -43,13 +53,15 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
{hasReferenceScale ? (
- Trainingsgewicht {formatWeight(skillWeight(skill))}
+ Gewicht {formatWeight(skillWeight(skill))}
{skill.club_best ? (
<>
{' '}
· Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
{formatWeight(skill.club_best.weight)})
>
+ ) : skill.is_club_best_for_skill ? (
+ ' · Stärkste Vereins-Nutzung'
) : null}
) : null}
@@ -57,6 +69,11 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
)
}
+function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
+ if (!skill) return null
+ return
+}
+
function CategoryGroupedProfile({ profile, ariaLabel }) {
const groups = profile?.by_main_category || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
@@ -82,11 +99,13 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
{(mc.categories || []).map((cat) => (
{cat.category_name}
-
+
+
+
))}
@@ -96,6 +115,30 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
)
}
+function FullSkillsProfile({ profile, ariaLabel }) {
+ const skills = profile?.skills || []
+ const hasReferenceScale = Boolean(profile?.has_reference_scale)
+ const maxWeight = useMemo(
+ () => Math.max(...skills.map((s) => skillWeight(s)), 1),
+ [skills]
+ )
+
+ if (!skills.length) return null
+
+ return (
+
+ {skills.map((sk) => (
+
+ ))}
+
+ )
+}
+
function topCategoryBadge(profile) {
const parts = []
for (const mc of profile?.by_main_category || []) {
@@ -111,6 +154,7 @@ function topCategoryBadge(profile) {
/**
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
+ * displayMode: 'summary' = Top je Kategorie (Editor), 'full' = alle Fähigkeiten (Modal).
*/
export default function SkillProfilePanel({
profile,
@@ -118,17 +162,25 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
- hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.',
+ hint = '',
defaultExpanded = true,
+ displayMode = 'summary',
+ embedded = false,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
+ const defaultHint =
+ displayMode === 'full'
+ ? 'Alle verknüpften Fähigkeiten nach Trainingsgewicht. Vergleich zum Vereins-Maximum je Fähigkeit (max. 100 %).'
+ : 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum.'
+
+ const hintText = hint || defaultHint
const badge = useMemo(() => topCategoryBadge(profile), [profile])
if (loading) {
return (
-
+
Fähigkeiten-Profil wird berechnet…
)
@@ -136,7 +188,7 @@ export default function SkillProfilePanel({
if (error) {
return (
-
+
)
@@ -151,6 +203,92 @@ export default function SkillProfilePanel({
0
)
+ const body = (
+
+
{hintText}
+
+ {noData ? (
+
+ Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe
+ Fähigkeiten in der Übungsbearbeitung.
+
+ ) : profile.exercises_with_skills_count === 0 ? (
+
+ {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
+ Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
+
+ ) : (
+ <>
+
+
+ {profile.distinct_exercise_count} Übungen
+
+
+ {profile.skills?.length ?? 0} Fähigkeiten
+
+ {displayMode === 'summary' ? (
+
+ {categoryCount} Kategorien
+
+ ) : null}
+
+ {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht
+
+
+
+ {displayMode === 'full' ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {displayMode === 'summary' && slots && slots.length > 0 ? (
+
+
Pro Session
+
+ {slots.map((sl) => {
+ const open = slotOpenId === sl.slot_id
+ const slotBadge = topCategoryBadge(sl.profile)
+ return (
+
+ setSlotOpenId(open ? null : sl.slot_id)}
+ aria-expanded={open}
+ >
+
+ {(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
+
+ {slotBadge ? (
+ {slotBadge}
+ ) : (
+
+ {sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
+
+ )}
+
+ {open && sl.profile?.by_main_category?.length > 0 ? (
+
+ ) : null}
+
+ )
+ })}
+
+
+ ) : null}
+
+ )
+
+ if (embedded) {
+ return
{body}
+ }
+
return (
-
- {expanded ? (
-
-
{hint}
-
- {noData ? (
-
- Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe
- Fähigkeiten in der Übungsbearbeitung.
-
- ) : profile.exercises_with_skills_count === 0 ? (
-
- {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
- Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
-
- ) : (
- <>
-
-
- {profile.distinct_exercise_count} Übungen
-
-
- {profile.skills?.length ?? 0} Fähigkeiten
-
-
- {categoryCount} Kategorien
-
-
- {formatWeight(profile.total_score ?? profile.total_weight)} Gesamt-Gewicht
-
-
-
-
- >
- )}
-
- {slots && slots.length > 0 ? (
-
-
Pro Session
-
- {slots.map((sl) => {
- const open = slotOpenId === sl.slot_id
- const slotBadge = topCategoryBadge(sl.profile)
- return (
-
- setSlotOpenId(open ? null : sl.slot_id)}
- aria-expanded={open}
- >
-
- {(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
-
- {slotBadge ? (
- {slotBadge}
- ) : (
-
- {sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
-
- )}
-
- {open && sl.profile?.by_main_category?.length > 0 ? (
-
- ) : null}
-
- )
- })}
-
-
- ) : null}
-
- ) : null}
+ {expanded ? body : null}
)
}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
index c8c5754..03422d4 100644
--- a/frontend/src/pages/TrainingModulesListPage.jsx
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -141,11 +141,11 @@ export default function TrainingModulesListPage() {
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
-
diff --git a/frontend/src/utils/skillProfileListHelpers.js b/frontend/src/utils/skillProfileListHelpers.js
index 63b226d..1fdf71c 100644
--- a/frontend/src/utils/skillProfileListHelpers.js
+++ b/frontend/src/utils/skillProfileListHelpers.js
@@ -26,10 +26,34 @@ export function maxSelectedSkillClubPercent(summary, skillIds = []) {
export function formatClubPercent(value) {
if (value == null || !Number.isFinite(Number(value))) return '—'
- const n = Number(value)
+ const n = Math.min(100, Number(value))
return n % 1 === 0 ? `${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 []
+ let rows = (summary.top_by_category || []).map((row) => ({
+ skill_id: row.skill_id,
+ skill_name: row.skill_name,
+ category_name: row.category_name,
+ main_category_name: row.main_category_name,
+ weight: row.weight ?? row.score,
+ universal_percent: row.universal_percent,
+ is_club_best_for_skill: row.is_club_best_for_skill,
+ }))
+ if (skillIds.length) {
+ const wanted = new Set(skillIds.map(String))
+ rows = rows.filter((row) => wanted.has(String(row.skill_id)))
+ }
+ return rows.slice(0, limit)
+}
+
+/** @deprecated Nutze kpiRowsFromSummary für Listen */
+export function compactSkillDisplayRows(summary, opts = {}) {
+ return kpiRowsFromSummary(summary, opts)
+}
+
export function artifactTypeLabel(type) {
if (type === 'framework_program') return 'Rahmenprogramm'
if (type === 'training_module') return 'Modul'
@@ -47,16 +71,3 @@ export function artifactPath(ref) {
}
return null
}
-
-/** Zeilen für Kompakt-Anzeige: gewählte Fähigkeiten oder Top je Kategorie. */
-export function compactSkillDisplayRows(summary, { skillIds = [], limit = 6 } = {}) {
- if (!summary) return []
- if (skillIds.length) {
- return skillIds
- .map((id) => skillEntryFromSummary(summary, id))
- .filter(Boolean)
- .sort((a, b) => (b.universal_percent ?? 0) - (a.universal_percent ?? 0))
- .slice(0, limit)
- }
- return (summary.top_by_category || []).slice(0, limit)
-}