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
**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.
**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).
**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**).
**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
Slots können fest oder variabel sein.
@ -512,7 +516,7 @@ Produktregel:
Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können:
* 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,
* Station ergänzen,
* 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.
@ -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 |
| **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 |
| **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) |
| **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 |
| **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) |
| **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 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` / 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`: 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 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) |

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
APP_VERSION = "0.8.103"
APP_VERSION = "0.8.104"
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.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_programs": "0.1.0",
"planning": "0.9.2", # Kombi: planning_method_profile auf Sektions-Items (Migration 057); Form-Payload + Coach-PUT
@ -35,6 +35,14 @@ MODULE_VERSIONS = {
}
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",
"date": "2026-05-12",

View File

@ -1,10 +1,15 @@
import React, { useMemo, useState } from 'react'
import { archetypeCoachHint, combinationArchetypeLabel } from '../constants/combinationArchetypes'
import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
setFullProfileRawJson,
updateProfileGuided,
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
function clampInt(n, min, max) {
@ -15,13 +20,31 @@ function clampInt(n, min, max) {
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({
methodArchetype,
methodProfileJson,
onChangeMethodProfileJson,
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
@ -29,20 +52,36 @@ export default function CombinationMethodProfileEditor({
const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
const [rawOpenError, setRawOpenError] = useState(null)
const [rawDraft, setRawDraft] = useState(null)
const [presetHint, setPresetHint] = useState(null)
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) => {
if (kind === 'bool') {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
if (value === '' || value === undefined || value === null) {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
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')
if (!res.ok) return
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
@ -63,8 +132,14 @@ export default function CombinationMethodProfileEditor({
setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
}
const showExpertSection = allowExpertJson && !plannerMode
return (
<div style={{ marginTop: '10px' }}>
{presetHint ? (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '0 0 8px', lineHeight: 1.4 }}>{presetHint}</p>
) : null}
{arch ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.48, margin: '0 0 12px' }}>
<strong style={{ color: 'var(--text1)' }}>
@ -75,7 +150,8 @@ export default function CombinationMethodProfileEditor({
</p>
) : (
<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>
)}
@ -85,6 +161,48 @@ export default function CombinationMethodProfileEditor({
{fields && fields.length > 0 ? (
<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) => {
if (def.kind === 'bool') {
const ck = !!profileObj[def.key]
@ -125,26 +243,142 @@ export default function CombinationMethodProfileEditor({
})}
</div>
) : arch && fields && fields.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px' }}>
Für diesen Archetyp gibt es keine vorgegebenen Profilfelder nutze die Freitexte der Kombination oder RohJSON bei Bedarf.
<div style={{ marginBottom: '12px' }}>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
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}
{showSlotTiming ? (
<div
style={{
marginTop: '6px',
marginBottom: '14px',
padding: '12px 14px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
Pro Station / Slot (Zeiten in Sekunden)
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
zur nächsten Station. Felder können leer bleiben z.B. nutzt der Zirkel oben erst die globalen Arbeit/
RotationsSekunden.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{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(--surface)',
background: 'var(--surface2)',
}}
onToggle={(ev) => {
if (ev.target.open) openAdvanced()
}}
>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>Erweitert: JSON direkt bearbeiten</summary>
<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 }}>
Zusätzliche Schlüssel (Piloten). Geführte Felder können dieselben Schlüssel beim nächsten Speichern überlagern.
Für Migrationen und Sonderfälle. Geführte Felder setzen weiterhin gültige Standardschlüssel.
</p>
<textarea
className="form-input"
@ -168,8 +402,11 @@ export default function CombinationMethodProfileEditor({
onChangeMethodProfileJson(res.json)
}}
/>
{rawOpenError ? <p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p> : null}
{rawOpenError ? (
<p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p>
) : null}
</details>
) : null}
</div>
)
}

View File

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

View File

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

View File

@ -162,4 +162,149 @@ export function setFullProfileRawJson(rawEditable) {
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 }