shinkan-jinkendo/frontend/src/components/skills/SkillProfilePanel.jsx
Lars 732b322c52
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m27s
Implement Phase 3 Features for Skill Profiles and Discovery
- Updated the framework program documentation to reflect the completion of Phase 3 v1.0, including new skill scoring and API enhancements.
- Added new API endpoints for skill profile retrieval and suggestions, improving the ability to aggregate and display skills based on training data.
- Introduced new UI components for skill profiles and discovery in the frontend, enhancing user interaction with training frameworks and skills.
- Updated version information to 0.8.151, reflecting the addition of skill profiles and related features.
2026-05-20 16:42:25 +02:00

190 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo, useState } from 'react'
function SkillBar({ skill, maxShare }) {
const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0
return (
<li className="skill-profile__row">
<div className="skill-profile__row-head">
<span className="skill-profile__name" title={skill.skill_name}>
{skill.skill_name}
</span>
<span className="skill-profile__pct">{skill.share_percent}%</span>
</div>
<div className="skill-profile__bar-track" aria-hidden="true">
<div
className="skill-profile__bar-fill"
style={{ width: `${pct}%` }}
/>
</div>
{skill.primary_link_count > 0 ? (
<span className="skill-profile__meta-hint">
{skill.primary_link_count}× als Primär-Fähigkeit in Übungen
</span>
) : null}
</li>
)
}
/**
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
*/
export default function SkillProfilePanel({
profile,
slots = null,
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).',
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
const skills = profile?.skills || []
const maxShare = useMemo(
() => Math.max(...skills.map((s) => s.share_percent || 0), 1),
[skills]
)
if (loading) {
return (
<div className="card skill-profile skill-profile--loading">
<p className="skill-profile__status">Fähigkeiten-Profil wird berechnet</p>
</div>
)
}
if (error) {
return (
<div className="card skill-profile skill-profile--error">
<p className="skill-profile__status">{error}</p>
</div>
)
}
const noData =
!profile ||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 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 && skills.length > 0 ? (
<span className="skill-profile__toggle-badge">
Top: {skills[0].skill_name} ({skills[0].share_percent}%)
</span>
) : null}
<span className="skill-profile__toggle-icon" aria-hidden="true">
{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>{skills.length}</strong> Fähigkeiten
</span>
<span>
<strong>{profile.exercise_occurrence_count}</strong> Positionen
</span>
</div>
<ul className="skill-profile__list" aria-label="Fähigkeiten nach Gewicht">
{skills.slice(0, 12).map((sk) => (
<SkillBar key={sk.skill_id} skill={sk} maxShare={maxShare} />
))}
</ul>
{profile.by_category?.length > 1 ? (
<div className="skill-profile__categories">
<span className="skill-profile__categories-label">Nach Kategorie</span>
<div className="skill-profile__category-chips">
{profile.by_category.slice(0, 6).map((c) => (
<span key={c.category} className="skill-profile__category-chip">
{c.category} {c.share_percent}%
</span>
))}
</div>
</div>
) : null}
</>
)}
{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 top = sl.profile?.skills?.[0]
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>
{top ? (
<span className="skill-profile__slot-top">
{top.skill_name} {top.share_percent}%
</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?.skills?.length > 0 ? (
<ul className="skill-profile__list skill-profile__list--nested">
{sl.profile.skills.slice(0, 6).map((sk) => (
<SkillBar
key={sk.skill_id}
skill={sk}
maxShare={Math.max(
...sl.profile.skills.map((x) => x.share_percent || 0),
1
)}
/>
))}
</ul>
) : null}
</li>
)
})}
</ul>
</div>
) : null}
</div>
) : null}
</div>
)
}