Module und Kombinationsübnungen in Version 0.8 #31

Merged
Lars merged 27 commits from develop into main 2026-05-13 16:16:49 +02:00
5 changed files with 466 additions and 125 deletions
Showing only changes of commit ce63d46cf4 - Show all commits

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.104" APP_VERSION = "0.8.105"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057" DB_SCHEMA_VERSION = "20260512057"
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.104",
"date": "2026-05-12", "date": "2026-05-12",

View File

@ -45,6 +45,7 @@ export default function CombinationMethodProfileEditor({
plannerMode = false, plannerMode = false,
allowExpertJson = false, allowExpertJson = false,
comboSlotsOutline = null, comboSlotsOutline = null,
omitPerSlotTiming = false,
}) { }) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : '' const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch] const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
@ -62,7 +63,7 @@ export default function CombinationMethodProfileEditor({
}, [comboSlotsOutline]) }, [comboSlotsOutline])
const showSlotTiming = 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]) const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])

View File

@ -8,6 +8,7 @@ import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import MediaPreviewModal from '../components/MediaPreviewModal' import MediaPreviewModal from '../components/MediaPreviewModal'
import ReportContentModal from '../components/ReportContentModal' import ReportContentModal from '../components/ReportContentModal'
import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor' import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
import ExercisePickerModal from '../components/ExercisePickerModal'
import { import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME, SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload, buildExerciseMediaDragPayload,
@ -16,6 +17,8 @@ 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' import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [ const INTENSITY_OPTIONS = [
{ value: '', label: '—' }, { value: '', label: '—' },
@ -32,16 +35,52 @@ const VARIANT_DIFFICULTY = [
{ value: 'adapted', label: 'Angepasst' }, { 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) { function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots 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) { if (!Array.isArray(raw) || raw.length === 0) {
return [{ slot_index: 0, title: '', idsText: '' }] return [emptyComboSlotRow()]
} }
return raw.map((s, i) => ({ const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
slot_index: s.slot_index != null ? Number(s.slot_index) : i, return sorted.map((s) => {
title: s.title != null ? String(s.title) : '', const si = Number(s.slot_index)
idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '', 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() { function emptyVariantDraft() {
@ -266,7 +305,7 @@ function emptyForm() {
exercise_kind: 'simple', exercise_kind: 'simple',
method_archetype: '', method_archetype: '',
method_profile_json: '{}', method_profile_json: '{}',
combination_slots: [{ slot_index: 0, title: '', idsText: '' }], combination_slots: [emptyComboSlotRow()],
} }
} }
@ -569,6 +608,69 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value })) 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 addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) { if (!id) {
@ -1004,7 +1106,7 @@ function ExerciseFormPage() {
? { ? {
method_archetype: '', method_archetype: '',
method_profile_json: '{}', method_profile_json: '{}',
combination_slots: [{ slot_index: 0, title: '', idsText: '' }], combination_slots: [emptyComboSlotRow()],
} }
: {}), : {}),
})) }))
@ -1031,119 +1133,290 @@ function ExerciseFormPage() {
))} ))}
</select> </select>
</div> </div>
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '10px' }}> {String(formData.method_archetype || '').trim() === 'station_parcour' ? (
<strong style={{ fontSize: '14px' }}>Stationen</strong> <p
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px', lineHeight: 1.45 }}> style={{
Zuerst die Stationen anlegen dann erscheinen die Zeiten darunter auch <strong>pro Slot</strong> im Ablaufprofil. 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> </p>
{(formData.combination_slots || []).map((row, idx) => ( ) : null}
<div <div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
key={`cs-${idx}`} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
style={{ <strong style={{ fontSize: '14px' }}>Stationen und ÜbungsPool</strong>
display: 'grid', <span style={{ fontSize: '11px', color: 'var(--text3)' }}>
gridTemplateColumns: 'minmax(0, 72px) minmax(0, 1fr) minmax(0, 1.2fr) auto', Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
gap: '8px', </span>
marginBottom: '8px', </div>
alignItems: 'end', <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>
<div className="form-row"> {(formData.combination_slots || []).map((row, idx) => {
<label className="form-label" style={{ fontSize: '12px' }}> const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
Idx. const lbl =
</label> row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
<input ? row.exercise_title_by_id
type="number" : {}
min={0} const isDropHere = comboDropTargetIx === idx
max={99} return (
className="form-input" <div
value={row.slot_index} key={`combo-slot-${idx}`}
onChange={(e) => { onDragOver={(e) => {
const next = [...(formData.combination_slots || [])] if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
const v = e.target.value e.preventDefault()
next[idx] = { e.dataTransfer.dropEffect = 'move'
...row, setComboDropTargetIx(idx)
slot_index: v === '' ? '' : parseInt(v, 10), }}
} onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
updateFormField('combination_slots', next) onDrop={(e) => {
}} const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
/> const fromI = parseInt(rawFrom, 10)
</div> e.preventDefault()
<div className="form-row"> setComboDropTargetIx(null)
<label className="form-label" style={{ fontSize: '12px' }}> if (!Number.isFinite(fromI)) return
Titel reorderCombinationSlots(fromI, idx)
</label> }}
<input style={{
type="text" marginBottom: '12px',
className="form-input" padding: '12px 14px',
value={row.title || ''} borderRadius: '12px',
onChange={(e) => { border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
const next = [...(formData.combination_slots || [])] background: 'var(--surface)',
next[idx] = { ...row, title: e.target.value } boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
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: '' }],
)
}} }}
> >
Entf. <div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-start', marginBottom: '12px' }}>
</button> <button
</div> 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 <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ fontSize: '12px', marginTop: '4px' }} style={{ fontSize: '12px', marginTop: '4px' }}
onClick={() => { onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
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: '' },
])
}}
> >
+ Station + Station hinzufügen
</button> </button>
</div> </div>
<div className="form-row"> <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 <CombinationMethodProfileEditor
methodArchetype={formData.method_archetype || ''} methodArchetype={formData.method_archetype || ''}
methodProfileJson={formData.method_profile_json || '{}'} methodProfileJson={formData.method_profile_json || '{}'}
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)} 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> </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 && ( {reportTarget && (
<ReportContentModal <ReportContentModal
targetType="media_asset" 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 slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const 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) { for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {} const row = slotRows[i] || {}
const ix = let ids = Array.isArray(row.candidate_exercise_ids)
row.slot_index === '' || row.slot_index == null ? i : parseInt(row.slot_index, 10) ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
if (Number.isNaN(ix) || ix < 0 || ix > 99) { : []
throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
/** 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({ combination_slots.push({
slot_index: ix, slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null, 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 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.method_profile = mpObj
payload.combination_slots = combination_slots payload.combination_slots = combination_slots
} else { } else {

View File

@ -21,6 +21,13 @@ function parseProfileJson(raw) {
/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */ /** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
sequence_linear: [ sequence_linear: [
{
key: 'rounds',
kind: 'int',
label: 'Anzahl Gesamtdurchläufe (komplette Sequenz, alle Stationen nacheinander)',
min: 1,
max: 999,
},
{ {
key: 'hint_step_duration_sec', key: 'hint_step_duration_sec',
kind: 'int', kind: 'int',
@ -37,6 +44,14 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
}, },
], ],
circuit_rotate_time: [ 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', key: 'work_seconds',
kind: 'int', kind: 'int',
@ -58,15 +73,15 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
min: 0, min: 0,
max: INT_MAX, max: INT_MAX,
}, },
],
circuit_all_parallel: [
{ {
key: 'rounds', key: 'rounds',
kind: 'int', 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, min: 1,
max: 999, max: 999,
}, },
],
circuit_all_parallel: [
{ {
key: 'explain_before_seconds', key: 'explain_before_seconds',
kind: 'int', kind: 'int',
@ -81,6 +96,13 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
}, },
], ],
station_parcour: [ 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', key: 'allow_free_visit_order',
kind: 'bool', kind: 'bool',
@ -121,7 +143,7 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
{ {
key: 'interval_rounds', key: 'interval_rounds',
kind: 'int', kind: 'int',
label: 'Anzahl Wiederholungen / Runden der Domäne (optional)', label: 'Anzahl Wiederholungen der Intervalldomäne (komplette Zyklen Arbeit/Pause)',
min: 1, min: 1,
max: 999, max: 999,
}, },