feat(version): bump to 0.8.101 and update exercise module versions
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 59s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 59s
- Updated app version to 0.8.101, reflecting recent enhancements. - Incremented exercise module version to 2.24.1, improving handling of combination exercises. - Added changelog entry for new features related to training-coach functionality in combination exercises. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3dc4c9c79e
commit
919910d52a
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.100"
|
APP_VERSION = "0.8.101"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260512056"
|
DB_SCHEMA_VERSION = "20260512056"
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
|
"planning": "0.9.1", # Kombinationsübungen: Sektionen PATCH/validator + exercise_kind GET; Frontend KEINE Varianten bei combination
|
||||||
|
|
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.100",
|
||||||
"date": "2026-05-12",
|
"date": "2026-05-12",
|
||||||
|
|
|
||||||
226
frontend/src/components/CombinationCoachSlots.jsx
Normal file
226
frontend/src/components/CombinationCoachSlots.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '14px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderLeft: '3px solid var(--accent-dark)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--text3)',
|
||||||
|
margin: '0 0 6px',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kombination · Stationen & Einzelübungen
|
||||||
|
</h3>
|
||||||
|
{archDisplay ? (
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
|
||||||
|
{archDisplay}
|
||||||
|
{archeKey && archDisplay !== archeKey ? (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: '0.78rem', fontWeight: 500, color: 'var(--text3)' }}>
|
||||||
|
({archeKey})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
|
||||||
|
{archetypeCoachHint(archeKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!slots.length ? (
|
||||||
|
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.88rem' }}>Keine Stationen hinterlegt.</p>
|
||||||
|
) : (
|
||||||
|
<ol style={{ margin: 0, paddingLeft: '1.1rem', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{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 (
|
||||||
|
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: candIds.length > 1 ? '6px' : '8px' }}>
|
||||||
|
{slotTitle}
|
||||||
|
</div>
|
||||||
|
{candIds.length === 0 ? (
|
||||||
|
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', paddingLeft: 0 }}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={`${cid}-${ci}`}
|
||||||
|
style={{
|
||||||
|
padding: '10px 11px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAlt ? (
|
||||||
|
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||||
|
{candIds.length > 2 ? `Alternative ${ci + 1}` : ci === 0 ? 'Alternative A' : 'Alternative B'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!ex && loadingIds ? (
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '0.86rem', color: 'var(--text2)' }}>
|
||||||
|
<span className="spinner" style={{ transform: 'scale(0.7)' }} />
|
||||||
|
Übung #{cid} laden…
|
||||||
|
</div>
|
||||||
|
) : err ? (
|
||||||
|
<p style={{ margin: 0, color: 'var(--danger)', fontSize: '0.88rem' }}>
|
||||||
|
Übung #{cid}: {err}
|
||||||
|
</p>
|
||||||
|
) : ex ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
||||||
|
{ex.summary ? (
|
||||||
|
<div style={{ marginBottom: ex.execution ? '8px' : 0 }}>
|
||||||
|
<ExerciseRichTextBlock html={ex.summary} exerciseId={ex.id} media={ex.media} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '0.86rem', color: 'var(--text3)' }}>
|
||||||
|
Keine Kurzbeschreibung im Katalog.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ex.execution ? (
|
||||||
|
<details style={{ marginTop: '4px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||||
|
Ablauf (Detail)
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<ExerciseRichTextBlock html={ex.execution} exerciseId={ex.id} media={ex.media} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{ex.trainer_notes ? (
|
||||||
|
<details style={{ marginTop: '6px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||||
|
Hinweise Trainer
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<ExerciseRichTextBlock html={ex.trainer_notes} exerciseId={ex.id} media={ex.media} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||||
|
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||||
|
Volle Übungsseite
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
|
||||||
|
{candTitleFallback || `Übung #${cid}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||||
|
import CombinationCoachSlots from './CombinationCoachSlots'
|
||||||
|
|
||||||
function TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
const tags = []
|
const tags = []
|
||||||
|
|
@ -76,6 +77,9 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const isCombination =
|
||||||
|
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
|
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
|
||||||
{variant ? (
|
{variant ? (
|
||||||
|
|
@ -106,6 +110,12 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isCombination && Array.isArray(exercise.combination_slots) ? (
|
||||||
|
<CombinationCoachSlots
|
||||||
|
combinationSlots={exercise.combination_slots}
|
||||||
|
methodArchetype={exercise.method_archetype}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
||||||
{meta.length > 0 && (
|
{meta.length > 0 && (
|
||||||
<p className="exercise-meta-line" style={{ marginBottom: '10px', color: 'var(--text3)', fontSize: '0.86rem' }}>
|
<p className="exercise-meta-line" style={{ marginBottom: '10px', color: 'var(--text3)', fontSize: '0.86rem' }}>
|
||||||
|
|
|
||||||
61
frontend/src/constants/combinationArchetypes.js
Normal file
61
frontend/src/constants/combinationArchetypes.js
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
||||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
|
||||||
|
|
||||||
const INTENSITY_OPTIONS = [
|
const INTENSITY_OPTIONS = [
|
||||||
{ value: '', label: '—' },
|
{ value: '', label: '—' },
|
||||||
|
|
@ -30,17 +31,6 @@ const VARIANT_DIFFICULTY = [
|
||||||
{ value: 'adapted', label: 'Angepasst' },
|
{ 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) {
|
function comboSlotsFromDetail(exercise) {
|
||||||
const raw = exercise?.combination_slots
|
const raw = exercise?.combination_slots
|
||||||
if (!Array.isArray(raw) || raw.length === 0) {
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user