feat(version): bump to 0.8.104 and enhance combination exercise features
All checks were successful
Deploy Development / deploy (push) Successful in 40s
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.104, reflecting recent improvements in combination exercise handling.
- Enhanced the CombinationMethodProfileEditor to support structured slot timing profiles without requiring JSON input from trainers.
- Introduced quick ratio presets for circuit and interval training methods, improving user experience in setting up training profiles.
- Updated documentation and changelog to reflect new features and integration details.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 07:38:44 +02:00
parent 4e654e50c0
commit 435da7f17a
7 changed files with 560 additions and 65 deletions

View File

@ -1,7 +1,7 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3 # Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf **Status:** fachlicher Spezifikationsentwurf
**Stand:** 2026-05-12 · **Coaching/Archetypen:** §10.2.1, §10.410.5, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** (Abgleich Code vs. Spec) **Stand:** 2026-05-12 (AnhangA **grob** App **0.8.104**; ZeitPfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** §10.2.1, §10.410.5, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan. **Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:** **Wichtige Leitlinie dieser Version:**
@ -417,8 +417,12 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **CoachAssistenz**
**Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits §2.5 und §8.3). **Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits §2.5 und §8.3).
**Umsetzung in der App (Stand 0.8.103):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB). **`null`** bedeutet: es wirkt das Ablaufprofil aus dem **Katalog** (`method_profile` der Übung). Ist ein Snapshot gesetzt, ersetzt er den Katalog **vollständig** für diese Platzierung (kein serverseitiges Zusammenführen). Bearbeitung in der Planungs-UI: aufklappbarer Block **„Ablaufprofil für diese Planung (Kombination)“** mit denselben geführten Feldern wie im Übungsformular.
**Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§10.4**). **Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§10.4**).
**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.
### 6.4 Slot- und Pool-Logik ### 6.4 Slot- und Pool-Logik
Slots können fest oder variabel sein. Slots können fest oder variabel sein.
@ -512,7 +516,7 @@ Produktregel:
Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können: Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können:
* Dauer ändern, * Dauer ändern,
* **bei Kombinationsübungen:** im Idealfall **`method_profile` (Arbeit, Erholung, Durchläufe)** und Stations-/Slot-Anpassungen des **konkreten Vorkommens**, nicht nur Gesamtzeit, * **bei Kombinationsübungen:** Ablaufprofil **optional nur für diese Platzierung** überschreiben (aktuell: Snapshot parallel zum Katalog-`method_profile`, z.B. Arbeit-, Erholungs- und Runden-/Intervallangaben über die gleichen strukturierten Felder wie im Übungskatalog) — zusätzlich zu den **Gepl.-Min.** am Eintrag; **Stations-/Slot-Austausch** am konkreten Vorkommen weiter über die bestehende Übungs-/Planungslogik, nicht gesondert als „Kombi-Programmierung“ je Zeile,
* Übung austauschen, * Übung austauschen,
* Station ergänzen, * Station ergänzen,
* Hinweise anpassen, * Hinweise anpassen,
@ -787,7 +791,7 @@ Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlich
--- ---
## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.102**, grob) ## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.104**, grob)
Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird. Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird.
@ -795,10 +799,10 @@ Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schr
|--------------------------------------------|-----------------|---------------------------------|-------------------------------------| |--------------------------------------------|-----------------|---------------------------------|-------------------------------------|
| **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus | | **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus |
| **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus §4/§6 dort umsetzen, wo die Domäne es noch nicht abdeckt | | **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus §4/§6 dort umsetzen, wo die Domäne es noch nicht abdeckt |
| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` | Persistenz; Übungsformular: **geführte Felder** nach Archetyp (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) + eingeklapptes RohJSON | SchemaValidierung serverseitig noch offen; UI für Pflicht je Archetyp (§10.5) weiter schärfen | | **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` (+ Pilot **`slot_profiles_v1`** je Station in derselben JSONStruktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne NutzerJSONPflicht; Schnellwahl typische Arbeit/PauseRelationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON„Experte“ weiter abschaltbar; SchemaPflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) |
| **Einplanbarkeit (normale Planung)** | Kombi wie Übung in Sektionen; **ZeitprofilOverrides** nach §8.3 / §10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Overrides von `method_profile` am Platzierungseintrag fehlen** | Planungs-UI/API: kopiertes **`method_profile` pro Einheit/item** bearbeitbar; Planungsblöcke (Phase 3) | | **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **ZeitprofilOverrides** nach §8.3 / §10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Override:** DB **`planning_method_profile`** je Sektions-Item (Migration **057**), Planungseditor: Details „Ablaufprofil für diese Planung“, **„Planung wie Katalog“** / **„Aus Katalog kopieren“** | Planungsblöcke als Produktkonzept · Phase 3; serverseitige Validierung Snapshot↔Archetyp optional |
| **Zeitphasen (global / pro Slot)** | §6.3 | Über `method_profile` teilweise (globale Schlüssel im Formular); **keine strukturierten slotgebundenen Zeitlisten** im UI | `slot_timing[]` oder äquivalent definieren und editieren | | **Zeitphasen (global / pro Slot)** | §6.3 | Über `method_profile` / PlanungsSnapshot (**gleiche JSON-Struktur** wie Katalogprofil): globale Schlüssel im Übungs- und Planungseditor; weiterhin **keine** eigenständigen slotgebundenen Zeitlisten im UI | `slot_timing[]` oder äquivalent definieren und editieren |
| **Coaching Stufe A** | Slots + Kandidaten sichtbar, ArchetypHinweis, Profil lesbar | `CombinationCoachSlots` zeigt **Key/Value** aus `method_profile`, sonst wie zuvor | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) | | **Coaching Stufe A** | Slots + Kandidaten sichtbar, ArchetypHinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **PlanungsSnapshot wenn gesetzt, sonst Katalog**; Anzeige **Key/Value** | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) |
| **Coaching Stufe B** | Zeitleiste archetypnah (z.B. Schritt pro Station) | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item | | **Coaching Stufe B** | Zeitleiste archetypnah (z.B. Schritt pro Station) | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item |
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** CoachTimer pro Planungsitem | Pro Archetyp UIState + Anbindung an `method_profile` | | **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** CoachTimer pro Planungsitem | Pro Archetyp UIState + Anbindung an `method_profile` |
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | SlotBlueprint, `from-framework-slot` | Modul-/KombiUX in Rahmen wie in Einheit konsolidieren (Phase 5) | | **Rahmenprogramm** | Gleiche Inhalte wie Einheit | SlotBlueprint, `from-framework-slot` | Modul-/KombiUX in Rahmen wie in Einheit konsolidieren (Phase 5) |

