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
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:
parent
435da7f17a
commit
ce63d46cf4
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 Übungs‑Pool</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 & Runden)</label>
|
||||
<label className="form-label">Ablaufprofil (Globale Zeiten & 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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user