Enhance Skill Scoring and Profile Functionality
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s

- Introduced a new function to calculate club-specific skill percentages, ensuring values are capped at 100%.
- Updated skill profile calculations to include indicators for the best club performance per skill.
- Enhanced frontend components to display club best indicators and improved layout for skill profiles.
- Refactored CSS styles for skill profile components, ensuring a more cohesive and user-friendly interface.
- Updated tests to validate new functionality and ensure accurate representation of skill metrics.
This commit is contained in:
Lars 2026-05-21 09:33:20 +02:00
parent 78c6c51520
commit 9a0cf7f823
9 changed files with 312 additions and 189 deletions

View File

@ -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

View File

@ -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():

View File

@ -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;

View File

@ -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
</button>
) : null}
</div>
<SkillProfileCompact
summary={skillSummary}
skillIds={skillFilterIds}
loading={skillSummaryLoading}
displayLimit={skillDisplayLimit}
highlightSkillIds={skillFilterIds}
/>
</section>

View File

@ -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 <p className="skill-profile-compact skill-profile-compact--loading form-sub">Fähigkeiten werden berechnet</p>
return (
<div className="skill-kpi-grid skill-kpi-grid--loading" aria-busy="true">
<span className="form-sub"></span>
</div>
)
}
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 ? (
<p className="skill-profile-compact skill-profile-compact--empty form-sub">{emptyText}</p>
) : null
return summary ? <p className="form-sub skill-kpi-grid--empty">{emptyText}</p> : null
}
return (
<div className="skill-profile-compact">
<span className="skill-profile-compact__label">Fähigkeiten</span>
<ul className="skill-profile-compact__list">
{rows.map((sk) => {
const best = sk.club_best
const path = showClubBest && best ? artifactPath(best) : null
return (
<li key={sk.skill_id} className="skill-profile-compact__item">
<span className="skill-profile-compact__name" title={sk.skill_name}>
{sk.skill_name}
</span>
<span className="skill-profile-compact__metric" title="Trainingsgewicht und Anteil am Vereins-Maximum">
{formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein
</span>
{showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? (
<span className="skill-profile-compact__best form-sub">
Vereins-Top: {artifactTypeLabel(best.artifact_type)} {best.artifact_title || best.artifact_id} (
{formatWeight(best.weight)})
</span>
) : sk.universal_percent >= 100 ? (
<span className="skill-profile-compact__best form-sub">Stärkste Vereins-Nutzung dieser Fähigkeit</span>
) : null}
</li>
)
})}
</ul>
</div>
<ul className="skill-kpi-grid" aria-label="Fähigkeiten je Kategorie">
{rows.map((row) => {
const highlighted = highlight.has(String(row.skill_id))
const isBest = row.is_club_best_for_skill || row.universal_percent >= 100
return (
<li
key={`${row.category_name}-${row.skill_id}`}
className={
'skill-kpi-tile' +
(highlighted ? ' skill-kpi-tile--highlight' : '') +
(isBest ? ' skill-kpi-tile--best' : '')
}
title={`${row.category_name}: ${row.skill_name}`}
>
<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__pct">
{isBest ? '★ ' : ''}
{formatClubPercent(row.universal_percent)}
</span>
</li>
)
})}
</ul>
)
}

View File

