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 - -
+ ) } 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 ( + + ) +} + 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 ( -
    +

    {error}

    ) @@ -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 ( +
    • + + {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 ( -
    • - - {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) -}