shinkan-jinkendo/frontend/src/pages/ExerciseDetailPage.jsx
Lars 1d698e4b0a
Some checks failed
Deploy Development / deploy (push) Failing after 22s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 3s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Implement Phase 3 Enhancements for Skill Scoring and Profiles
- Added capabilities for weighted skill profiles, allowing trainers to compare training modules, frameworks, and regression paths based on skill contributions.
- Updated the skill scoring specification to include peer context separation and list filtering, ensuring accurate comparisons among visible artifacts of the same type.
- Enhanced the API to support batch summaries for skill profiles and discovery suggestions, improving data retrieval efficiency.
- Refactored frontend components to display skill metrics, including scores and peer percentages, with improved filtering options for better user experience.
- Updated documentation to reflect the latest changes and enhancements in the skill scoring system.
2026-05-21 12:35:45 +02:00

322 lines
12 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, { useEffect, useMemo, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import PageReturnButton from '../components/PageReturnButton'
import NavStateLink from '../components/NavStateLink'
import {
EXERCISES_LIST_PATH,
buildCurrentLocationReturnContext,
} from '../utils/navReturnContext'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import ExercisePeekModal from '../components/ExercisePeekModal'
import { formatSkillLevelSlug } from '../constants/skillLevels'
import { formatExerciseSkillIntensityLabel } from '../constants/exerciseSkillIntensity'
function TagRow({ exercise }) {
const tags = []
;(exercise.focus_areas || []).forEach((f) => {
tags.push({ key: `fa-${f.id}`, label: f.name, accent: !!f.is_primary })
})
;(exercise.training_styles || []).forEach((t) => {
tags.push({ key: `ts-${t.id}`, label: t.name, accent: false })
})
;(exercise.training_types || []).forEach((t) => {
tags.push({ key: `tt-${t.id}`, label: t.name, accent: false })
})
;(exercise.target_groups || []).forEach((g) => {
tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary })
})
if (tags.length === 0) return null
return (
<div className="exercise-tag-row">
{tags.map((t) => (
<span key={t.key} className={`exercise-tag${t.accent ? ' exercise-tag--accent' : ''}`}>
{t.label}
</span>
))}
</div>
)
}
function metaParts(exercise) {
const parts = []
if (exercise.duration_min != null || exercise.duration_max != null) {
const a = exercise.duration_min
const b = exercise.duration_max
if (a != null && b != null && a !== b) parts.push(`${a}${b} Min.`)
else if (a != null) parts.push(`ca. ${a} Min.`)
else if (b != null) parts.push(`ca. ${b} Min.`)
}
if (exercise.group_size_min != null || exercise.group_size_max != null) {
const a = exercise.group_size_min
const b = exercise.group_size_max
if (a != null && b != null && a !== b) parts.push(`Gruppe ${a}${b}`)
else if (a != null) parts.push(`Gruppe ab ${a}`)
else if (b != null) parts.push(`Gruppe bis ${b}`)
}
return parts
}
function ExerciseDetailPage() {
const { id } = useParams()
const location = useLocation()
const editReturnContext = useMemo(
() => buildCurrentLocationReturnContext(location, 'Zurück zur Übung'),
[location]
)
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
/** Schnellansicht für eingebettete Einzelübungen (Kombination) — ohne Route zu verlassen */
const [embeddedPeekExerciseId, setEmbeddedPeekExerciseId] = useState(null)
useEffect(() => {
let cancelled = false
const load = async () => {
setLoading(true)
setError(null)
try {
const data = await api.getExercise(id)
if (!cancelled) setExercise(data)
} catch (err) {
if (!cancelled) setError(err)
} finally {
if (!cancelled) setLoading(false)
}
}
if (id) load()
return () => {
cancelled = true
}
}, [id])
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
if (error) {
const msg = error.message || String(error)
return (
<div style={{ padding: '1rem' }} className="app-page">
<div className="card">
<h2>Übung</h2>
<p style={{ color: 'var(--danger)' }}>{msg}</p>
<PageReturnButton
fallbackPath={EXERCISES_LIST_PATH}
fallbackLabel="Zurück zur Übungsliste"
/>
</div>
</div>
)
}
if (!exercise) return null
const meta = metaParts(exercise)
const isCombinationDetail =
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0
const catalogMethodProfileForBracket =
exercise.method_profile &&
typeof exercise.method_profile === 'object' &&
!Array.isArray(exercise.method_profile)
? exercise.method_profile
: {}
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<ExercisePeekModal
key={embeddedPeekExerciseId != null ? String(embeddedPeekExerciseId) : 'exercise-detail-peek'}
open={embeddedPeekExerciseId != null}
exerciseId={embeddedPeekExerciseId}
onClose={() => setEmbeddedPeekExerciseId(null)}
/>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<PageReturnButton
fallbackPath={EXERCISES_LIST_PATH}
fallbackLabel="Zurück zur Übungsliste"
className="page-return-btn btn btn-secondary btn-small"
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<NavStateLink
to={`/exercises/${exercise.id}/edit`}
returnContext={editReturnContext}
className="btn btn-primary"
>
Bearbeiten
</NavStateLink>
</div>
</div>
<div className="card exercise-detail-section">
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
{exercise.summary && (
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
</div>
)}
<TagRow exercise={exercise} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '10px' }}>
<span className="badge">{exercise.visibility}</span>
<span className="badge">{exercise.status}</span>
{exercise.club_name && <span className="badge">{exercise.club_name}</span>}
</div>
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div>
{isCombinationDetail ? (
<section className="card exercise-detail-section">
<h2>Ablauf und Stationen</h2>
<p style={{ marginTop: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
KatalogAblauf mit Archetyp, Zeiten und Stationen. Station bzw. Einzelübung antippen öffnet eine
Schnellansicht mit Kurztext und Ablauf, ohne diese Seite zu verlassen. Die vollständige Übungsseite
liegt im Popup unten als Link.
</p>
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(exercise.method_archetype || '').trim()}
methodProfile={catalogMethodProfileForBracket}
combinationSlots={exercise.combination_slots}
planningAdjusted={false}
candidateInteraction="button"
onCandidatePeek={(exerciseId) => setEmbeddedPeekExerciseId(Number(exerciseId))}
/>
</div>
</section>
) : null}
{exercise.goal && (
<section className="card exercise-detail-section">
<h2>Ziel</h2>
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{(exercise.equipment || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Material &amp; Aufbau</h2>
<ul style={{ paddingLeft: '1.2rem', margin: 0 }}>
{exercise.equipment.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</section>
)}
{exercise.preparation && (
<section className="card exercise-detail-section">
<h2>Vorbereitung</h2>
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{exercise.execution && (
<section className="card exercise-detail-section">
<h2>Ablauf</h2>
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={exercise.id} />
{exercise.trainer_notes && (
<section className="card exercise-detail-section">
<h2>Hinweise für Trainer</h2>
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{(exercise.skills || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Fähigkeiten</h2>
<div className="exercise-tag-row">
{exercise.skills.map((s) => {
const rl = formatSkillLevelSlug(s.required_level)
const tl = formatSkillLevelSlug(s.target_level)
const lvl =
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
return (
<span key={s.id} className="exercise-tag">
{s.skill_name}
{` · ${formatExerciseSkillIntensityLabel(s.intensity)}`}
{lvl}
</span>
)
})}
</div>
</section>
)}
{(exercise.variants || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Varianten</h2>
{exercise.variants.map((v) => {
const dur =
v.duration_min != null || v.duration_max != null
? v.duration_min != null && v.duration_max != null && v.duration_min !== v.duration_max
? `${v.duration_min}${v.duration_max} Min.`
: v.duration_min != null
? `ca. ${v.duration_min} Min.`
: `bis ca. ${v.duration_max} Min.`
: null
const diffLabel =
v.difficulty_adjustment === 'easier'
? 'einfacher'
: v.difficulty_adjustment === 'harder'
? 'schwerer'
: v.difficulty_adjustment === 'same'
? 'gleiche Schwierigkeit'
: v.difficulty_adjustment === 'adapted'
? 'angepasst'
: null
const equip =
Array.isArray(v.equipment_changes) && v.equipment_changes.length > 0
? v.equipment_changes.join(', ')
: null
return (
<div
key={v.id}
style={{
marginBottom: '1rem',
paddingBottom: '1rem',
borderBottom: '1px solid var(--border)',
}}
>
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
{(dur || diffLabel || equip || v.progression_level != null) && (
<div style={{ fontSize: '13px', color: 'var(--text3)', marginTop: '4px' }}>
{[dur, diffLabel, equip && `Material: ${equip}`, v.progression_level != null && `Progression ${v.progression_level}`]
.filter(Boolean)
.join(' · ')}
</div>
)}
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
{v.execution_changes && (
<div style={{ marginTop: '8px' }}>
<ExerciseRichTextBlock
html={v.execution_changes}
exerciseId={exercise.id}
media={exercise.media}
/>
</div>
)}
</div>
)
})}
</section>
)}
</div>
)
}
export default ExerciseDetailPage