View File

@ -0,0 +1,100 @@
# KombinationsAblaufprofil — Zeitmodell, ArchetypVorgaben, Umsetzung
**Zweck:** Fach-/Technik-Brücke zwischen Wunschbild („kein NutzerJSON“, globale und slotbezogene Eckwerte, ArchetypStrukturen) und bestehendem Speicher **`method_profile` (JSON)** + **`planning_method_profile`** auf Planungszeilen.
**Bezüge:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md`6.3 / §8.3); Frontend `CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`; ArchetypIDs siehe Backend `COMBINATION_ARCHETYPE_IDS` / Frontend `COMBINATION_ARCHETYPE_OPTIONS`.
---
## 1. Grundprinzipien
| Prinzip | Beschreibung |
|--------|--------------|
| **Kein PflichtJSON für Trainer** | Alle trainertypischen Pflegepfade nur über geführte Felder + ArchetypVorschlagsknöpfe. |
| **JSON bleibt Transport** | Persistenz geschieht weiter in `method_profile` / Kopie in `planning_method_profile`; **kanonische Schlüssel** werden hier und in Codekommentaren festgehalten. |
| **Archetyp = Struktur + Defaults** | Wechsel des Archetyps soll (optional/togglebar) Grundwerte oder typische Relationen vorbelegen können — keine stillen Überschreibungen ohne Hinweis. |
| **`free_method_block` = Maximale Freiheit** | Entspricht „maximaler Konfiguration“: alle relevanten TimingDimensionen über UI, insbesondere **pro Slot**; keine impliziten stationären Constraints. |
---
## 2. Kanonisches ZeitSchema (`timing_schema`)
**Empfohlene Versionierung im Objekt:**
- **`timing_schema: 1`** — sobald neue globale/strukturierte Felder aktiv genutzt werden (Pilot; UI kann ohne Migration starten durch parallele Schlüssel).
### 2.1 Globalebene (`method_profile`)
| Feld (Pilot) | Semantik |
|----------------|----------|
| `timing_schema` | `1` wenn Block unten aktiv |
| `intro_sec` oder bestehend `block_intro_sec` | einmalige Einführung/Demo am Block |
| `rounds` (bzw. bei Intervallen `interval_rounds` — Angleich später) | komplette Durchläufe des Musters |
| *Planned totals* nur **berechnete Anzeige** in UI, optional persistiert z.B. `planned_total_duration_min_hint` später |
Relationen **Zwischen Arbeit und Pause** können als Schnellwahl gesetzt werden (kein eigener PersistErzwingTyp nötig), indem konkrete Sekunden geschrieben werden.
### 2.2 Slots (`slot_profiles_v1`)
Array synchron zu `slot_index`; fehlende Einträge = „nicht gefüllt / aus globalen Eckdaten ableiten wo sinnvoll“.
ObjektShape (Sekunden, ganze Zahlen ≥ 0):
```json
{
"slot_index": 0,
"load_sec": 40,
"consecutive_reps": 1,
"intra_rep_rest_sec": 10,
"transition_after_sec": 15
}
```
| 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. |
| `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.
---
## 3. Archetyp → typische Schnellwahl (ÜberblicksMatrix)
| Archetyp | Globale Schnellwahl (Beispiele) | Slots |
|----------|---------------------------------|-------|
| `circuit_rotate_time` | Arbeit; Rotation „≈ Arbeit“ oder „Pause 2/3 Arbeit“ bezogen auf RundPausen/Rotation wo im UI dokumentiert | sinnvoll ab **timing_schema** geführt |
| `time_domain_interval` | Pause = Arbeit; Pause = 2/3 Arbeit (auf `rest_seconds`↔`work_seconds`) | optional |
| `sequence_linear` | Einführung + grobe Sek./Station | **slot_profiles_v1** priorisiert |
| `circuit_all_parallel` | Erklärzeit, gemeinsamer Start | Slots optional |
| `pair_superset` | Wechsel A↔B, Arbeit je Seite (+ später erweiterbar) | 2SlotFokus |
| `free_method_block` | alle globalen Slots optional | **Pfad für maximale Flex** |
| `station_parcour` | Reihenfolge freiFlag bestehend | pro Station Verweilen sinnvoll |
**Pyramidal (später):** neue ArchetypID **`pyramid_interval`** o. ä. oder Flag `pyramid_recovery_rule` mit Regelwerk „Pause abhängig von letzter Belastung“ — **explizit out of scope** bis Regeln feststehen.
---
## 4. UXNormen
- **Trainingsplanung** (`plannerMode`): **keine** RohJSONOberfläche.
- **Übungsformular**: RohJSON nur wenn `allowExpertJson === true` (Default false; später z.B. Superadmin/Dev).
- **CoachingAnsicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln.
---
## 5. Phasen (Implementierung)
| Phase | Inhalt |
|-------|--------|
| **1 (jetzt)** | SlotZeilenUI über `slot_profiles_v1`; SchnellwahlRatios für `circuit_rotate_time` + `time_domain_interval`; `plannerMode` ohne JSON; `allowExpertJson` default false |
| **2** | Beim Archetypwechsel **optionales** Modal „ArchetypVorlage anwenden?“ mit nichtdestruktivem Merge |
| **3** | Geplante **Gesamtzeit** konsistent rechnerisch (Summe Slots × Runden + Global) mit Transparenz in UI |
| **4** | Konsolidierung flacher Schlüssel → **`timing_schema`** v1only im Editor |
| **5** | Pyramide / adaptive Recovery |
---
**Pflege:** Änderungen an Schlüsseln oder Phasen hier und in Anhang A der Fachspez mitziehen.

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.103" APP_VERSION = "0.8.104"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512057" DB_SCHEMA_VERSION = "20260512057"
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.24.2", # Kombi: geführtes method_profile im Übungsformular nach Archetyp + Coach zeigt Profil als Key/Wert "exercises": "2.25.0", # Kombi: slot_profiles_v1 + Schnellwahl Belastung/Erholung; keine NutzerJSONPflicht; Übungsform Stationen vor Ablaufprofil
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT "planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
@ -35,6 +35,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.104",
"date": "2026-05-12",
"changes": [
"KombinationsAblaufprofil UX: Stationszeilen (slot_profiles_v1); Schnellwahlen Arbeit↔Pause (Zirkel + Intervall); PlanungsOverride ohne JSON; Übungsformular: Reihenfolge Stationen dann Ablaufprofil.",
"Arbeitspapier: `.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`.",
],
},
{ {
"version": "0.8.103", "version": "0.8.103",
"date": "2026-05-12", "date": "2026-05-12",

View File

@ -1,10 +1,15 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { archetypeCoachHint, combinationArchetypeLabel } from '../constants/combinationArchetypes' import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import { import {
METHOD_PROFILE_GUI_FIELDS, METHOD_PROFILE_GUI_FIELDS,
parseProfileJson, parseProfileJson,
setFullProfileRawJson, setFullProfileRawJson,
updateProfileGuided, updateProfileGuided,
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi' } from '../utils/combinationMethodProfileUi'
function clampInt(n, min, max) { function clampInt(n, min, max) {
@ -15,13 +20,31 @@ function clampInt(n, min, max) {
return Math.round(x) return Math.round(x)
} }
/** Archetypen mit klar bezifferbarer Stationslogik · alle mit Slot-Liste sinnvoll */
const ARCHETYPES_WITH_SLOT_TIMING = new Set([
'circuit_rotate_time',
'sequence_linear',
'station_parcour',
'time_domain_interval',
'circuit_all_parallel',
'pair_superset',
'free_method_block',
])
/** /**
* Kombination: geführtes Ablaufprofil + optionales Roh-JSON. * Kombination: geführtes method_profile (+ optional Stationszeilen, ohne JSON für Trainer).
*
* @param {boolean} [props.plannerMode] z. B. PlanungsOverride: keine RohJSONSektion.
* @param {boolean} [props.allowExpertJson] wenn true und nicht plannerMode: RohJSON (Support).
* @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] für SlotFelder aus der Übung
*/ */
export default function CombinationMethodProfileEditor({ export default function CombinationMethodProfileEditor({
methodArchetype, methodArchetype,
methodProfileJson, methodProfileJson,
onChangeMethodProfileJson, onChangeMethodProfileJson,
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
}) { }) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : '' const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch] const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
@ -29,20 +52,36 @@ export default function CombinationMethodProfileEditor({
const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson]) const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
const [rawOpenError, setRawOpenError] = useState(null) const [rawOpenError, setRawOpenError] = useState(null)
const [rawDraft, setRawDraft] = useState(null) const [rawDraft, setRawDraft] = useState(null)
const [presetHint, setPresetHint] = useState(null)
const profileObj = parseState.ok ? parseState.obj : {} const profileObj = parseState.ok ? parseState.obj : {}
const outlineSorted = useMemo(() => {
if (!comboSlotsOutline || !Array.isArray(comboSlotsOutline) || comboSlotsOutline.length === 0) return []
return sortCombinationSlotsForDisplay(comboSlotsOutline)
}, [comboSlotsOutline])
const showSlotTiming =
ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
const lookupSlotTiming = (slotIndex) =>
slotRowsModel.find((r) => Number(r.slot_index) === Number(slotIndex)) || {}
const applyGuided = (key, value, kind) => { const applyGuided = (key, value, kind) => {
if (kind === 'bool') { if (kind === 'bool') {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool') const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
if (!res.ok) return if (!res.ok) return
onChangeMethodProfileJson(res.json) onChangeMethodProfileJson(res.json)
setPresetHint(null)
return return
} }
if (value === '' || value === undefined || value === null) { if (value === '' || value === undefined || value === null) {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int') const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
if (!res.ok) return if (!res.ok) return
onChangeMethodProfileJson(res.json) onChangeMethodProfileJson(res.json)
setPresetHint(null)
return return
} }
const num = typeof value === 'number' ? value : parseInt(String(value), 10) const num = typeof value === 'number' ? value : parseInt(String(value), 10)
@ -53,6 +92,36 @@ export default function CombinationMethodProfileEditor({
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int') const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int')
if (!res.ok) return if (!res.ok) return
onChangeMethodProfileJson(res.json) onChangeMethodProfileJson(res.json)
setPresetHint(null)
}
const onSlotField = (slotIx, field, rawStr) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotTimingField(d, slotIx, field, rawStr)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const runIntervalPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyIntervalDomainQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
} }
const archeLabel = arch ? combinationArchetypeLabel(arch) : null const archeLabel = arch ? combinationArchetypeLabel(arch) : null
@ -63,8 +132,14 @@ export default function CombinationMethodProfileEditor({
setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || '')) setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
} }
const showExpertSection = allowExpertJson && !plannerMode
return ( return (
<div style={{ marginTop: '10px' }}> <div style={{ marginTop: '10px' }}>
{presetHint ? (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '0 0 8px', lineHeight: 1.4 }}>{presetHint}</p>
) : null}
{arch ? ( {arch ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.48, margin: '0 0 12px' }}> <p style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.48, margin: '0 0 12px' }}>
<strong style={{ color: 'var(--text1)' }}> <strong style={{ color: 'var(--text1)' }}>
@ -75,7 +150,8 @@ export default function CombinationMethodProfileEditor({
</p> </p>
) : ( ) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 12px' }}> <p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 12px' }}>
Wähle einen Archetyp, um das Ablaufprofil strukturiert zu erfassen oder nur das JSON weiter unten. Wähle einen MethodenArchetyp besonders beim <strong>freien Methodenblock</strong> stehen alle
typischen StationsZeiten zur Verfügung. Ohne Archetyp keine geführten Eingaben.
</p> </p>
)} )}
@ -85,6 +161,48 @@ export default function CombinationMethodProfileEditor({
{fields && fields.length > 0 ? ( {fields && fields.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{arch === 'circuit_rotate_time' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('transition_equals_work')}>
Wechsel Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_equals_work')}>
RundenPause Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_two_thirds_work')}>
RundenPause Arbeit
</button>
</div>
) : null}
{arch === 'time_domain_interval' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_equals_work')}>
Erholung = Belastung
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_two_thirds_work')}>
Erholung Belastung
</button>
</div>
) : null}
{fields.map((def) => { {fields.map((def) => {
if (def.kind === 'bool') { if (def.kind === 'bool') {
const ck = !!profileObj[def.key] const ck = !!profileObj[def.key]
@ -125,51 +243,170 @@ export default function CombinationMethodProfileEditor({
})} })}
</div> </div>
) : arch && fields && fields.length === 0 ? ( ) : arch && fields && fields.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px' }}> <div style={{ marginBottom: '12px' }}>
Für diesen Archetyp gibt es keine vorgegebenen Profilfelder nutze die Freitexte der Kombination oder RohJSON bei Bedarf. <p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
</p> Dieser Archetyp ist für <strong>maximal flexible</strong> Stationsblöcke gedacht die ZeitEckdaten sind
unten je Station möglich. Freitexte der Kombination beschreiben alles Organisatorische, was nicht in
Sekunden gefasst wird.
</p>
</div>
) : null} ) : null}
<details {showSlotTiming ? (
style={{ <div
marginTop: '4px', style={{
borderRadius: '8px', marginTop: '6px',
border: '1px solid var(--border)', marginBottom: '14px',
padding: '8px 10px', padding: '12px 14px',
background: 'var(--surface)', borderRadius: '10px',
}} border: '1px solid var(--border)',
onToggle={(ev) => { background: 'var(--surface)',
if (ev.target.open) openAdvanced()
}}
>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>Erweitert: JSON direkt bearbeiten</summary>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 6px', lineHeight: 1.4 }}>
Zusätzliche Schlüssel (Piloten). Geführte Felder können dieselben Schlüssel beim nächsten Speichern überlagern.
</p>
<textarea
className="form-input"
rows={8}
style={{ fontFamily: 'Consolas,monospace', fontSize: '12px' }}
value={rawDraft != null ? rawDraft : methodProfileJson || '{}'}
onChange={(e) => {
setRawDraft(e.target.value)
setRawOpenError(null)
}} }}
spellCheck={false} >
onBlur={() => { <div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
const src = rawDraft != null ? rawDraft : methodProfileJson Pro Station / Slot (Zeiten in Sekunden)
const res = setFullProfileRawJson(src || '{}') </div>
if (!res.ok) { <p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
setRawOpenError(res.error) Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
return zur nächsten Station. Felder können leer bleiben z.B. nutzt der Zirkel oben erst die globalen Arbeit/
} RotationsSekunden.
setRawOpenError(null) </p>
setRawDraft(null) <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
onChangeMethodProfileJson(res.json) {outlineSorted.map((slot) => {
const siRaw = slot.slot_index
const si =
siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
return (
<div
key={`slot-timing-${si}`}
style={{
padding: '10px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div style={{ marginBottom: '10px', fontWeight: 700, color: 'var(--text1)', fontSize: '0.9rem' }}>
Station {si}
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.load_sec != null ? String(row.load_sec) : ''}
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wdh. ohne Wechsel
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="oft 1"
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
onChange={(e) => onSlotField(si, '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>
<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)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.transition_after_sec != null ? String(row.transition_after_sec) : ''}
onChange={(e) => onSlotField(si, 'transition_after_sec', e.target.value)}
/>
</div>
</div>
</div>
)
})}
</div>
</div>
) : null}
{showExpertSection ? (
<details
style={{
marginTop: '4px',
borderRadius: '8px',
border: '1px solid var(--border)',
padding: '8px 10px',
background: 'var(--surface2)',
}} }}
/> onToggle={(ev) => {
{rawOpenError ? <p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p> : null} if (ev.target.open) openAdvanced()
</details> }}
>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>
Support / Entwicklung: Rohdaten (JSON)
</summary>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 6px', lineHeight: 1.4 }}>
Für Migrationen und Sonderfälle. Geführte Felder setzen weiterhin gültige Standardschlüssel.
</p>
<textarea
className="form-input"
rows={8}
style={{ fontFamily: 'Consolas,monospace', fontSize: '12px' }}
value={rawDraft != null ? rawDraft : methodProfileJson || '{}'}
onChange={(e) => {
setRawDraft(e.target.value)
setRawOpenError(null)
}}
spellCheck={false}
onBlur={() => {
const src = rawDraft != null ? rawDraft : methodProfileJson
const res = setFullProfileRawJson(src || '{}')
if (!res.ok) {
setRawOpenError(res.error)
return
}
setRawOpenError(null)
setRawDraft(null)
onChangeMethodProfileJson(res.json)
}}
/>
{rawOpenError ? (
<p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p>
) : null}
</details>
) : null}
</div> </div>
) )
} }

