diff --git a/backend/version.py b/backend/version.py
index 2988c92..f937795 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.104"
+APP_VERSION = "0.8.105"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.105",
+ "date": "2026-05-12",
+ "changes": [
+ "Übungsbearbeitung Kombi: Stationen mit Pool per Modal (nur Einzelübungen), Zeiten pro Station in derselben Karte, Drag&Drop + Pfeile statt Index; API schreibt slot_index aus Reihenfolge; Gesamtdurchläufe bei Zirkel/Sequenz/Parcours/Parallel klar beschriftet.",
+ ],
+ },
{
"version": "0.8.104",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index f205fa6..cb6f5f4 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -45,6 +45,7 @@ export default function CombinationMethodProfileEditor({
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
+ omitPerSlotTiming = false,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
@@ -62,7 +63,7 @@ export default function CombinationMethodProfileEditor({
}, [comboSlotsOutline])
const showSlotTiming =
- ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
+ !omitPerSlotTiming && ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index ad92334..f384086 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -8,6 +8,7 @@ import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import MediaPreviewModal from '../components/MediaPreviewModal'
import ReportContentModal from '../components/ReportContentModal'
import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
+import ExercisePickerModal from '../components/ExercisePickerModal'
import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
@@ -16,6 +17,8 @@ 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'
+import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
+import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
@@ -32,16 +35,52 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' },
]
+/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
+const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
+
+function emptyComboSlotRow() {
+ return {
+ title: '',
+ candidate_exercise_ids: [],
+ exercise_title_by_id: {},
+ load_sec: '',
+ consecutive_reps: '',
+ intra_rep_rest_sec: '',
+ transition_after_sec: '',
+ }
+}
+
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
+ const mp =
+ exercise?.method_profile &&
+ typeof exercise.method_profile === 'object' &&
+ !Array.isArray(exercise.method_profile)
+ ? exercise.method_profile
+ : {}
+ const spvList = readSlotProfilesV1(mp)
+ const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
+
if (!Array.isArray(raw) || raw.length === 0) {
- return [{ slot_index: 0, title: '', idsText: '' }]
+ return [emptyComboSlotRow()]
}
- return raw.map((s, i) => ({
- slot_index: s.slot_index != null ? Number(s.slot_index) : i,
- title: s.title != null ? String(s.title) : '',
- idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '',
- }))
+ const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
+ return sorted.map((s) => {
+ const si = Number(s.slot_index)
+ const st = byIx.get(si) || {}
+ const cands = Array.isArray(s.candidate_exercise_ids)
+ ? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
+ : []
+ return {
+ title: s.title != null ? String(s.title) : '',
+ candidate_exercise_ids: cands,
+ exercise_title_by_id: {},
+ load_sec: st.load_sec != null ? String(st.load_sec) : '',
+ consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
+ intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
+ transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
+ }
+ })
}
function emptyVariantDraft() {
@@ -266,7 +305,7 @@ function emptyForm() {
exercise_kind: 'simple',
method_archetype: '',
method_profile_json: '{}',
- combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
+ combination_slots: [emptyComboSlotRow()],
}
}
@@ -569,6 +608,69 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
+ const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
+ const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
+
+ const reorderCombinationSlots = (fromI, toBeforeIx) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ if (fromI < 0 || fromI >= rows.length) return prev
+ const [moved] = rows.splice(fromI, 1)
+ let insertAt = toBeforeIx
+ if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
+ insertAt = Math.max(0, Math.min(insertAt, rows.length))
+ rows.splice(insertAt, 0, moved)
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const patchComboSlotRow = (idx, patch) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ if (!rows[idx]) return prev
+ rows[idx] = { ...rows[idx], ...patch }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const removeCandidateFromSlot = (slotIdx, exerciseId) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ const row = rows[slotIdx]
+ if (!row) return prev
+ const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
+ const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
+ delete labels[Number(exerciseId)]
+ rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
+ if (!Array.isArray(pickedList) || !pickedList.length) return
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ const row = rows[slotIdx] || emptyComboSlotRow()
+ const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n)))
+ const labels =
+ row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
+ pickedList.forEach((ex) => {
+ if (ex && ex.id != null) {
+ const id = Number(ex.id)
+ nextSet.add(id)
+ const t = (ex.title || '').trim()
+ if (t) labels[id] = t
+ }
+ })
+ rows[slotIdx] = { ...row, candidate_exercise_ids: [...nextSet], exercise_title_by_id: labels }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
const addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) {
@@ -1004,7 +1106,7 @@ function ExerciseFormPage() {
? {
method_archetype: '',
method_profile_json: '{}',
- combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
+ combination_slots: [emptyComboSlotRow()],
}
: {}),
}))
@@ -1031,119 +1133,290 @@ function ExerciseFormPage() {
))}
-
-
Stationen
-
- Zuerst die Stationen anlegen — dann erscheinen die Zeiten darunter auch pro Slot im Ablaufprofil.
+ {String(formData.method_archetype || '').trim() === 'station_parcour' ? (
+
+ Parcours / Bahnsystem: typischerweise starten alle an Station 1 und durchlaufen der
+ Reihe nach alle Punkte (Geschwindigkeit variabel). Die Stationsreihenfolge unten ist der Ablaufweg;
+ Zeitangaben pro Station und Gesamtdurchläufe im Ablaufprofil strukturieren das
+ spätere Coaching.
- {(formData.combination_slots || []).map((row, idx) => (
-
-
-
- Idx.
-
- {
- const next = [...(formData.combination_slots || [])]
- const v = e.target.value
- next[idx] = {
- ...row,
- slot_index: v === '' ? '' : parseInt(v, 10),
- }
- updateFormField('combination_slots', next)
- }}
- />
-
-
-
- Titel
-
- {
- const next = [...(formData.combination_slots || [])]
- next[idx] = { ...row, title: e.target.value }
- updateFormField('combination_slots', next)
- }}
- />
-
-
-
- Übungs-IDs
-
- {
- const next = [...(formData.combination_slots || [])]
- next[idx] = { ...row, idsText: e.target.value }
- updateFormField('combination_slots', next)
- }}
- placeholder="z. B. 12, 34, 56"
- />
-
-
{
- const prev = formData.combination_slots || []
- const next = prev.filter((_, j) => j !== idx)
- updateFormField(
- 'combination_slots',
- next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
- )
+ ) : null}
+
+
+ Stationen und Übungs‑Pool
+
+ Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
+
+
+
+ Jede Station: Titel (optional), am Ort wählbare Einzelübungen sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen).
+
+ {(formData.combination_slots || []).map((row, idx) => {
+ const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
+ const lbl =
+ row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
+ ? row.exercise_title_by_id
+ : {}
+ const isDropHere = comboDropTargetIx === idx
+ return (
+
{
+ if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ setComboDropTargetIx(idx)
+ }}
+ onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
+ onDrop={(e) => {
+ const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
+ const fromI = parseInt(rawFrom, 10)
+ e.preventDefault()
+ setComboDropTargetIx(null)
+ if (!Number.isFinite(fromI)) return
+ reorderCombinationSlots(fromI, idx)
+ }}
+ style={{
+ marginBottom: '12px',
+ padding: '12px 14px',
+ borderRadius: '12px',
+ border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
+ background: 'var(--surface)',
+ boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
}}
>
- Entf.
-
-
- ))}
+
+
{
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData(DND_EXERCISE_COMBO_STATION, String(idx))
+ }}
+ onDragEnd={() => setComboDropTargetIx(null)}
+ aria-label={`Station ${idx + 1} ziehen`}
+ title="Ziehen zum Sortieren"
+ className="btn btn-secondary framework-ctrl framework-ctrl--xs"
+ style={{ cursor: 'grab', padding: '6px 8px', touchAction: 'none' }}
+ >
+
+
+
+ reorderCombinationSlots(idx, idx - 1)}
+ >
+ ▲
+
+ reorderCombinationSlots(idx, idx + 2)}
+ >
+ ▼
+
+
+
+
+ Station {idx + 1} — Titel
+
+ patchComboSlotRow(idx, { title: e.target.value })}
+ />
+
+
+ setComboStationPickerIx(idx)}
+ >
+ Einzelübungen wählen…
+
+ {
+ const prev = formData.combination_slots || []
+ const next = prev.filter((_, j) => j !== idx)
+ updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
+ }}
+ >
+ Station entfernen
+
+
+
+
+
+ Gewählte Einzelübungen (Pool für diese Station)
+
+ {candIds.length === 0 ? (
+
Noch keine Übung gewählt — mindestens eine erforderlich zum Speichern.
+ ) : (
+
+ {candIds.map((id) => (
+
+
+ {(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
+
+ removeCandidateFromSlot(idx, id)}
+ >
+ ✗
+
+
+ ))}
+
+ )}
+
+
+
+ )
+ })}
+ {
+ if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ }}
+ onDrop={(e) => {
+ const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
+ const fromI = parseInt(rawFrom, 10)
+ e.preventDefault()
+ setComboDropTargetIx(null)
+ if (!Number.isFinite(fromI)) return
+ const len = (formData.combination_slots || []).length
+ reorderCombinationSlots(fromI, len)
+ }}
+ style={{
+ padding: '10px',
+ textAlign: 'center',
+ fontSize: '11px',
+ color: 'var(--text3)',
+ border: '1px dashed var(--border)',
+ borderRadius: '10px',
+ marginBottom: '8px',
+ }}
+ >
+ Hier ablegen zum Anhängen am Ende der Reihenfolge
+
{
- const cur = formData.combination_slots || []
- const ixList = cur
- .map((r) =>
- typeof r.slot_index === 'number' && !Number.isNaN(r.slot_index) ? r.slot_index : null,
- )
- .filter((n) => n != null)
- const nextIx = ixList.length ? Math.max(...ixList) + 1 : 0
- updateFormField('combination_slots', [
- ...cur,
- { slot_index: nextIx, title: '', idsText: '' },
- ])
- }}
+ onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
>
- + Station
+ + Station hinzufügen
- Ablaufprofil (Zeiten & Runden)
+ Ablaufprofil (Globale Zeiten & Gesamtdurchläufe)
updateFormField('method_profile_json', s)}
- comboSlotsOutline={formData.combination_slots || []}
+ comboSlotsOutline={(formData.combination_slots || []).map((r, i) => ({
+ slot_index: i,
+ title: r.title || '',
+ }))}
+ omitPerSlotTiming
/>
>
@@ -1871,6 +2144,18 @@ function ExerciseFormPage() {
}
/>
)}
+
setComboStationPickerIx(null)}
+ exerciseKindAny={['simple']}
+ multiSelect
+ enableQuickCreateDraft
+ onSelectExercises={(picked) => {
+ if (comboStationPickerIx === null) return
+ mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
+ setComboStationPickerIx(null)
+ }}
+ />
{reportTarget && (
99) {
- throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
+ let ids = Array.isArray(row.candidate_exercise_ids)
+ ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
+ : []
+
+ /** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
+ if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
+ ids = row.idsText
+ .split(/[\s,;]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((s) => parseInt(s, 10))
+ .filter((n) => Number.isFinite(n))
}
- const idsText = typeof row.idsText === 'string' ? row.idsText : ''
- const candidate_exercise_ids = idsText
- .split(/[\s,;]+/)
- .map((s) => s.trim())
- .filter(Boolean)
- .map((s) => parseInt(s, 10))
- .filter((n) => Number.isFinite(n))
+
combination_slots.push({
- slot_index: ix,
+ slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null,
- candidate_exercise_ids,
+ candidate_exercise_ids: ids,
})
}
+ const slot_profiles_v1_next = []
+ for (let i = 0; i < slotRows.length; i += 1) {
+ const row = slotRows[i] || {}
+ const o = { slot_index: i }
+ const load = parseTimingField(row.load_sec)
+ const crs = parseTimingField(row.consecutive_reps)
+ const intra = parseTimingField(row.intra_rep_rest_sec)
+ const tran = parseTimingField(row.transition_after_sec)
+ if (load !== undefined && load >= 0) o.load_sec = Math.round(load)
+ if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
+ if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
+ if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
+ if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
+ }
+
payload.method_archetype = (formData.method_archetype || '').trim() || null
+ if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
+ else delete mpObj.slot_profiles_v1
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index 1beb348..f1643ef 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -21,6 +21,13 @@ function parseProfileJson(raw) {
/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
sequence_linear: [
+ {
+ key: 'rounds',
+ kind: 'int',
+ label: 'Anzahl Gesamtdurchläufe (komplette Sequenz, alle Stationen nacheinander)',
+ min: 1,
+ max: 999,
+ },
{
key: 'hint_step_duration_sec',
kind: 'int',
@@ -37,6 +44,14 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
},
],
circuit_rotate_time: [
+ {
+ key: 'rounds',
+ kind: 'int',
+ label:
+ 'Anzahl Gesamtdurchläufe (jede Station pro Sportler mehrfach beim Umlauf, z. B. 4 Stationen × 2 = zwei komplette Runden)',
+ min: 1,
+ max: 999,
+ },
{
key: 'work_seconds',
kind: 'int',
@@ -58,15 +73,15 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
min: 0,
max: INT_MAX,
},
+ ],
+ circuit_all_parallel: [
{
key: 'rounds',
kind: 'int',
- label: 'Runden (optional, wenn alle Station je Runde angefahren werden)',
+ label: 'Anzahl Durchläufe (wenn alle parallel dieselbe Rundenlogik haben, optional)',
min: 1,
max: 999,
},
- ],
- circuit_all_parallel: [
{
key: 'explain_before_seconds',
kind: 'int',
@@ -81,6 +96,13 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
},
],
station_parcour: [
+ {
+ key: 'rounds',
+ kind: 'int',
+ label: 'Anzahl Durchläufe des Parcours (Start Station 1, alle Bahnpunkte, Wiederholung bei Bedarf)',
+ min: 1,
+ max: 999,
+ },
{
key: 'allow_free_visit_order',
kind: 'bool',
@@ -121,7 +143,7 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
{
key: 'interval_rounds',
kind: 'int',
- label: 'Anzahl Wiederholungen / Runden der Domäne (optional)',
+ label: 'Anzahl Wiederholungen der Intervalldomäne (komplette Zyklen Arbeit/Pause)',
min: 1,
max: 999,
},