feat(version): bump to 0.8.109 and enhance combination exercise features
All checks were successful
Deploy Development / deploy (push) Successful in 42s
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.109, reflecting recent improvements in combination exercise handling.
- Introduced `rep_series_count` for slot profiles, allowing for multiple series in `rep` and `manual` modes, enhancing flexibility in exercise configurations.
- Updated the CombinationMethodProfileEditor and CombinationCoachSlots components to support and display the new series count feature.
- Enhanced ExerciseFormPage to manage series count and intra-series pauses effectively, improving user experience.
- 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:58:41 +02:00
parent 38d84ecdf6
commit cf9932990e
9 changed files with 251 additions and 48 deletions

View File

@ -423,7 +423,7 @@ 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.
**Fortschritt pro Slot (Stand 0.8.109):** 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`**). Optional **`rep_series_count`**: Standard **1** (wird im Formular/API explizit geführt); Ausnahmen nur, wenn der **MethodenArchetyp** in `ARCHETYPE_DEFAULT_REP_SERIES_COUNT` eine andere Vorgabe definiert oder der Nutzer eine andere Zahl setzt. ≥2 ermöglicht Pause **zwischen Serien** (`intra_rep_rest_sec`). Bei nur **einer** Serie: kein **`intra_rep_rest_sec`** in UI und Payload; **`transition_after_sec`** = Wechsel zur nächsten Station.
### 6.4 Slot- und Pool-Logik

View File

@ -53,8 +53,9 @@ ObjektShape (Sekunden, ganze Zahlen ≥ 0):
| Feld | Bedeutung |
|------|------------|
| `load_sec` | Belastungsdauer „an der Station“. |
| `consecutive_reps` | Übungen hintereinander ohne Wechsel zu **neuem** Stationsinhalt („oft 1“). |
| `intra_rep_rest_sec` | Pause zwischen diesen FolgeWiederholungen. |
| `consecutive_reps` | Wiederholungen pro „Serie“ bzw. ohne Wechsel zu **neuem** Stationsinhalt (oft 1). |
| `rep_series_count` | Anzahl Serien à `consecutive_reps` bei rep/manual; Standard **1**, ArchetypVorgabe möglich (**`ARCHETYPE_DEFAULT_REP_SERIES_COUNT`**). Persistiert für rep/manual ab 1. |
| `intra_rep_rest_sec` | Pause zwischen den FolgeWiederholungen bzw. **zwischen Serien** (nur sinnvoll, wenn `rep_series_count` ≥ 2 im Modus `rep`/`manual`; sonst Wechselzeit `transition_after_sec` nutzen). |
| `transition_after_sec` | Pause / Wechsel **zur nächsten** Station oder zum nächsten logischen Block. |
**Hinweis:** Bestehende Archetyp„flachen“ Schlüssel (`work_seconds`, `transition_seconds`, …) werden schrittweise **nicht zerstört**, sondern Slots ergänzen; Konvergenz (eine Darstellung zu v1) kann Phase 4 sein.

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.106"
APP_VERSION = "0.8.109"
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.26.0", # Kombi: advance_mode je Station (slot_profiles_v1: timed|rep|manual); Payload/Coach-Lesetext
"exercises": "2.27.2", # Kombi: SerienStandard 1 + ArchetypMap ARCHETYPE_DEFAULT_REP_SERIES_COUNT; Payload rep_series_count ab 1
"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,27 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.109",
"date": "2026-05-12",
"changes": [
"Kombination: „Serien“ standardmäßig 1 (Formular/API); Archetyp kann via `ARCHETYPE_DEFAULT_REP_SERIES_COUNT` andere Vorgaben setzen; ProfilEditor zeigt Fallback.",
],
},
{
"version": "0.8.108",
"date": "2026-05-12",
"changes": [
"Kombination rep/manual: Feld „Pause zw. Serien“ nur ab 2 Serien sichtbar und speicherbar; Hinweis unterscheidet Wechsel zur nächsten Station; API verwirft intra_rep_rest_sec bei nur einer Serie.",
],
},
{
"version": "0.8.107",
"date": "2026-05-12",
"changes": [
"Kombination Wiederholungsziel: `rep_series_count` in `slot_profiles_v1` (mehrere Serien à ZielWdh.); Formular und ProfilEditorFelder; Pause als „zwischen Serien“ beschriftet; CoachZusammenfassung angepasst.",
],
},
{
"version": "0.8.106",
"date": "2026-05-12",

View File

@ -21,17 +21,39 @@ function summarizeSlotProfilesRow(r) {
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}×`)
const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`)
else bits.push(`${r.consecutive_reps}×`)
}
} else {
bits.push('Coach')
if (r.consecutive_reps != null) bits.push(`Richtwert ${r.consecutive_reps}×`)
const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1
if (r.consecutive_reps != null) {
if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`)
else bits.push(`Richtwert ${r.consecutive_reps}×`)
} else if (r.rep_series_count != null && r.rep_series_count >= 2) {
bits.push(`${r.rep_series_count} Serien`)
}
}
if (r.intra_rep_rest_sec != null) {
if (adv === 'timed') bits.push(`Pause ${r.intra_rep_rest_sec}s`)
else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2)
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
else if (
adv === 'manual' &&
r.rep_series_count != null &&
r.rep_series_count >= 2
) {
bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`)
}
}
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()

View File

@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'
import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
@ -10,6 +10,7 @@ import {
patchSlotTimingField,
patchSlotAdvanceMode,
normalizeAdvanceMode,
parseComboRepSeriesCountUi,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
@ -116,6 +117,20 @@ export default function CombinationMethodProfileEditor({
setPresetHint(null)
}
const onSlotRepSeriesCount = (slotIx, rawStr) => {
const trimmed = String(rawStr ?? '').trim()
const effective = trimmed === '' ? '1' : trimmed
const pn = parseInt(effective, 10)
const clearIntra = !Number.isFinite(pn) || pn < 2
const patched = patchMethodProfile(methodProfileJson || '{}', (d) => {
patchSlotTimingField(d, slotIx, 'rep_series_count', effective)
if (clearIntra) patchSlotTimingField(d, slotIx, 'intra_rep_rest_sec', '')
})
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
@ -293,7 +308,11 @@ export default function CombinationMethodProfileEditor({
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'
slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const showInterSeriesPause = showMultiSeries && serienUi >= 2
const intraLabel = slotAdv === 'timed' ? 'Pause zwischen Wdh.' : 'Pause zw. Serien'
return (
<div
key={`slot-timing-${si}`}
@ -359,22 +378,47 @@ export default function CombinationMethodProfileEditor({
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
/>
</div>
{showMultiSeries ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label
className="form-label"
style={{ fontSize: '11px' }}
title="Wie oft die Wdh.-Zahl pro Serie hintereinander (mit Pause zwischen den Serien)?"
>
Serien
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="1"
value={
row.rep_series_count != null && String(row.rep_series_count) !== ''
? String(row.rep_series_count)
: String(defaultRepSeriesCountForArchetype(methodArchetype))
}
onChange={(e) => onSlotRepSeriesCount(si, e.target.value)}
/>
</div>
) : null}
{slotAdv === 'timed' || showInterSeriesPause ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
{intraLabel} (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.intra_rep_rest_sec != null ? String(row.intra_rep_rest_sec) : ''}
onChange={(e) => onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
/>
</div>
) : null}
<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=""
value={row.intra_rep_rest_sec != null ? String(row.intra_rep_rest_sec) : ''}
onChange={(e) => onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Pause / Wechsel (s)
Wechsel (s)
</label>
<input
type="number"
@ -386,6 +430,12 @@ export default function CombinationMethodProfileEditor({
/>
</div>
</div>
{showMultiSeries && serienUi < 2 ? (
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.38 }}>
<strong>Wechsel (s)</strong> zur <strong>nächsten Station</strong>. Pause zw. Serien nur ab&nbsp;2
Serien.
</p>
) : null}
</div>
)
})}

View File

@ -59,3 +59,17 @@ export function sortCombinationSlotsForDisplay(slotsRaw) {
return String(a.title || '').localeCompare(String(b.title || ''), 'de')
})
}
/**
* Vorgabe Serien pro Station bei Steuerung rep/manual, wenn kein Wert in `slot_profiles_v1` steht.
* Nur Archetypen eintragen, die fachlich 1 verlangen; sonst Standard 1.
*/
export const ARCHETYPE_DEFAULT_REP_SERIES_COUNT = Object.freeze({})
export function defaultRepSeriesCountForArchetype(archetypeId) {
const key = archetypeId != null ? String(archetypeId).trim() : ''
const raw = key ? ARCHETYPE_DEFAULT_REP_SERIES_COUNT[key] : undefined
const n = typeof raw === 'number' ? raw : raw != null ? parseInt(String(raw), 10) : NaN
if (!Number.isFinite(n) || n < 1) return 1
return Math.round(n)
}

View File

@ -16,8 +16,8 @@ import {
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, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi'
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
const INTENSITY_OPTIONS = [
@ -57,6 +57,7 @@ function emptyComboSlotRow() {
advance_mode: 'timed',
load_sec: '',
consecutive_reps: '',
rep_series_count: '1',
intra_rep_rest_sec: '',
transition_after_sec: '',
}
@ -64,6 +65,8 @@ function emptyComboSlotRow() {
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : ''
const serienFallback = defaultRepSeriesCountForArchetype(arch)
const mp =
exercise?.method_profile &&
typeof exercise.method_profile === 'object' &&
@ -83,13 +86,19 @@ function comboSlotsFromDetail(exercise) {
const cands = Array.isArray(s.candidate_exercise_ids)
? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
const mode = normalizeAdvanceMode(st.advance_mode)
let repSer = ''
if (st.rep_series_count != null) repSer = String(st.rep_series_count)
else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback)
else repSer = '1'
return {
title: s.title != null ? String(s.title) : '',
candidate_exercise_ids: cands,
exercise_title_by_id: {},
advance_mode: normalizeAdvanceMode(st.advance_mode),
advance_mode: mode,
load_sec: st.load_sec != null ? String(st.load_sec) : '',
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
rep_series_count: repSer,
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) : '',
}
@ -1152,7 +1161,26 @@ function ExerciseFormPage() {
<select
className="form-input"
value={formData.method_archetype || ''}
onChange={(e) => updateFormField('method_archetype', e.target.value)}
onChange={(e) => {
const arch = (e.target.value || '').trim()
const forced = ARCHETYPE_DEFAULT_REP_SERIES_COUNT[arch]
setFormDirty(true)
setFormData((prev) => {
const slots = prev.combination_slots || []
const nextSlots =
forced !== undefined && forced !== null
? slots.map((row) =>
normalizeAdvanceMode(row.advance_mode) !== 'timed'
? {
...row,
rep_series_count: String(Math.max(1, Math.round(Number(forced)))),
}
: row,
)
: slots
return { ...prev, method_archetype: arch, combination_slots: nextSlots }
})
}}
>
<option value=""> noch nicht festgelegt </option>
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
@ -1197,8 +1225,12 @@ function ExerciseFormPage() {
const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'ZielWdh.' : 'Richtwert'
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '' : '1'
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
const serienCountUi = parseComboRepSeriesCountUi(row.rep_series_count)
const showInterSeriesPause = showMultiSeries && serienCountUi >= 2
const intraLabel = slotAdv === 'timed' ? 'Pause (s)' : 'Pause zw. Serien'
const lbl =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
? row.exercise_title_by_id
@ -1361,6 +1393,14 @@ function ExerciseFormPage() {
const m = normalizeAdvanceMode(e.target.value)
const patch = { advance_mode: m }
if (m !== 'timed') patch.load_sec = ''
if (m === 'rep' || m === 'manual') {
const curSer = String(row.rep_series_count ?? '').trim()
if (!curSer) {
patch.rep_series_count = String(
defaultRepSeriesCountForArchetype(formData.method_archetype || ''),
)
}
}
patchComboSlotRow(idx, patch)
}}
>
@ -1373,8 +1413,8 @@ function ExerciseFormPage() {
{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.'}
? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen. Ab zwei Serien: Pause zwischen diesen Serien; sonst nur Wechsel zur nächsten Station.'
: 'Coach: keine feste Arbeitsuhr — Fortschritt später per Tipp. Ab 2 Serien: Pause zwischen Serien; sonst nur Wechsel zur nächsten Station zeitlich planen.'}
</p>
<div
style={{
@ -1414,20 +1454,45 @@ function ExerciseFormPage() {
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
Pause (s)
</label>
<input
type="number"
min={0}
className="form-input"
style={comboTinyNumberInputSx}
placeholder=""
value={row.intra_rep_rest_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
/>
</div>
{showMultiSeries ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }} title="Wie oft die angegebene Wdh.-Zahl hintereinander (mit Pause zw. Serien)?">
Serien
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
style={comboTinyNumberInputSx}
placeholder="1"
value={row.rep_series_count || ''}
onChange={(e) => {
let rawSer = e.target.value.trim()
if (rawSer === '') rawSer = '1'
const pn = parseInt(String(rawSer).trim(), 10)
const patch = { rep_series_count: rawSer }
if (!Number.isFinite(pn) || pn < 2) patch.intra_rep_rest_sec = ''
patchComboSlotRow(idx, patch)
}}
/>
</div>
) : null}
{slotAdv === 'timed' || showInterSeriesPause ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
{intraLabel}
</label>
<input
type="number"
min={0}
className="form-input"
style={comboTinyNumberInputSx}
placeholder=""
value={row.intra_rep_rest_sec || ''}
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '10px' }}>
Wechsel (s)
@ -1443,6 +1508,12 @@ function ExerciseFormPage() {
/>
</div>
</div>
{showMultiSeries && serienCountUi < 2 ? (
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.38 }}>
<strong>Wechsel (s)</strong> = Pause bis zur <strong>nächsten Station</strong>. Feld Pause zw.
Serien erscheint erst ab&nbsp;2 Serien (sonst keine Pause zwischen zwei Blöcken nötig).
</p>
) : null}
</div>
)
})}

View File

@ -5,7 +5,7 @@
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeAdvanceMode } from './combinationMethodProfileUi'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
const API_URL = import.meta.env.VITE_API_URL || ''
@ -546,11 +546,24 @@ export function buildExerciseApiPayload(formData, extras = {}) {
if (advanceMode !== 'timed') o.advance_mode = advanceMode
const load = parseTimingField(row.load_sec)
const crs = parseTimingField(row.consecutive_reps)
const rsc = parseTimingField(row.rep_series_count)
const intra = parseTimingField(row.intra_rep_rest_sec)
const tran = parseTimingField(row.transition_after_sec)
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const allowInterSeriesPause =
advanceMode === 'timed' ||
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
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 (
rsc !== undefined &&
rsc >= 1 &&
(advanceMode === 'rep' || advanceMode === 'manual')
) {
o.rep_series_count = Math.round(rsc)
}
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) 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)
}

View File

@ -15,6 +15,15 @@ export function normalizeAdvanceMode(v) {
return 'timed'
}
/** UI: Serien-Anzahl aus Formularfeld; leer/ungültig ⇒ 1 (eine Serie). */
export function parseComboRepSeriesCountUi(raw) {
if (raw === '' || raw === undefined || raw === null) return 1
const n =
typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : parseInt(String(raw).trim(), 10)
if (!Number.isFinite(n) || n < 1) return 1
return n
}
function parseProfileJson(raw) {
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} }
try {
@ -225,6 +234,7 @@ export function readSlotProfilesV1(profileObj) {
advance_mode: mode,
load_sec: normalizeOptionalNonNegInt(row.load_sec),
consecutive_reps: normalizeOptionalPositiveInt(row.consecutive_reps),
rep_series_count: normalizeOptionalPositiveInt(row.rep_series_count),
intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec),
transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec),
}
@ -249,6 +259,7 @@ function normalizeOptionalPositiveInt(v) {
const SLOT_TIMING_FIELDS = /** @type {const} */ ([
'load_sec',
'consecutive_reps',
'rep_series_count',
'intra_rep_rest_sec',
'transition_after_sec',
])
@ -322,7 +333,7 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
if (rawInput === null || rawInput === undefined || String(rawInput).trim() === '') {
delete nextRow[field]
} else if (field === 'consecutive_reps') {
} else if (field === 'consecutive_reps' || field === 'rep_series_count') {
const n = normalizeOptionalPositiveInt(rawInput)
if (n === undefined) delete nextRow[field]
else nextRow[field] = n