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
- 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.
190 lines
6.8 KiB
JavaScript
190 lines
6.8 KiB
JavaScript
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>
|
||
)
|
||
}
|