View File

@ -1066,6 +1066,7 @@ export default function TrainingUnitSectionsEditor({
</button> </button>
</div> </div>
<CombinationMethodProfileEditor <CombinationMethodProfileEditor
plannerMode
methodArchetype={(it.catalog_method_archetype || '').trim()} methodArchetype={(it.catalog_method_archetype || '').trim()}
methodProfileJson={comboPlanningProfileJsonForEditor( methodProfileJson={comboPlanningProfileJsonForEditor(
it.catalog_method_profile || {}, it.catalog_method_profile || {},

View File

@ -1031,19 +1031,10 @@ function ExerciseFormPage() {
))} ))}
</select> </select>
</div> </div>
<div className="form-row"> <div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '10px' }}>
<label className="form-label">Ablaufprofil (über Archetyp)</label>
<CombinationMethodProfileEditor
methodArchetype={formData.method_archetype || ''}
methodProfileJson={formData.method_profile_json || '{}'}
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)}
/>
</div>
<div>
<strong style={{ fontSize: '14px' }}>Stationen</strong> <strong style={{ fontSize: '14px' }}>Stationen</strong>
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px' }}> <p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px', lineHeight: 1.45 }}>
Index (Reihenfolge), optional Stationstitel und kommaseparierte IDs von{' '} Zuerst die Stationen anlegen dann erscheinen die Zeiten darunter auch <strong>pro Slot</strong> im Ablaufprofil.
<strong>Einzelübungen</strong> im Pool (nur Übungen mit Art Einzelübung).
</p> </p>
{(formData.combination_slots || []).map((row, idx) => ( {(formData.combination_slots || []).map((row, idx) => (
<div <div
@ -1146,6 +1137,15 @@ function ExerciseFormPage() {
+ Station + Station
</button> </button>
</div> </div>
<div className="form-row">
<label className="form-label">Ablaufprofil (Zeiten &amp; Runden)</label>
<CombinationMethodProfileEditor
methodArchetype={formData.method_archetype || ''}
methodProfileJson={formData.method_profile_json || '{}'}
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)}
comboSlotsOutline={formData.combination_slots || []}
/>
</div>
</> </>
) : null} ) : null}
</div> </div>

View File

@ -162,4 +162,149 @@ export function setFullProfileRawJson(rawEditable) {
return { ok: true, obj: parsed.obj, json: j === '{}' ? '{}' : j } return { ok: true, obj: parsed.obj, json: j === '{}' ? '{}' : j }
} }
/**
* Pfad für slot_profiles_v1 und ähnliche strukturierte Erweiterungen.
* Ungültiges JSON gibt { ok:false } zurück; mutator erhält geklontes ProfilObjekt.
*/
export function patchMethodProfile(rawJson, mutator) {
const parsed = parseProfileJson(rawJson || '{}')
if (!parsed.ok) return parsed
const draft = { ...parsed.obj }
mutator(draft)
try {
const j = JSON.stringify(draft)
return { ok: true, obj: draft, json: j === '{}' ? '{}' : j }
} catch {
return { ok: false, error: 'Ablaufprofil konnte nicht gespeichert werden.' }
}
}
/** Normalisiert slot_profiles_v1 aus dem gespeicherten Profil */
export function readSlotProfilesV1(profileObj) {
if (!profileObj || typeof profileObj !== 'object') return []
const raw = profileObj.slot_profiles_v1
if (!Array.isArray(raw)) return []
return raw.map((row) => {
if (!row || typeof row !== 'object') return null
const si = Number(row.slot_index)
return {
slot_index: Number.isFinite(si) ? si : 0,
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),
}
}).filter(Boolean)
}
function normalizeOptionalNonNegInt(v) {
if (v === '' || v === undefined || v === null) return undefined
const n = typeof v === 'number' ? v : parseInt(String(v), 10)
if (!Number.isFinite(n) || n < 0) return undefined
return Math.round(n)
}
function normalizeOptionalPositiveInt(v) {
const n = normalizeOptionalNonNegInt(v)
if (n === undefined) return undefined
if (n < 1) return undefined
return n
}
const SLOT_TIMING_FIELDS = /** @type {const} */ ([
'load_sec',
'consecutive_reps',
'intra_rep_rest_sec',
'transition_after_sec',
])
/** '', null = Feld entfernen; sonst gültige Zahl setzen */
export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) {
if (!SLOT_TIMING_FIELDS.includes(field)) return
const ix =
typeof slotIndex === 'number' && Number.isFinite(slotIndex)
? slotIndex
: parseInt(String(slotIndex), 10)
if (!Number.isFinite(ix)) return
let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : []
let 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 (rawInput === null || rawInput === undefined || String(rawInput).trim() === '') {
delete nextRow[field]
} else if (field === 'consecutive_reps') {
const n = normalizeOptionalPositiveInt(rawInput)
if (n === undefined) delete nextRow[field]
else nextRow[field] = n
} else {
const n = normalizeOptionalNonNegInt(rawInput)
if (n === undefined) delete nextRow[field]
else nextRow[field] = n
}
const hasTiming = SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null)
if (found >= 0) {
if (!hasTiming) {
arr = arr.filter((_, i) => i !== found)
} else {
arr[found] = nextRow
}
} else if (hasTiming) {
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
}
/** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */
export function applyCircuitRotateQuickRatio(profileDraft, preset) {
const wRaw = profileDraft.work_seconds
const work =
typeof wRaw === 'number' && Number.isFinite(wRaw) ? Math.round(wRaw) : parseInt(String(wRaw), 10)
if (!Number.isFinite(work) || work <= 0)
return { ok: false, error: 'Zuerst Arbeitszeit pro Station (Sek.) setzen.' }
profileDraft.timing_schema = profileDraft.timing_schema ?? 1
if (preset === 'transition_equals_work') {
profileDraft.transition_seconds = work
return { ok: true }
}
if (preset === 'round_rest_equals_work') {
profileDraft.rest_seconds = work
return { ok: true }
}
if (preset === 'round_rest_two_thirds_work') {
profileDraft.rest_seconds = Math.round((work * 2) / 3)
return { ok: true }
}
return { ok: false, error: 'Unbekannte Schnellwahl.' }
}
export function applyIntervalDomainQuickRatio(profileDraft, preset) {
const wRaw = profileDraft.work_seconds
const work =
typeof wRaw === 'number' && Number.isFinite(wRaw) ? Math.round(wRaw) : parseInt(String(wRaw), 10)
if (!Number.isFinite(work) || work <= 0)
return { ok: false, error: 'Zuerst Belastungszeit Intervall (Sek.) setzen.' }
profileDraft.timing_schema = profileDraft.timing_schema ?? 1
if (preset === 'rest_equals_work') {
profileDraft.rest_seconds = work
return { ok: true }
}
if (preset === 'rest_two_thirds_work') {
profileDraft.rest_seconds = Math.round((work * 2) / 3)
return { ok: true }
}
return { ok: false, error: 'Unbekannte Schnellwahl.' }
}
export { parseProfileJson, INT_MAX } export { parseProfileJson, INT_MAX }