feat(version): bump to 0.8.105 and enhance combination exercise features
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s

- Updated app version to 0.8.105, reflecting recent improvements in combination exercise handling.
- Added support for per-slot timing options in the CombinationMethodProfileEditor, allowing for more flexible exercise configurations.
- Enhanced the ExerciseFormPage to manage combination slots more effectively, including new functions for reordering and merging exercises.
- Updated changelog to document the latest changes and improvements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 07:53:00 +02:00
parent 435da7f17a
commit ce63d46cf4
5 changed files with 466 additions and 125 deletions

View File

@ -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",

View File

@ -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])

View File

@ -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() {
))}
</select>
</div>
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '10px' }}>
<strong style={{ fontSize: '14px' }}>Stationen</strong>
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px', lineHeight: 1.45 }}>
Zuerst die Stationen anlegen dann erscheinen die Zeiten darunter auch <strong>pro Slot</strong> im Ablaufprofil.
{String(formData.method_archetype || '').trim() === 'station_parcour' ? (
<p
style={{
fontSize: '12px',
color: 'var(--text2)',
margin: '4px 0 10px',
lineHeight: 1.48,
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
>
<strong>Parcours / Bahnsystem:</strong> 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 <strong>Gesamtdurchläufe</strong> im Ablaufprofil strukturieren das
spätere Coaching.
</p>
{(formData.combination_slots || []).map((row, idx) => (
<div
key={`cs-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 72px) minmax(0, 1fr) minmax(0, 1.2fr) auto',
gap: '8px',
marginBottom: '8px',
alignItems: 'end',
}}
>
<div className="form-row">
<label className="form-label" style={{ fontSize: '12px' }}>
Idx.
</label>
<input
type="number"
min={0}
max={99}
className="form-input"
value={row.slot_index}
onChange={(e) => {
const next = [...(formData.combination_slots || [])]
const v = e.target.value
next[idx] = {
...row,
slot_index: v === '' ? '' : parseInt(v, 10),
}
updateFormField('combination_slots', next)
}}
/>
</div>
<div className="form-row">
<label className="form-label" style={{ fontSize: '12px' }}>
Titel
</label>
<input
type="text"
className="form-input"
value={row.title || ''}
onChange={(e) => {
const next = [...(formData.combination_slots || [])]
next[idx] = { ...row, title: e.target.value }
updateFormField('combination_slots', next)
}}
/>
</div>
<div className="form-row">
<label className="form-label" style={{ fontSize: '12px' }}>
Übungs-IDs
</label>
<input
type="text"
className="form-input"
value={row.idsText || ''}
onChange={(e) => {
const next = [...(formData.combination_slots || [])]
next[idx] = { ...row, idsText: e.target.value }
updateFormField('combination_slots', next)
}}
placeholder="z. B. 12, 34, 56"
/>
</div>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '6px 8px' }}
onClick={() => {
const prev = formData.combination_slots || []
const next = prev.filter((_, j) => j !== idx)
updateFormField(
'combination_slots',
next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
)
) : null}
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '14px' }}>Stationen und ÜbungsPool</strong>
<span style={{ fontSize: '11px', color: 'var(--text3)' }}>
Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
</span>
</div>
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 12px', lineHeight: 1.48 }}>
Jede Station: Titel (optional), am Ort wählbare <strong>Einzelübungen</strong> sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen).
</p>
{(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 (
<div
key={`combo-slot-${idx}`}
onDragOver={(e) => {
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.
</button>
</div>
))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-start', marginBottom: '12px' }}>
<button
type="button"
draggable
onDragStart={(e) => {
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' }}
>
<GripVertical size={18} strokeWidth={2} aria-hidden />
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Station nach oben"
disabled={idx === 0}
onClick={() => reorderCombinationSlots(idx, idx - 1)}
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Station nach unten"
disabled={idx === (formData.combination_slots || []).length - 1}
onClick={() => reorderCombinationSlots(idx, idx + 2)}
>
</button>
</div>
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '12px' }}>
Station {idx + 1} Titel
</label>
<input
type="text"
className="form-input"
value={row.title || ''}
placeholder={`z. B. Station ${idx + 1}`}
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-end' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => setComboStationPickerIx(idx)}
>
Einzelübungen wählen
</button>
<button
type="button"
className="btn framework-ctrl framework-ctrl--xs"
style={{ fontSize: '12px' }}
title="Station entfernen"
onClick={() => {
const prev = formData.combination_slots || []
const next = prev.filter((_, j) => j !== idx)
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
}}
>
Station entfernen
</button>
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<span className="form-label" style={{ fontSize: '11px', display: 'block', marginBottom: '6px' }}>
Gewählte Einzelübungen (Pool für diese Station)
</span>
{candIds.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>Noch keine Übung gewählt mindestens eine erforderlich zum Speichern.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{candIds.map((id) => (
<li
key={`${idx}-c-${id}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '999px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '14rem' }} title={`#${id}`}>
{(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
</span>
<button
type="button"
className="tu-icon-btn"
aria-label={`Übung ${id} entfernen`}
title="Entfernen"
onClick={() => removeCandidateFromSlot(idx, id)}
>
</button>
</li>
))}
</ul>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung an Station (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="optional"
value={row.load_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wdh. ohne Wechsel
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="oft 1"
value={row.consecutive_reps || ''}
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Pause zwischen Wdh. (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="optional"
value={row.intra_rep_rest_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Pause / nächste Station (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="optional"
value={row.transition_after_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
/>
</div>
</div>
</div>
)
})}
<div
onDragOver={(e) => {
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
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', marginTop: '4px' }}
onClick={() => {
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
</button>
</div>
<div className="form-row">
<label className="form-label">Ablaufprofil (Zeiten &amp; Runden)</label>
<label className="form-label">Ablaufprofil (Globale Zeiten &amp; Gesamtdurchläufe)</label>
<CombinationMethodProfileEditor
methodArchetype={formData.method_archetype || ''}
methodProfileJson={formData.method_profile_json || '{}'}
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)}
comboSlotsOutline={formData.combination_slots || []}
comboSlotsOutline={(formData.combination_slots || []).map((r, i) => ({
slot_index: i,
title: r.title || '',
}))}
omitPerSlotTiming
/>
</div>
</>
@ -1871,6 +2144,18 @@ function ExerciseFormPage() {
}
/>
)}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
{reportTarget && (
<ReportContentModal
targetType="media_asset"

View File

@ -507,28 +507,54 @@ export function buildExerciseApiPayload(formData, extras = {}) {
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const combination_slots = []
function parseTimingField(raw) {
if (raw === '' || raw == null || raw === undefined) return undefined
const n = parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const ix =
row.slot_index === '' || row.slot_index == null ? i : parseInt(row.slot_index, 10)
if (Number.isNaN(ix) || ix < 0 || ix > 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 {

View File

@ -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,
},