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
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:
parent
4e654e50c0
commit
435da7f17a
|
|
@ -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.4–10.5, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** (Abgleich Code vs. Spec)
|
**Stand:** 2026-05-12 (Anhang A **grob** App **0.8.104**; Zeit‑Pfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.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 **Coach‑Assistenz**
|
||||||
|
|
||||||
**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 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.
|
||||||
|
|
||||||
### 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 Roh‑JSON | Schema‑Validierung 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 JSON‑Struktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne Nutzer‑JSON‑Pflicht; Schnellwahl typische Arbeit/Pause‑Relationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON‑„Experte“ weiter abschaltbar; Schema‑Pflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) |
|
||||||
| **Einplanbarkeit (normale Planung)** | Kombi wie Übung in Sektionen; **Zeitprofil‑Overrides** 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; **Zeitprofil‑Overrides** 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` / Planungs‑Snapshot (**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, Archetyp‑Hinweis, 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, Archetyp‑Hinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **Planungs‑Snapshot 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 Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item |
|
| **Coaching Stufe B** | Zeitleiste archetypnah (z. B. Schritt pro Station) | **Nein** — ein Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item |
|
||||||
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Coach‑Timer pro Planungsitem | Pro Archetyp UI‑State + Anbindung an `method_profile` |
|
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Coach‑Timer pro Planungsitem | Pro Archetyp UI‑State + Anbindung an `method_profile` |
|
||||||
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | Slot‑Blueprint, `from-framework-slot` | Modul-/Kombi‑UX in Rahmen wie in Einheit konsolidieren (Phase 5) |
|
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | Slot‑Blueprint, `from-framework-slot` | Modul-/Kombi‑UX in Rahmen wie in Einheit konsolidieren (Phase 5) |
|
||||||
|
|
|
||||||
100
.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
Normal file
100
.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Kombinations‑Ablaufprofil — Zeitmodell, Archetyp‑Vorgaben, Umsetzung
|
||||||
|
|
||||||
|
**Zweck:** Fach-/Technik-Brücke zwischen Wunschbild („kein Nutzer‑JSON“, globale und slotbezogene Eckwerte, Archetyp‑Strukturen) 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`; Archetyp‑IDs siehe Backend `COMBINATION_ARCHETYPE_IDS` / Frontend `COMBINATION_ARCHETYPE_OPTIONS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Grundprinzipien
|
||||||
|
|
||||||
|
| Prinzip | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| **Kein Pflicht‑JSON für Trainer** | Alle trainertypischen Pflegepfade nur über geführte Felder + Archetyp‑Vorschlagsknö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 Timing‑Dimensionen über UI, insbesondere **pro Slot**; keine impliziten stationären Constraints. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kanonisches Zeit‑Schema (`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 Persist‑Erzwing‑Typ 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“.
|
||||||
|
|
||||||
|
Objekt‑Shape (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 Folge‑Wiederholungen. |
|
||||||
|
| `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 (Überblicks‑Matrix)
|
||||||
|
|
||||||
|
| Archetyp | Globale Schnellwahl (Beispiele) | Slots |
|
||||||
|
|----------|---------------------------------|-------|
|
||||||
|
| `circuit_rotate_time` | Arbeit; Rotation „≈ Arbeit“ oder „Pause 2/3 Arbeit“ bezogen auf Rund‑Pausen/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) | 2‑Slot‑Fokus |
|
||||||
|
| `free_method_block` | alle globalen Slots optional | **Pfad für maximale Flex** |
|
||||||
|
| `station_parcour` | Reihenfolge frei‑Flag bestehend | pro Station Verweilen sinnvoll |
|
||||||
|
|
||||||
|
**Pyramidal (später):** neue Archetyp‑ID **`pyramid_interval`** o. ä. oder Flag `pyramid_recovery_rule` mit Regelwerk „Pause abhängig von letzter Belastung“ — **explizit out of scope** bis Regeln feststehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UX‑Normen
|
||||||
|
|
||||||
|
- **Trainingsplanung** (`plannerMode`): **keine** Roh‑JSON‑Oberfläche.
|
||||||
|
- **Übungsformular**: Roh‑JSON nur wenn `allowExpertJson === true` (Default false; später z. B. Superadmin/Dev).
|
||||||
|
- **Coaching‑Ansicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phasen (Implementierung)
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **1 (jetzt)** | Slot‑Zeilen‑UI über `slot_profiles_v1`; Schnellwahl‑Ratios für `circuit_rotate_time` + `time_domain_interval`; `plannerMode` ohne JSON; `allowExpertJson` default false |
|
||||||
|
| **2** | Beim Archetypwechsel **optionales** Modal „Archetyp‑Vorlage anwenden?“ mit nicht‑destruktivem Merge |
|
||||||
|
| **3** | Geplante **Gesamtzeit** konsistent rechnerisch (Summe Slots × Runden + Global) mit Transparenz in UI |
|
||||||
|
| **4** | Konsolidierung flacher Schlüssel → **`timing_schema`** v1‑only im Editor |
|
||||||
|
| **5** | Pyramide / adaptive Recovery |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Pflege:** Änderungen an Schlüsseln oder Phasen hier und in Anhang A der Fachspez mitziehen.
|
||||||
|
|
@ -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 Nutzer‑JSON‑Pflicht; Ü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": [
|
||||||
|
"Kombinations‑Ablaufprofil UX: Stationszeilen (slot_profiles_v1); Schnellwahlen Arbeit↔Pause (Zirkel + Intervall); Planungs‑Override 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",
|
||||||
|
|
|
||||||
|
|
@ -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. Planungs‑Override: keine Roh‑JSON‑Sektion.
|
||||||
|
* @param {boolean} [props.allowExpertJson] — wenn true und nicht plannerMode: Roh‑JSON (Support).
|
||||||
|
* @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] — für Slot‑Felder 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 Methoden‑Archetyp — besonders beim <strong>freien Methodenblock</strong> stehen alle
|
||||||
|
typischen Stations‑Zeiten 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')}>
|
||||||
|
Runden‑Pause ≈ Arbeit
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_two_thirds_work')}>
|
||||||
|
Runden‑Pause ≈ ⅔ 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 Roh‑JSON 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 Zeit‑Eckdaten 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‑/
|
||||||
}
|
Rotations‑Sekunden.
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 || {},
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 Profil‑Objekt.
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user