feat(version): bump to 0.8.106 and enhance combination exercise features
All checks were successful
Deploy Development / deploy (push) Successful in 38s
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 55s

- Updated app version to 0.8.106, reflecting recent improvements in combination exercise handling.
- Introduced `advance_mode` for slot profiles, allowing for flexible timing options (timed, repetitions, manual) in the CombinationMethodProfileEditor.
- Enhanced the CombinationCoachSlots component to display timing summaries based on the selected advance mode.
- Updated ExerciseFormPage to manage combination slots with new validation and user feedback for exercise selection.
- Documented changes in the changelog for better tracking of feature enhancements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 08:19:46 +02:00
parent ce63d46cf4
commit 38d84ecdf6
7 changed files with 306 additions and 80 deletions

View File

@ -423,6 +423,8 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **CoachAssistenz**
**Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z.B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z.B. Zirkel: Relation Belastungszeit=Übergangszeit oder Erholungsanteil2/3der Belastung); der Archetyp **Freier Methodenblock** bildet den **MaximalPfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden.
**Fortschritt pro Slot (Stand 0.8.106):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). **`intra_rep_rest_sec`** und **`transition_after_sec`** bleiben unabhängig vom Modus nutzbar. **`load_sec`** wird nur im Modus `timed` persistiert.
### 6.4 Slot- und Pool-Logik
Slots können fest oder variabel sein.

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.105"
APP_VERSION = "0.8.106"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057"
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.25.0", # Kombi: slot_profiles_v1 + Schnellwahl Belastung/Erholung; keine NutzerJSONPflicht; Übungsform Stationen vor Ablaufprofil
"exercises": "2.26.0", # Kombi: advance_mode je Station (slot_profiles_v1: timed|rep|manual); Payload/Coach-Lesetext
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.106",
"date": "2026-05-12",
"changes": [
"Kombinationsübung: Stationssteuerung `advance_mode` in `slot_profiles_v1` (zeitlich / ZielWiederholungen / Coach ohne Arbeitsuhr); Übungsformular + PlanungsProfilEditor; APIPayload verwirft ArbeitSekunden außer bei Zeitmodus; Coach zeigt verkürzte Planzeile je Station.",
],
},
{
"version": "0.8.105",
"date": "2026-05-12",

View File

@ -10,9 +10,28 @@ import {
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
function summarizeSlotProfilesRow(r) {
if (!r) return null
const adv = r.advance_mode || 'timed'
const bits = []
if (adv === 'timed') {
bits.push('Zeit')
if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`)
} else if (adv === 'rep') {
bits.push('ZielWdh.')
if (r.consecutive_reps != null) bits.push(`${r.consecutive_reps}×`)
} else {
bits.push('Coach')
if (r.consecutive_reps != null) bits.push(`Richtwert ${r.consecutive_reps}×`)
}
if (r.intra_rep_rest_sec != null) bits.push(`Pause ${r.intra_rep_rest_sec}s`)
if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`)
return bits.join(' · ')
}
export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
@ -76,6 +95,23 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
const slotTimingByIx = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return new Map()
const rows = readSlotProfilesV1(methodProfile)
const m = new Map()
for (const r of rows) {
m.set(Number(r.slot_index), r)
}
return m
}, [methodProfile])
const methodProfileKvSansSlots = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
return Object.entries(methodProfile)
.filter(([k]) => k !== 'slot_profiles_v1')
.sort(([a], [b]) => a.localeCompare(b, 'de'))
}, [methodProfile])
return (
<section
className="card"
@ -124,10 +160,13 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', marginBottom: '6px' }}>
Geplantes Ablaufprofil (Katalog)
</div>
{methodProfileKvSansSlots.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.42 }}>
Nur stationsbezogene Daten (Zeiten/ZählSteuerung) siehe je Station unter der Überschrift Plan:.
</p>
) : (
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
{Object.entries(methodProfile)
.sort(([a], [b]) => a.localeCompare(b, 'de'))
.map(([k, val]) => (
{methodProfileKvSansSlots.map(([k, val]) => (
<div key={k} style={{ marginBottom: '4px', display: 'grid', gridTemplateColumns: 'minmax(72px,1fr) minmax(0,2fr)', gap: '6px 10px' }}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
<dd style={{ margin: 0, color: 'var(--text1)' }}>
@ -144,6 +183,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
</div>
))}
</dl>
)}
</div>
) : null}
@ -162,11 +202,19 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
`Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
const timingSummary = summarizeSlotProfilesRow(slotTimingByIx.get(ix))
return (
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: candIds.length > 1 ? '6px' : '8px' }}>
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: timingSummary ? '4px' : candIds.length > 1 ? '6px' : '8px' }}>
{slotTitle}
</div>
{timingSummary ? (
<p style={{ margin: '0 0 8px', fontSize: '0.78rem', color: 'var(--text2)', lineHeight: 1.42 }}>
Plan: <span style={{ color: 'var(--text1)', fontWeight: 600 }}>{timingSummary}</span>
</p>
) : null}
{candIds.length === 0 ? (
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
) : (

View File

@ -8,6 +8,8 @@ import {
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
patchSlotAdvanceMode,
normalizeAdvanceMode,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
@ -105,6 +107,15 @@ export default function CombinationMethodProfileEditor({
setPresetHint(null)
}
const onSlotAdvanceChange = (slotIx, rawMode) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotAdvanceMode(d, slotIx, rawMode)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
@ -265,12 +276,12 @@ export default function CombinationMethodProfileEditor({
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
Pro Station / Slot (Zeiten in Sekunden)
Pro Station / Slot Steuerung &amp; Sekunden
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
zur nächsten Station. Felder können leer bleiben z.B. nutzt der Zirkel oben erst die globalen Arbeit/
RotationsSekunden.
<strong>Steuerung:</strong> zeitlich (ArbeitsCountdown), Zielzahl Wiederholungen oder Coachgeführt ohne
Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar.&nbsp;Felder können leer bleiben z.&nbsp;B. nutzt der
Zirkel erst die globalen ArbeitSekunden.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{outlineSorted.map((slot) => {
@ -280,6 +291,9 @@ export default function CombinationMethodProfileEditor({
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'ZielWdh.' : 'Richtwert'
return (
<div
key={`slot-timing-${si}`}
@ -294,14 +308,30 @@ export default function CombinationMethodProfileEditor({
Station {si}
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
</div>
<div className="form-row" style={{ marginBottom: '10px', maxWidth: '22rem' }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Steuerung
</label>
<select
className="form-input"
style={{ fontSize: '0.8125rem' }}
value={slotAdv}
onChange={(e) => onSlotAdvanceChange(si, e.target.value)}
>
<option value="timed">Zeit (Arbeit in Sekunden)</option>
<option value="rep">Wiederholungen (Ziel)</option>
<option value="manual">Coach (ohne Arbeitsuhr)</option>
</select>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
{slotAdv === 'timed' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung (s)
@ -315,15 +345,16 @@ export default function CombinationMethodProfileEditor({
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wdh. ohne Wechsel
{serieLabel}
</label>
<input
type="number"
min={1}
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="oft 1"
placeholder={slotAdv === 'manual' ? 'optional' : 'oft 1'}
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
/>

View File

@ -17,7 +17,7 @@ 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 { readSlotProfilesV1, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
@ -38,11 +38,23 @@ const VARIANT_DIFFICULTY = [
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
const MAX_COMBO_CANDIDATES_PER_STATION = 3
const comboTinyNumberInputSx = {
width: '3.5rem',
maxWidth: '100%',
padding: '4px 6px',
fontSize: '0.8125rem',
textAlign: 'center',
}
function emptyComboSlotRow() {
return {
title: '',
candidate_exercise_ids: [],
exercise_title_by_id: {},
advance_mode: 'timed',
load_sec: '',
consecutive_reps: '',
intra_rep_rest_sec: '',
@ -75,6 +87,7 @@ function comboSlotsFromDetail(exercise) {
title: s.title != null ? String(s.title) : '',
candidate_exercise_ids: cands,
exercise_title_by_id: {},
advance_mode: normalizeAdvanceMode(st.advance_mode),
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) : '',
@ -651,22 +664,38 @@ function ExerciseFormPage() {
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
if (!Array.isArray(pickedList) || !pickedList.length) return
const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
: []
const ordered = [...existingIds]
pickedList.forEach((ex) => {
if (ex?.id == null) return
const id = Number(ex.id)
if (!Number.isFinite(id)) return
if (!ordered.includes(id)) ordered.push(id)
})
let nextIds = ordered
if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
window.alert(
`Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner WechselPool. Überschüssige Auswahl wurde abgeschnitten.`,
)
nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
}
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 }
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
return { ...prev, combination_slots: rows }
})
}
@ -1154,16 +1183,22 @@ function ExerciseFormPage() {
) : 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>
<strong style={{ fontSize: '14px' }}>Stationen</strong>
<span style={{ fontSize: '11px', color: 'var(--text3)' }}>
Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station
</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).
Pro Station oft <strong>eine</strong> feste Übung; höchstens <strong>drei</strong> als kleiner AuswahlPool.
Unter <strong>Steuerung</strong> wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).
</p>
{(formData.combination_slots || []).map((row, idx) => {
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'ZielWdh.' : 'Richtwert'
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '' : '1'
const lbl =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
? row.exercise_title_by_id
@ -1234,13 +1269,13 @@ function ExerciseFormPage() {
</div>
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '12px' }}>
Station {idx + 1} Titel
Name (St.&nbsp;{idx + 1})
</label>
<input
type="text"
className="form-input"
value={row.title || ''}
placeholder={`z. B. Station ${idx + 1}`}
placeholder="z.&nbsp;B. Liegestütz"
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
/>
</div>
@ -1248,31 +1283,39 @@ function ExerciseFormPage() {
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={comboPoolFull}
title={
comboPoolFull
? `Max. ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — eine entfernen, um weitere zu wählen.`
: 'Einzelübung zur Station hinzufügen'
}
onClick={() => setComboStationPickerIx(idx)}
>
Einzelübungen wählen
+ Übung
</button>
<button
type="button"
className="btn framework-ctrl framework-ctrl--xs"
style={{ fontSize: '12px' }}
title="Station entfernen"
title="Diese Station entfernen"
onClick={() => {
const prev = formData.combination_slots || []
const next = prev.filter((_, j) => j !== idx)
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
}}
>
Station entfernen
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)
Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_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>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Mindestens eine Übung mit + Übung wählen.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{candIds.map((id) => (
@ -1306,62 +1349,95 @@ function ExerciseFormPage() {
</ul>
)}
</div>
<div className="form-row" style={{ marginBottom: '8px', maxWidth: '22rem' }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Steuerung
</label>
<select
className="form-input"
style={{ fontSize: '0.8125rem' }}
value={slotAdv}
onChange={(e) => {
const m = normalizeAdvanceMode(e.target.value)
const patch = { advance_mode: m }
if (m !== 'timed') patch.load_sec = ''
patchComboSlotRow(idx, patch)
}}
>
<option value="timed">Zeit (Arbeit in Sekunden)</option>
<option value="rep">Wiederholungen (Ziel)</option>
<option value="manual">Coach (Weiter nach Freigabe)</option>
</select>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.42 }}>
{slotAdv === 'timed'
? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
: slotAdv === 'rep'
? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen; Pause/Wechsel können weiter automatisch unterstützt werden.'
: 'Ohne feste Arbeitsuhr auf dieser Station — Fortschritt im Coach später per Tippschritt; Pause/Wechsel optional weiter mit Sekunden.'}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '10px',
gridTemplateColumns: 'repeat(auto-fill, minmax(5.25rem, 1fr))',
gap: '8px 10px',
alignItems: 'end',
}}
>
{slotAdv === 'timed' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung an Station (s)
<label className="form-label" style={{ fontSize: '10px' }}>
Arbeit (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="optional"
style={comboTinyNumberInputSx}
placeholder=""
value={row.load_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wdh. ohne Wechsel
<label className="form-label" style={{ fontSize: '10px' }}>
{serieLabel}
</label>
<input
type="number"
min={1}
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="oft 1"
style={comboTinyNumberInputSx}
placeholder={seriePlaceholder}
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 className="form-label" style={{ fontSize: '10px' }}>
Pause (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="optional"
style={comboTinyNumberInputSx}
placeholder=""
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 className="form-label" style={{ fontSize: '10px' }}>
Wechsel (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="optional"
style={comboTinyNumberInputSx}
placeholder=""
value={row.transition_after_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
/>
@ -1403,11 +1479,11 @@ function ExerciseFormPage() {
style={{ fontSize: '12px', marginTop: '4px' }}
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
>
+ Station hinzufügen
+ Station
</button>
</div>
<div className="form-row">
<label className="form-label">Ablaufprofil (Globale Zeiten &amp; Gesamtdurchläufe)</label>
<label className="form-label">Ablaufprofil (Runden &amp; global)</label>
<CombinationMethodProfileEditor
methodArchetype={formData.method_archetype || ''}
methodProfileJson={formData.method_profile_json || '{}'}

View File

@ -5,6 +5,7 @@
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeAdvanceMode } from './combinationMethodProfileUi'
const API_URL = import.meta.env.VITE_API_URL || ''
@ -541,11 +542,13 @@ export function buildExerciseApiPayload(formData, extras = {}) {
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const o = { slot_index: i }
const advanceMode = normalizeAdvanceMode(row.advance_mode)
if (advanceMode !== 'timed') o.advance_mode = advanceMode
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 (advanceMode === 'timed' && 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)

View File

@ -5,6 +5,16 @@
const INT_MAX = 86400
/** Pro Station: zeitlich (Standard), mengenorientiert oder coachgeführt (ohne Arbeits-Countdown). */
export const SLOT_ADVANCE_MODES = Object.freeze(['timed', 'rep', 'manual'])
export function normalizeAdvanceMode(v) {
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
if (s === 'rep' || s === 'reps' || s === 'count') return 'rep'
if (s === 'manual' || s === 'coach' || s === 'coach_led') return 'manual'
return 'timed'
}
function parseProfileJson(raw) {
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
try {
@ -209,13 +219,16 @@ export function readSlotProfilesV1(profileObj) {
return raw.map((row) => {
if (!row || typeof row !== 'object') return null
const si = Number(row.slot_index)
return {
const mode = normalizeAdvanceMode(row.advance_mode)
const out = {
slot_index: Number.isFinite(si) ? si : 0,
advance_mode: mode,
load_sec: normalizeOptionalNonNegInt(row.load_sec),
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
}
return out
}).filter(Boolean)
}
@ -240,6 +253,55 @@ const SLOT_TIMING_FIELDS = /** @type {const} */ ([
'transition_after_sec',
])
function slotProfileRowShouldKeep(nextRow) {
if (!nextRow || typeof nextRow !== 'object') return false
const mode = normalizeAdvanceMode(nextRow.advance_mode)
if (mode !== 'timed') return true
return SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
}
function writeSlotProfilesV1Arr(profileDraft, arr) {
const sorted = [...arr].sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
if (sorted.length === 0) delete profileDraft.slot_profiles_v1
else profileDraft.slot_profiles_v1 = sorted
}
/** Steuert Ende der Arbeitsphase: Zeit, Wiederholungsziel oder nur manuell weiter. */
export function patchSlotAdvanceMode(profileDraft, slotIndex, modeRaw) {
const ix =
typeof slotIndex === 'number' && Number.isFinite(slotIndex)
? slotIndex
: parseInt(String(slotIndex), 10)
if (!Number.isFinite(ix)) return
const mode = normalizeAdvanceMode(modeRaw)
let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
const found = arr.findIndex((r) => r && typeof r === 'object' && Number(r.slot_index) === ix)
const nextRow = {}
if (found >= 0 && arr[found] && typeof arr[found] === 'object') {
Object.assign(nextRow, arr[found])
}
nextRow.slot_index = ix
if (mode === 'timed') delete nextRow.advance_mode
else {
nextRow.advance_mode = mode
delete nextRow.load_sec
}
let nextArr
if (!slotProfileRowShouldKeep(nextRow)) {
nextArr = found >= 0 ? arr.filter((_, i) => i !== found) : arr
} else if (found >= 0) {
nextArr = [...arr]
nextArr[found] = nextRow
} else {
nextArr = [...arr, nextRow]
}
writeSlotProfilesV1Arr(profileDraft, nextArr)
}
/** '', null = Feld entfernen; sonst gültige Zahl setzen */
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
if (!SLOT_TIMING_FIELDS.includes(field)) return
@ -270,22 +332,19 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
else nextRow[field] = n
}
const hasTiming = SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
const keep = slotProfileRowShouldKeep(nextRow)
if (found >= 0) {
if (!hasTiming) {
if (!keep) {
arr = arr.filter((_, i) => i !== found)
} else {
arr[found] = nextRow
}
} else if (hasTiming) {
} else if (keep) {
arr.push(nextRow)
}
arr.sort((a, b) => Number(a.slot_index) - Number(b.slot_index))
if (arr.length === 0) delete profileDraft.slot_profiles_v1
else profileDraft.slot_profiles_v1 = arr
writeSlotProfilesV1Arr(profileDraft, arr)
}
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */