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 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m27s
- Enhanced the skill scoring system with category grouping and a universal scale for improved comparability across programs. - Introduced new calculations for artifact share percentage and universal percent, allowing for a more nuanced understanding of skill contributions. - Updated the API to reflect changes in the skill profile structure, including main category and top skill details. - Improved frontend components to display skills by main category, enhancing user experience in skill discovery and profile visualization. - Adjusted tests to validate the new scoring logic and ensure accurate representation of skills and their weights.
186 lines
6.7 KiB
JavaScript
186 lines
6.7 KiB
JavaScript
import React, { useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../../utils/api'
|
|
|
|
const ARTIFACT_LABELS = {
|
|
framework_program: 'Rahmenprogramm',
|
|
training_module: 'Trainingsmodul',
|
|
progression_graph: 'Regressionspfad',
|
|
}
|
|
|
|
function formatScore(value) {
|
|
const n = Number(value)
|
|
if (!Number.isFinite(n)) return '0'
|
|
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
|
}
|
|
|
|
/**
|
|
* Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
|
|
*/
|
|
export default function SkillDiscoveryPanel({ skills = [] }) {
|
|
const [selectedIds, setSelectedIds] = useState([])
|
|
const [query, setQuery] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [result, setResult] = useState(null)
|
|
|
|
const filteredSkills = useMemo(() => {
|
|
const q = query.trim().toLowerCase()
|
|
const list = (skills || []).filter((s) => s.status !== 'inactive')
|
|
if (!q) return list
|
|
return list.filter(
|
|
(s) =>
|
|
(s.name || '').toLowerCase().includes(q) ||
|
|
(s.category || '').toLowerCase().includes(q)
|
|
)
|
|
}, [skills, query])
|
|
|
|
const toggleSkill = (id) => {
|
|
const s = String(id)
|
|
setSelectedIds((prev) =>
|
|
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
|
|
)
|
|
}
|
|
|
|
async function handleSearch() {
|
|
const ids = selectedIds.map((x) => parseInt(x, 10)).filter((n) => n > 0)
|
|
if (!ids.length) {
|
|
setError('Wähle mindestens eine Fähigkeit.')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
setError('')
|
|
setResult(null)
|
|
try {
|
|
const data = await api.getSkillDiscoverySuggestions(ids, { limit: 25 })
|
|
setResult(data)
|
|
} catch (e) {
|
|
setError(e.message || 'Suche fehlgeschlagen')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="skill-discovery card">
|
|
<h2 className="skill-discovery__title">Planungs-Vorschläge</h2>
|
|
<p className="form-sub skill-discovery__lead">
|
|
Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst — Shinkan schlägt passende
|
|
Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
|
|
Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
|
|
</p>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Fähigkeiten filtern</label>
|
|
<input
|
|
className="form-input"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Name oder Kategorie …"
|
|
/>
|
|
</div>
|
|
|
|
<div className="skill-discovery__pick-grid" role="group" aria-label="Fähigkeiten auswählen">
|
|
{filteredSkills.length === 0 ? (
|
|
<p className="form-sub">Keine Fähigkeiten gefunden.</p>
|
|
) : (
|
|
filteredSkills.map((sk) => (
|
|
<label key={sk.id} className="skill-discovery__pick">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(String(sk.id))}
|
|
onChange={() => toggleSkill(sk.id)}
|
|
/>
|
|
<span className="skill-discovery__pick-name">{sk.name}</span>
|
|
{sk.category ? (
|
|
<span className="skill-discovery__pick-cat">{sk.category}</span>
|
|
) : null}
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="skill-discovery__actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={loading || selectedIds.length === 0}
|
|
onClick={handleSearch}
|
|
>
|
|
{loading ? 'Suche …' : 'Bibliothek durchsuchen'}
|
|
</button>
|
|
{selectedIds.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => {
|
|
setSelectedIds([])
|
|
setResult(null)
|
|
setError('')
|
|
}}
|
|
>
|
|
Auswahl leeren
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? <p className="skill-discovery__error">{error}</p> : null}
|
|
|
|
{result?.suggestions?.length > 0 ? (
|
|
<ul className="skill-discovery__results">
|
|
{result.suggestions.map((item) => {
|
|
const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
|
|
const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
|
|
const topByCat = item.skill_profile_summary?.top_by_category || []
|
|
return (
|
|
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
|
|
<div className="skill-discovery__result-head">
|
|
<span className="skill-discovery__result-type">
|
|
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
|
|
</span>
|
|
<span className="skill-discovery__result-match" title="Summe der Trainingsgewichte der gewählten Fähigkeiten">
|
|
Gewicht {formatScore(matchScore)}
|
|
</span>
|
|
</div>
|
|
<strong className="skill-discovery__result-title">
|
|
{item.artifact_title || `#${item.artifact_id}`}
|
|
</strong>
|
|
{item.match?.matched_skills?.length > 0 ? (
|
|
<p className="skill-discovery__result-skills">
|
|
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
|
|
{focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
|
|
</p>
|
|
) : null}
|
|
{topByCat.length > 0 ? (
|
|
<ul className="skill-discovery__result-cats">
|
|
{topByCat.slice(0, 4).map((row) => (
|
|
<li key={`${row.category_name}-${row.skill_id}`}>
|
|
<span className="skill-discovery__result-cat-name">{row.category_name}</span>
|
|
<span>{row.skill_name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{item.path ? (
|
|
<Link to={item.path} className="btn btn-secondary btn-small skill-discovery__result-link">
|
|
Öffnen
|
|
</Link>
|
|
) : item.artifact_type === 'progression_graph' ? (
|
|
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
|
Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten.
|
|
</p>
|
|
) : null}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
) : result && !loading ? (
|
|
<p className="form-sub skill-discovery__no-hit">
|
|
Keine passenden Artefakte in deiner sichtbaren Bibliothek — prüfe Fähigkeiten-Verknüpfungen an
|
|
den Übungen oder erweitere die Auswahl.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|