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 ? ( +
+ + onSlotRepSeriesCount(si, e.target.value)} + /> +
+ ) : null} + {slotAdv === 'timed' || showInterSeriesPause ? ( +
+ + onSlotField(si, 'intra_rep_rest_sec', e.target.value)} + /> +
+ ) : null}
- onSlotField(si, 'intra_rep_rest_sec', e.target.value)} - /> -
-
-
+ {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() { patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })} - /> - + {showMultiSeries ? ( +
+ + { + 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 ? ( +
+ + patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })} + /> +
+ ) : null}
+ {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