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 ( +
+

+ Kombination · Stationen & Einzelübungen +

+ {archDisplay ? ( +

+ {archDisplay} + {archeKey && archDisplay !== archeKey ? ( + + ({archeKey}) + + ) : null} +

+ ) : null} +

+ {archetypeCoachHint(archeKey)} +

+ + {!slots.length ? ( +

Keine Stationen hinterlegt.

+ ) : ( +
    + {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 ( +
  1. +
    1 ? '6px' : '8px' }}> + {slotTitle} +
    + {candIds.length === 0 ? ( +

    Keine Übung zugeordnet.

    + ) : ( +
    + {candIds.map((cid, ci) => { + const ex = byId[cid] + const err = errById[cid] + const candTitleFallback = + slot.candidates?.find((c) => Number(c.exercise_id) === cid)?.title || + slot.candidates?.[ci]?.title + + const isAlt = candIds.length > 1 + + return ( +
    + {isAlt ? ( +
    + {candIds.length > 2 ? `Alternative ${ci + 1}` : ci === 0 ? 'Alternative A' : 'Alternative B'} +
    + ) : null} + {!ex && loadingIds ? ( +
    + + Übung #{cid} laden… +
    + ) : err ? ( +

    + Übung #{cid}: {err} +

    + ) : ex ? ( + <> +

    {ex.title}

    + {ex.summary ? ( +
    + +
    + ) : ( +

    + Keine Kurzbeschreibung im Katalog. +

    + )} + {ex.execution ? ( +
    + + Ablauf (Detail) + +
    + +
    +
    + ) : null} + {ex.trainer_notes ? ( +
    + + Hinweise Trainer + +
    + +
    +
    + ) : null} +

    + + Volle Übungsseite + +

    + + ) : ( +

    + {candTitleFallback || `Übung #${cid}`} +

    + )} +
    + ) + })} +
    + )} +
  2. + ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 288f4d9..ee4833a 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -5,6 +5,7 @@ import React from 'react' import { Link } from 'react-router-dom' import ExerciseRichTextBlock from './ExerciseRichTextBlock' +import CombinationCoachSlots from './CombinationCoachSlots' function TagRow({ exercise }) { const tags = [] @@ -76,6 +77,9 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise ? exercise.variants.find((v) => String(v.id) === String(variantId)) || null : null + const isCombination = + String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination' + return (
{variant ? ( @@ -106,6 +110,12 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise ) : null} ) : null} + {isCombination && Array.isArray(exercise.combination_slots) ? ( + + ) : null}

{exercise.title}

{meta.length > 0 && (

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) {