@ -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 ? (

View File

@ -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 (
<li className="skill-profile__cat-row">
<li className="skill-profile__row">
<div className="skill-profile__row-head">
<span className="skill-profile__name" title={skill.skill_name}>
{skill.category_name ? (
<span className="skill-profile__name-cat">{skill.category_name} · </span>
) : null}
{skill.skill_name}
</span>
<span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)">
@ -43,13 +53,15 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
</div>
{hasReferenceScale ? (
<span className="skill-profile__meta-hint">
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}
</span>
) : null}
@ -57,6 +69,11 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
)
}
function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
if (!skill) return null
return <SkillRow skill={skill} maxWeight={maxWeight} hasReferenceScale={hasReferenceScale} />
}
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) => (
<li key={cat.category_id ?? cat.category_name} className="skill-profile__cat-item">
<span className="skill-profile__cat-label">{cat.category_name}</span>
<CategoryTopSkill
skill={cat.top_skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
/>
<div className="skill-profile__cat-row">
<CategoryTopSkill
skill={cat.top_skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
/>
</div>
</li>
))}
</ul>
@ -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 (
<ul className="skill-profile__list" aria-label={ariaLabel}>
{skills.map((sk) => (
<SkillRow
key={sk.skill_id}
skill={sk}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
/>
))}
</ul>
)
}
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 (
<div className="card skill-profile skill-profile--loading">
<div className={'skill-profile skill-profile--loading' + (embedded ? '' : ' card')}>
<p className="skill-profile__status">Fähigkeiten-Profil wird berechnet</p>
</div>
)
@ -136,7 +188,7 @@ export default function SkillProfilePanel({
if (error) {
return (
<div className="card skill-profile skill-profile--error">
<div className={'skill-profile skill-profile--error' + (embedded ? '' : ' card')}>
<p className="skill-profile__status">{error}</p>
</div>
)
@ -151,6 +203,92 @@ export default function SkillProfilePanel({
0
)
const body = (
<div className="skill-profile__body">
<p className="form-sub skill-profile__hint">{hintText}</p>
{noData ? (
<p className="skill-profile__empty">
Noch keine Übungen mit Fähigkeiten-Verknüpfung lege Übungen im Ablauf an und verknüpfe
Fähigkeiten in der Übungsbearbeitung.
</p>
) : profile.exercises_with_skills_count === 0 ? (
<p className="skill-profile__empty">
{profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
</p>
) : (
<>
<div className="skill-profile__stats">
<span>
<strong>{profile.distinct_exercise_count}</strong> Übungen
</span>
<span>
<strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
</span>
{displayMode === 'summary' ? (
<span>
<strong>{categoryCount}</strong> Kategorien
</span>
) : null}
<span>
<strong>{formatWeight(profile.total_score ?? profile.total_weight)}</strong> Gesamt-Gewicht
</span>
</div>
{displayMode === 'full' ? (
<FullSkillsProfile profile={profile} ariaLabel="Alle Fähigkeiten nach Gewicht" />
) : (
<CategoryGroupedProfile profile={profile} ariaLabel="Top-Fähigkeit je Kategorie" />
)}
</>
)}
{displayMode === 'summary' && slots && slots.length > 0 ? (
<div className="skill-profile__slots">
<span className="skill-profile__slots-label">Pro Session</span>
<ul className="skill-profile__slot-list">
{slots.map((sl) => {
const open = slotOpenId === sl.slot_id
const slotBadge = topCategoryBadge(sl.profile)
return (
<li key={sl.slot_id} className="skill-profile__slot-item">
<button
type="button"
className="skill-profile__slot-btn"
onClick={() => setSlotOpenId(open ? null : sl.slot_id)}
aria-expanded={open}
>
<span>
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
</span>
{slotBadge ? (
<span className="skill-profile__slot-top">{slotBadge}</span>
) : (
<span className="skill-profile__slot-top skill-profile__slot-top--muted">
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
</span>
)}
</button>
{open && sl.profile?.by_main_category?.length > 0 ? (
<CategoryGroupedProfile
profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
/>
) : null}
</li>
)
})}
</ul>
</div>
) : null}
</div>
)
if (embedded) {
return <div className="skill-profile skill-profile--embedded">{body}</div>
}
return (
<div className="card skill-profile">
<button
@ -167,85 +305,7 @@ export default function SkillProfilePanel({
{expanded ? '▾' : '▸'}
</span>
</button>
{expanded ? (
<div className="skill-profile__body">
<p className="form-sub skill-profile__hint">{hint}</p>
{noData ? (
<p className="skill-profile__empty">
Noch keine Übungen mit Fähigkeiten-Verknüpfung lege Übungen im Ablauf an und verknüpfe
Fähigkeiten in der Übungsbearbeitung.
</p>
) : profile.exercises_with_skills_count === 0 ? (
<p className="skill-profile__empty">
{profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
</p>
) : (
<>
<div className="skill-profile__stats">
<span>
<strong>{profile.distinct_exercise_count}</strong> Übungen
</span>
<span>
<strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
</span>
<span>
<strong>{categoryCount}</strong> Kategorien
</span>
<span>
<strong>{formatWeight(profile.total_score ?? profile.total_weight)}</strong> Gesamt-Gewicht
</span>
</div>
<CategoryGroupedProfile
profile={profile}
ariaLabel="Top-Fähigkeit je Kategorie"
/>
</>
)}
{slots && slots.length > 0 ? (
<div className="skill-profile__slots">
<span className="skill-profile__slots-label">Pro Session</span>
<ul className="skill-profile__slot-list">
{slots.map((sl) => {
const open = slotOpenId === sl.slot_id
const slotBadge = topCategoryBadge(sl.profile)
return (
<li key={sl.slot_id} className="skill-profile__slot-item">
<button
type="button"
className="skill-profile__slot-btn"
onClick={() => setSlotOpenId(open ? null : sl.slot_id)}
aria-expanded={open}
>
<span>
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
</span>
{slotBadge ? (
<span className="skill-profile__slot-top">{slotBadge}</span>
) : (
<span className="skill-profile__slot-top skill-profile__slot-top--muted">
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
</span>
)}
</button>
{open && sl.profile?.by_main_category?.length > 0 ? (
<CategoryGroupedProfile
profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
/>
) : null}
</li>
)
})}
</ul>
</div>
) : null}
</div>
) : null}
{expanded ? body : null}
</div>
)
}

View File

@ -141,11 +141,11 @@ export default function TrainingModulesListPage() {
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
</span>
</p>
<div style={{ marginTop: '0.65rem' }}>
<div style={{ marginTop: '0.5rem' }}>
<SkillProfileCompact
summary={skillSummaries[`training_module:${r.id}`]}
loading={summariesLoading}
displayLimit={4}
displayLimit={6}
/>
</div>
</div>

View File

@ -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)
}