From cf9932990ed37546731ad241d91b256aeb6ce0f2 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 08:58:41 +0200
Subject: [PATCH] feat(version): bump to 0.8.109 and enhance combination
exercise features
- 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
---
...e Kombinationsuebungen Spezifikation V2.md | 2 +-
.../COMBINATION_TIMING_PROFILE_PLAN.md | 5 +-
backend/version.py | 25 +++-
.../src/components/CombinationCoachSlots.jsx | 28 ++++-
.../CombinationMethodProfileEditor.jsx | 82 ++++++++++---
.../src/constants/combinationArchetypes.js | 14 +++
frontend/src/pages/ExerciseFormPage.jsx | 113 ++++++++++++++----
frontend/src/utils/api.js | 17 ++-
.../src/utils/combinationMethodProfileUi.js | 13 +-
9 files changed, 251 insertions(+), 48 deletions(-)
diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
index b42be9b..55d3643 100644
--- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
+++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
@@ -423,7 +423,7 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz**
**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 Erholungsanteil ≈ 2/3 der Belastung); der Archetyp **Freier Methodenblock** bildet den **Maximal‑Pfad** 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 **Methoden‑Archetyp** 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
diff --git a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
index dae1862..6a31017 100644
--- a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
+++ b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
@@ -53,8 +53,9 @@ Objekt‑Shape (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 Folge‑Wiederholungen. |
+| `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**, Archetyp‑Vorgabe möglich (**`ARCHETYPE_DEFAULT_REP_SERIES_COUNT`**). Persistiert für rep/manual ab 1. |
+| `intra_rep_rest_sec` | Pause zwischen den Folge‑Wiederholungen 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.
diff --git a/backend/version.py b/backend/version.py
index 02c0f10..98c30eb 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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: Serien‑Standard 1 + Archetyp‑Map 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; Profil‑Editor 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 à Ziel‑Wdh.); Formular‑ und Profil‑Editor‑Felder; Pause als „zwischen Serien“ beschriftet; Coach‑Zusammenfassung angepasst.",
+ ],
+ },
{
"version": "0.8.106",
"date": "2026-05-12",
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index 1dfee64..c8ca19d 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -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('Ziel‑Wdh.')
- 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()
diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx
index bdb0243..a8fae3f 100644
--- a/frontend/src/components/CombinationMethodProfileEditor.jsx
+++ b/frontend/src/components/CombinationMethodProfileEditor.jsx
@@ -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' ? 'Ziel‑Wdh.' : '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 (
onSlotField(si, 'consecutive_reps', e.target.value)}
/>
+ {showMultiSeries ? (
+
+
+ Serien
+
+ onSlotRepSeriesCount(si, e.target.value)}
+ />
+
+ ) : null}
+ {slotAdv === 'timed' || showInterSeriesPause ? (
+
+
+ {intraLabel} (s)
+
+ onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
+ />
+
+ ) : null}
- Pause zwischen Wdh. (s)
-
- onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
- />
-
-
-
- Pause / Wechsel (s)
+ Wechsel (s)
+ {showMultiSeries && serienUi < 2 ? (
+
+ Wechsel (s) zur nächsten Station . „Pause zw. Serien“ nur ab 2
+ Serien.
+
+ ) : null}
)
})}
diff --git a/frontend/src/constants/combinationArchetypes.js b/frontend/src/constants/combinationArchetypes.js
index 928c39d..93a4eb2 100644
--- a/frontend/src/constants/combinationArchetypes.js
+++ b/frontend/src/constants/combinationArchetypes.js
@@ -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)
+}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index 9fdc60c..5e29bbb 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -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() {
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 }
+ })
+ }}
>
— noch nicht festgelegt —
{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' ? 'Ziel‑Wdh.' : '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.'}
patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
/>
-
-
- Pause (s)
-
- patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
- />
-
+ {showMultiSeries ? (
+
+
+ Serien
+
+ {
+ 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)
+ }}
+ />
+
+ ) : null}
+ {slotAdv === 'timed' || showInterSeriesPause ? (
+
+
+ {intraLabel}
+
+ patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
+ />
+
+ ) : null}
Wechsel (s)
@@ -1443,6 +1508,12 @@ function ExerciseFormPage() {
/>
+ {showMultiSeries && serienCountUi < 2 ? (
+
+ Wechsel (s) = Pause bis zur nächsten Station . Feld „Pause zw.
+ Serien“ erscheint erst ab 2 Serien (sonst keine Pause zwischen zwei Blöcken nötig).
+
+ ) : null}
)
})}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 00e887b..8cc9fee 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -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)
}
diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js
index a52122c..719d435 100644
--- a/frontend/src/utils/combinationMethodProfileUi.js
+++ b/frontend/src/utils/combinationMethodProfileUi.js
@@ -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