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
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:
parent
78c6c51520
commit
9a0cf7f823
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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={sk.skill_id} className="skill-profile-compact__item">
|
||||
<span className="skill-profile-compact__name" title={sk.skill_name}>
|
||||
{sk.skill_name}
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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,26 +203,9 @@ export default function SkillProfilePanel({
|
|||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="card skill-profile">
|
||||
<button
|
||||
type="button"
|
||||
className="skill-profile__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span className="skill-profile__toggle-title">{title}</span>
|
||||
{!noData && badge ? (
|
||||
<span className="skill-profile__toggle-badge">{badge}</span>
|
||||
) : null}
|
||||
<span className="skill-profile__toggle-icon" aria-hidden="true">
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
const body = (
|
||||
<div className="skill-profile__body">
|
||||
<p className="form-sub skill-profile__hint">{hint}</p>
|
||||
<p className="form-sub skill-profile__hint">{hintText}</p>
|
||||
|
||||
{noData ? (
|
||||
<p className="skill-profile__empty">
|
||||
|
|
@ -191,22 +226,25 @@ export default function SkillProfilePanel({
|
|||
<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>
|
||||
|
||||
<CategoryGroupedProfile
|
||||
profile={profile}
|
||||
ariaLabel="Top-Fähigkeit je Kategorie"
|
||||
/>
|
||||
{displayMode === 'full' ? (
|
||||
<FullSkillsProfile profile={profile} ariaLabel="Alle Fähigkeiten nach Gewicht" />
|
||||
) : (
|
||||
<CategoryGroupedProfile profile={profile} ariaLabel="Top-Fähigkeit je Kategorie" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{slots && slots.length > 0 ? (
|
||||
{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">
|
||||
|
|
@ -245,7 +283,29 @@ export default function SkillProfilePanel({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div className="skill-profile skill-profile--embedded">{body}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card skill-profile">
|
||||
<button
|
||||
type="button"
|
||||
className="skill-profile__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span className="skill-profile__toggle-title">{title}</span>
|
||||
{!noData && badge ? (
|
||||
<span className="skill-profile__toggle-badge">{badge}</span>
|
||||
) : null}
|
||||
<span className="skill-profile__toggle-icon" aria-hidden="true">
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
{expanded ? body : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user