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 (
+ 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() {