diff --git a/backend/version.py b/backend/version.py
index 9f8d5b5..c1144b6 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.100"
+APP_VERSION = "0.8.101"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512056"
@@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056)
+ "exercises": "2.24.1", # Coach/Kombination: Stationen laden Einzelübungen + Archetyp-Hilfstext (Frontend ExerciseFullContent)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.101",
+ "date": "2026-05-12",
+ "changes": [
+ "Training-Coach bei Kombinationsübungen: Stationen/Kandidaten mit geladenem Katalog (Kurzbeschreibung, aufklappbar Ablauf/Trainerhinweise); Archetyp-spezifischer Coach-Hilfstext; Archetyp-Labels aus `combinationArchetypes.js`.",
+ ],
+ },
{
"version": "0.8.100",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
new file mode 100644
index 0000000..65f9f14
--- /dev/null
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -0,0 +1,226 @@
+/**
+ * Kombinationsübung im Coach: Archetyp-Hinweis + Katalog-Inhalt je Slot/Kandidat.
+ */
+import React, { useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+import ExerciseRichTextBlock from './ExerciseRichTextBlock'
+import {
+ archetypeCoachHint,
+ combinationArchetypeLabel,
+ sortCombinationSlotsForDisplay,
+} from '../constants/combinationArchetypes'
+
+export default function CombinationCoachSlots({ combinationSlots, methodArchetype }) {
+ const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
+
+ const candidateIds = useMemo(() => {
+ const set = new Set()
+ for (const s of slots) {
+ for (const id of s.candidate_exercise_ids || []) {
+ const n = typeof id === 'number' ? id : parseInt(String(id), 10)
+ if (Number.isFinite(n)) set.add(n)
+ }
+ }
+ return [...set]
+ }, [slots])
+
+ const [byId, setById] = useState({})
+ const [errById, setErrById] = useState({})
+ const [loadingIds, setLoadingIds] = useState(false)
+
+ const sig = candidateIds.slice().sort((a, b) => a - b).join(',')
+
+ useEffect(() => {
+ let cancelled = false
+ setById({})
+ setErrById({})
+
+ if (candidateIds.length === 0) {
+ setLoadingIds(false)
+ return () => {
+ cancelled = true
+ }
+ }
+
+ setLoadingIds(true)
+ Promise.all(
+ candidateIds.map((id) =>
+ api.getExercise(id).then(
+ (ex) => ({ id, ok: true, ex }),
+ (e) => ({
+ id,
+ ok: false,
+ err: e?.message || String(e),
+ }),
+ ),
+ ),
+ ).then((results) => {
+ if (cancelled) return
+ const map = {}
+ const emap = {}
+ for (const r of results) {
+ if (r.ok) map[r.id] = r.ex
+ else emap[r.id] = r.err
+ }
+ setById(map)
+ setErrById(emap)
+ setLoadingIds(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [sig])
+
+ const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
+ const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
+
+ return (
+
+ {archDisplay}
+ {archeKey && archDisplay !== archeKey ? (
+
+ ({archeKey})
+
+ ) : null}
+
+ {archetypeCoachHint(archeKey)}
+ Keine Stationen hinterlegt. Keine Übung zugeordnet.
+ Übung #{cid}: {err}
+ {ex.title}
+ Keine Kurzbeschreibung im Katalog.
+
+
+ Volle Übungsseite
+
+
+ {candTitleFallback || `Übung #${cid}`}
+
+ Kombination · Stationen & Einzelübungen
+
+ {archDisplay ? (
+
+ {slots.map((slot, si) => {
+ const candIdsRaw = slot.candidate_exercise_ids || []
+ const candIds = candIdsRaw
+ .map((id) => (typeof id === 'number' ? id : parseInt(String(id), 10)))
+ .filter((n) => Number.isFinite(n))
+
+ const slotTitle =
+ (slot.title && String(slot.title).trim()) ||
+ (candIds.length <= 1 && slot.candidates?.[0]?.title) ||
+ `Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
+
+ return (
+
+ )}
+
+ Ablauf (Detail)
+
+
+ Hinweise Trainer
+
+
diff --git a/frontend/src/constants/combinationArchetypes.js b/frontend/src/constants/combinationArchetypes.js new file mode 100644 index 0000000..928c39d --- /dev/null +++ b/frontend/src/constants/combinationArchetypes.js @@ -0,0 +1,61 @@ +/** API `method_archetype`-Werte (Backend `COMBINATION_ARCHETYPE_IDS`). */ + +export const COMBINATION_ARCHETYPE_OPTIONS = [ + { id: 'sequence_linear', label: 'Lineare Sequenz' }, + { id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' }, + { id: 'circuit_all_parallel', label: 'Parallele Stationen' }, + { id: 'station_parcour', label: 'Parcours' }, + { id: 'pair_superset', label: 'Partner- / Paarwechsel' }, + { id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' }, + { id: 'free_method_block', label: 'Freier Methodenblock' }, +] + +const LABEL_BY_ID = Object.fromEntries( + COMBINATION_ARCHETYPE_OPTIONS.map((o) => [String(o.id), o.label]), +) + +/** Coach-/Lesetexte: strukturieren die Erwartung, nicht die komplette Methodik */ +const COACH_HINT_BY_ID = { + sequence_linear: + 'Station für Station der Reihenfolge nach durchfahren. Pro Abschnitt zuerst klarziehen, dann erst zur nächsten Übung übergehen.', + circuit_rotate_time: + 'Zirkelsystem nach Zeitfenster drehen oder Gruppe weitergeben. Halte Rotation und Pausen an der Kombi-Beschreibung fest.', + circuit_all_parallel: + 'Alle Stationen parallel nutzen: Aufteilen, gleicher Zeitraum bzw. Rundenlogik wie in dieser Kombination beschrieben.', + station_parcour: + 'Parcours: Besuchsreihenfolge und Regeln (z.\u202fB. Stopp-/Wechselpunkte) aus der Kombi-Beschreibung und Stationsnamen.', + pair_superset: + 'Nach Paar-/Superset-Logik abstimmen: z.\u202fB. Übung A ↔ B oder Abwechseln — Rhythmus an der Kombi oder Stationen ausrichten.', + time_domain_interval: + 'Strikt an die Zeituhr bzw. Intervallarbeit halten (Arbeit, Pause, Etappen). Kombi beschreibt meist Arbeit–Pause–Schema.', + free_method_block: + 'Lockerer Stationenblock: Reihenfolge und Verweildauer können flexibel sein — Stationsübungen unten sind die angebotenen Bausteine.', +} + +export function combinationArchetypeLabel(archetypeId) { + if (archetypeId == null || String(archetypeId).trim() === '') { + return null + } + const key = String(archetypeId).trim() + return LABEL_BY_ID[key] || key +} + +export function archetypeCoachHint(archetypeId) { + if (archetypeId == null || String(archetypeId).trim() === '') { + return 'Nutze die Stationen wie in der Kombi-Beschreibung oben angelegt.' + } + const key = String(archetypeId).trim() + return COACH_HINT_BY_ID[key] || 'Nutze die Stationen entsprechend dem gewählten Archetyp und der Kombination-Beschreibung.' +} + +export function sortCombinationSlotsForDisplay(slotsRaw) { + if (!Array.isArray(slotsRaw) || slotsRaw.length === 0) return [] + return [...slotsRaw].sort((a, b) => { + const ia = Number(a.slot_index) + const ib = Number(b.slot_index) + const na = Number.isFinite(ia) ? ia : 0 + const nb = Number.isFinite(ib) ? ib : 0 + if (na !== nb) return na - nb + return String(a.title || '').localeCompare(String(b.title || ''), 'de') + }) +} diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index d49c38a..ba7a42f 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -14,6 +14,7 @@ import { import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' import { useAuth } from '../context/AuthContext' +import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes' const INTENSITY_OPTIONS = [ { value: '', label: '—' }, @@ -30,17 +31,6 @@ const VARIANT_DIFFICULTY = [ { value: 'adapted', label: 'Angepasst' }, ] -/** An API `method_archetype` (Backend `COMBINATION_ARCHETYPE_IDS`) */ -const COMBINATION_ARCHETYPE_OPTIONS = [ - { id: 'sequence_linear', label: 'Lineare Sequenz' }, - { id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' }, - { id: 'circuit_all_parallel', label: 'Parallele Stationen' }, - { id: 'station_parcour', label: 'Parcours' }, - { id: 'pair_superset', label: 'Partner- / Paarwechsel' }, - { id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' }, - { id: 'free_method_block', label: 'Freier Methodenblock' }, -] - function comboSlotsFromDetail(exercise) { const raw = exercise?.combination_slots if (!Array.isArray(raw) || raw.length === 0) {