From ce63d46cf427e113f8d80a457f8a25a669f58147 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 07:53:00 +0200 Subject: [PATCH] feat(version): bump to 0.8.105 and enhance combination exercise features - 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 --- backend/version.py | 9 +- .../CombinationMethodProfileEditor.jsx | 3 +- frontend/src/pages/ExerciseFormPage.jsx | 497 ++++++++++++++---- frontend/src/utils/api.js | 52 +- .../src/utils/combinationMethodProfileUi.js | 30 +- 5 files changed, 466 insertions(+), 125 deletions(-) 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) => ( -
-
- - { - const next = [...(formData.combination_slots || [])] - const v = e.target.value - next[idx] = { - ...row, - slot_index: v === '' ? '' : parseInt(v, 10), - } - updateFormField('combination_slots', next) - }} - /> -
-
- - { - const next = [...(formData.combination_slots || [])] - next[idx] = { ...row, title: e.target.value } - updateFormField('combination_slots', next) - }} - /> -
-
- - { - const next = [...(formData.combination_slots || [])] - next[idx] = { ...row, idsText: e.target.value } - updateFormField('combination_slots', next) - }} - placeholder="z. B. 12, 34, 56" - /> -
- -
- ))} +
+ +
+ + +
+
+ + patchComboSlotRow(idx, { title: e.target.value })} + /> +
+
+ + +
+
+
+ + 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}`} + + +
  • + ))} +
+ )} +
+
+
+ + patchComboSlotRow(idx, { load_sec: e.target.value })} + /> +
+
+ + patchComboSlotRow(idx, { consecutive_reps: e.target.value })} + /> +
+
+ + patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })} + /> +
+
+ + patchComboSlotRow(idx, { transition_after_sec: e.target.value })} + /> +
+
+
+ ) + })} +
{ + 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 +
- + 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, },