From 38d84ecdf6684cb0a813c53b203dde1c02b54cf3 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 08:19:46 +0200 Subject: [PATCH] feat(version): bump to 0.8.106 and enhance combination exercise features - Updated app version to 0.8.106, reflecting recent improvements in combination exercise handling. - Introduced `advance_mode` for slot profiles, allowing for flexible timing options (timed, repetitions, manual) in the CombinationMethodProfileEditor. - Enhanced the CombinationCoachSlots component to display timing summaries based on the selected advance mode. - Updated ExerciseFormPage to manage combination slots with new validation and user feedback for exercise selection. - Documented changes in the changelog for better tracking of feature enhancements. Co-Authored-By: Claude Sonnet 4.6 --- ...e Kombinationsuebungen Spezifikation V2.md | 2 + backend/version.py | 11 +- .../src/components/CombinationCoachSlots.jsx | 62 ++++++- .../CombinationMethodProfileEditor.jsx | 73 +++++--- frontend/src/pages/ExerciseFormPage.jsx | 158 +++++++++++++----- frontend/src/utils/api.js | 5 +- .../src/utils/combinationMethodProfileUi.js | 75 ++++++++- 7 files changed, 306 insertions(+), 80 deletions(-) diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md index dd18836..b42be9b 100644 --- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md +++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md @@ -423,6 +423,8 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz** **Geplantes kanonisches Zeitmodell:** Globale Eckwerte (z. B. Anzahl der Durchläufe / Runden, optionale Gesamt-/Einführungszeit als Ziel oder Rechenhilfe) und **pro Platz (Slot)** die Dimensionen „Belastung“, „wie viele gleiche Übung hintereinander“, „kurze Pause dazwischen“, „Übergangszeit zur nächsten Übung/arbeitstation“ — dokumentiert für die technische Angleichung in **`.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md`** (Felder **`slot_profiles_v1`**, `timing_schema`). Archetypen können **Strukturen und typische Schnellwahlen** vorgeben (z. B. Zirkel: Relation Belastungszeit = Übergangszeit oder Erholungsanteil ≈ 2/3 der Belastung); der Archetyp **Freier Methodenblock** bildet den **Maximal‑Pfad** ohne stärkere stille Annahmen. **Pyramidale/abhängige Pausen** (Pause abhängig von vorheriger Belastung) sind **nicht Teil des aktuellen Umsetzungspfads**, können später als eigener Untertyp ergänzt werden. +**Fortschritt pro Slot (Stand 0.8.106):** optional **`advance_mode`** je Eintrag in **`slot_profiles_v1`**: `timed` — Standard (`load_sec` = geplante Arbeitsdauer für Timer im Coach; fehlende Angabe entspricht `timed` ohne Sekundenfeld), **`rep`** — mengenorientiert (Zielzahl über **`consecutive_reps`**; keine verbindliche Arbeitsuhr), **`manual`** — coachgeführt (Fortschritt bewusst per Schritt später im Coach, optional Richtwert über **`consecutive_reps`**). **`intra_rep_rest_sec`** und **`transition_after_sec`** bleiben unabhängig vom Modus nutzbar. **`load_sec`** wird nur im Modus `timed` persistiert. + ### 6.4 Slot- und Pool-Logik Slots können fest oder variabel sein. diff --git a/backend/version.py b/backend/version.py index f937795..02c0f10 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.105" +APP_VERSION = "0.8.106" 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.25.0", # Kombi: slot_profiles_v1 + Schnellwahl Belastung/Erholung; keine Nutzer‑JSON‑Pflicht; Übungsform Stationen vor Ablaufprofil + "exercises": "2.26.0", # Kombi: advance_mode je Station (slot_profiles_v1: timed|rep|manual); Payload/Coach-Lesetext "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,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.106", + "date": "2026-05-12", + "changes": [ + "Kombinationsübung: Stationssteuerung `advance_mode` in `slot_profiles_v1` (zeitlich / Ziel‑Wiederholungen / Coach ohne Arbeitsuhr); Übungsformular + Planungs‑Profil‑Editor; API‑Payload verwirft Arbeit‑Sekunden außer bei Zeitmodus; Coach zeigt verkürzte Planzeile je Station.", + ], + }, { "version": "0.8.105", "date": "2026-05-12", diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx index 3645836..1dfee64 100644 --- a/frontend/src/components/CombinationCoachSlots.jsx +++ b/frontend/src/components/CombinationCoachSlots.jsx @@ -10,9 +10,28 @@ import { combinationArchetypeLabel, sortCombinationSlotsForDisplay, } from '../constants/combinationArchetypes' +import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi' + +function summarizeSlotProfilesRow(r) { + if (!r) return null + const adv = r.advance_mode || 'timed' + const bits = [] + if (adv === 'timed') { + bits.push('Zeit') + if (r.load_sec != null) bits.push(`${r.load_sec}s Arbeit`) + } else if (adv === 'rep') { + bits.push('Ziel‑Wdh.') + if (r.consecutive_reps != null) bits.push(`${r.consecutive_reps}×`) + } else { + bits.push('Coach') + if (r.consecutive_reps != null) bits.push(`Richtwert ${r.consecutive_reps}×`) + } + if (r.intra_rep_rest_sec != null) bits.push(`Pause ${r.intra_rep_rest_sec}s`) + if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`) + return bits.join(' · ') +} export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) { - const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots]) const candidateIds = useMemo(() => { const set = new Set() @@ -76,6 +95,23 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp const archeKey = methodArchetype != null ? String(methodArchetype).trim() : '' const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null + const slotTimingByIx = useMemo(() => { + if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return new Map() + const rows = readSlotProfilesV1(methodProfile) + const m = new Map() + for (const r of rows) { + m.set(Number(r.slot_index), r) + } + return m + }, [methodProfile]) + + const methodProfileKvSansSlots = useMemo(() => { + if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return [] + return Object.entries(methodProfile) + .filter(([k]) => k !== 'slot_profiles_v1') + .sort(([a], [b]) => a.localeCompare(b, 'de')) + }, [methodProfile]) + return (
Geplantes Ablaufprofil (Katalog) -
- {Object.entries(methodProfile) - .sort(([a], [b]) => a.localeCompare(b, 'de')) - .map(([k, val]) => ( + {methodProfileKvSansSlots.length === 0 ? ( +

+ Nur stationsbezogene Daten (Zeiten/Zähl‑Steuerung) — siehe je Station unter der Überschrift „Plan:“. +

+ ) : ( +
+ {methodProfileKvSansSlots.map(([k, val]) => (
{k}
@@ -143,7 +182,8 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
))} -
+
+ )} ) : null} @@ -162,11 +202,19 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp (candIds.length <= 1 && slot.candidates?.[0]?.title) || `Station ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}` + const ix = slot.slot_index != null ? Number(slot.slot_index) : si + const timingSummary = summarizeSlotProfilesRow(slotTimingByIx.get(ix)) + return (
  • -
    1 ? '6px' : '8px' }}> +
    1 ? '6px' : '8px' }}> {slotTitle}
    + {timingSummary ? ( +

    + Plan: {timingSummary} +

    + ) : null} {candIds.length === 0 ? (

    Keine Übung zugeordnet.

    ) : ( diff --git a/frontend/src/components/CombinationMethodProfileEditor.jsx b/frontend/src/components/CombinationMethodProfileEditor.jsx index cb6f5f4..bdb0243 100644 --- a/frontend/src/components/CombinationMethodProfileEditor.jsx +++ b/frontend/src/components/CombinationMethodProfileEditor.jsx @@ -8,6 +8,8 @@ import { patchMethodProfile, readSlotProfilesV1, patchSlotTimingField, + patchSlotAdvanceMode, + normalizeAdvanceMode, applyCircuitRotateQuickRatio, applyIntervalDomainQuickRatio, } from '../utils/combinationMethodProfileUi' @@ -105,6 +107,15 @@ export default function CombinationMethodProfileEditor({ setPresetHint(null) } + const onSlotAdvanceChange = (slotIx, rawMode) => { + const patched = patchMethodProfile(methodProfileJson || '{}', (d) => + patchSlotAdvanceMode(d, slotIx, rawMode) + ) + if (!patched.ok) return + onChangeMethodProfileJson(patched.json) + setPresetHint(null) + } + const runCircuitPreset = (presetId) => { const r = patchMethodProfile(methodProfileJson || '{}', (draft) => { const pr = applyCircuitRotateQuickRatio(draft, presetId) @@ -265,12 +276,12 @@ export default function CombinationMethodProfileEditor({ }} >
    - Pro Station / Slot (Zeiten in Sekunden) + Pro Station / Slot — Steuerung & Sekunden

    - 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‑/ - Rotations‑Sekunden. + Steuerung: zeitlich (Arbeits‑Countdown), Zielzahl Wiederholungen oder Coach‑geführt ohne + Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar. Felder können leer bleiben — z. B. nutzt der + Zirkel erst die globalen Arbeit‑Sekunden.

    {outlineSorted.map((slot) => { @@ -280,6 +291,9 @@ export default function CombinationMethodProfileEditor({ if (!Number.isFinite(si)) return null const row = lookupSlotTiming(si) const ttl = ((slot.title || '').trim() || `Station ${si}`).trim() + const slotAdv = normalizeAdvanceMode(row.advance_mode) + const serieLabel = + slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert' return (
    {ttl}
    +
    + + +
    + {slotAdv === 'timed' ? ( +
    + + onSlotField(si, 'load_sec', e.target.value)} + /> +
    + ) : null}
    onSlotField(si, 'load_sec', e.target.value)} - /> -
    -
    - - onSlotField(si, 'consecutive_reps', e.target.value)} /> diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index f384086..9fdc60c 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -17,7 +17,7 @@ import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' import { useAuth } from '../context/AuthContext' import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes' -import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi' +import { readSlotProfilesV1, normalizeAdvanceMode } from '../utils/combinationMethodProfileUi' import { GripVertical } from 'lucide-react' const INTENSITY_OPTIONS = [ @@ -38,11 +38,23 @@ const VARIANT_DIFFICULTY = [ /** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */ const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1' +/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */ +const MAX_COMBO_CANDIDATES_PER_STATION = 3 + +const comboTinyNumberInputSx = { + width: '3.5rem', + maxWidth: '100%', + padding: '4px 6px', + fontSize: '0.8125rem', + textAlign: 'center', +} + function emptyComboSlotRow() { return { title: '', candidate_exercise_ids: [], exercise_title_by_id: {}, + advance_mode: 'timed', load_sec: '', consecutive_reps: '', intra_rep_rest_sec: '', @@ -75,6 +87,7 @@ function comboSlotsFromDetail(exercise) { title: s.title != null ? String(s.title) : '', candidate_exercise_ids: cands, exercise_title_by_id: {}, + advance_mode: normalizeAdvanceMode(st.advance_mode), load_sec: st.load_sec != null ? String(st.load_sec) : '', consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '', intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '', @@ -651,22 +664,38 @@ function ExerciseFormPage() { const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => { if (!Array.isArray(pickedList) || !pickedList.length) return + const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow() + const existingIds = Array.isArray(rowNow.candidate_exercise_ids) + ? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n)) + : [] + const ordered = [...existingIds] + pickedList.forEach((ex) => { + if (ex?.id == null) return + const id = Number(ex.id) + if (!Number.isFinite(id)) return + if (!ordered.includes(id)) ordered.push(id) + }) + let nextIds = ordered + if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) { + window.alert( + `Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`, + ) + nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION) + } setFormDirty(true) setFormData((prev) => { const rows = [...(prev.combination_slots || [])] const row = rows[slotIdx] || emptyComboSlotRow() - const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n))) const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {} pickedList.forEach((ex) => { if (ex && ex.id != null) { const id = Number(ex.id) - nextSet.add(id) const t = (ex.title || '').trim() if (t) labels[id] = t } }) - rows[slotIdx] = { ...row, candidate_exercise_ids: [...nextSet], exercise_title_by_id: labels } + rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels } return { ...prev, combination_slots: rows } }) } @@ -1154,16 +1183,22 @@ function ExerciseFormPage() { ) : null}
    - Stationen und Übungs‑Pool + Stationen - Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar + Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station

    - Jede Station: Titel (optional), am Ort wählbare Einzelübungen sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen). + Pro Station oft eine feste Übung; höchstens drei als kleiner Auswahl‑Pool. + Unter Steuerung wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).

    {(formData.combination_slots || []).map((row, idx) => { const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : [] + const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION + const slotAdv = normalizeAdvanceMode(row.advance_mode) + const serieLabel = + slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Ziel‑Wdh.' : 'Richtwert' + const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '–' : '1' const lbl = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? row.exercise_title_by_id @@ -1234,13 +1269,13 @@ function ExerciseFormPage() {
    patchComboSlotRow(idx, { title: e.target.value })} />
    @@ -1248,31 +1283,39 @@ function ExerciseFormPage() {
    - Gewählte Einzelübungen (Pool für diese Station) + Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_STATION}) {candIds.length === 0 ? ( -

    Noch keine Übung gewählt — mindestens eine erforderlich zum Speichern.

    +

    + Mindestens eine Übung — mit „+ Übung“ wählen. +

    ) : (
      {candIds.map((id) => ( @@ -1306,62 +1349,95 @@ function ExerciseFormPage() {
    )}
    +
    + + +
    +

    + {slotAdv === 'timed' + ? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.' + : slotAdv === 'rep' + ? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen; Pause/Wechsel können weiter automatisch unterstützt werden.' + : 'Ohne feste Arbeitsuhr auf dieser Station — Fortschritt im Coach später per Tippschritt; Pause/Wechsel optional weiter mit Sekunden.'} +

    + {slotAdv === 'timed' ? ( +
    + + patchComboSlotRow(idx, { load_sec: e.target.value })} + /> +
    + ) : null}
    -
    -
    - - patchComboSlotRow(idx, { consecutive_reps: e.target.value })} />
    -
    -
    - + = 0) o.load_sec = Math.round(load) + if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load) if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs) if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra) if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran) diff --git a/frontend/src/utils/combinationMethodProfileUi.js b/frontend/src/utils/combinationMethodProfileUi.js index f1643ef..a52122c 100644 --- a/frontend/src/utils/combinationMethodProfileUi.js +++ b/frontend/src/utils/combinationMethodProfileUi.js @@ -5,6 +5,16 @@ const INT_MAX = 86400 +/** Pro Station: zeitlich (Standard), mengenorientiert oder coachgeführt (ohne Arbeits-Countdown). */ +export const SLOT_ADVANCE_MODES = Object.freeze(['timed', 'rep', 'manual']) + +export function normalizeAdvanceMode(v) { + const s = typeof v === 'string' ? v.trim().toLowerCase() : '' + if (s === 'rep' || s === 'reps' || s === 'count') return 'rep' + if (s === 'manual' || s === 'coach' || s === 'coach_led') return 'manual' + return 'timed' +} + function parseProfileJson(raw) { if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} } try { @@ -209,13 +219,16 @@ export function readSlotProfilesV1(profileObj) { return raw.map((row) => { if (!row || typeof row !== 'object') return null const si = Number(row.slot_index) - return { + const mode = normalizeAdvanceMode(row.advance_mode) + const out = { slot_index: Number.isFinite(si) ? si : 0, + advance_mode: mode, 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), } + return out }).filter(Boolean) } @@ -240,6 +253,55 @@ const SLOT_TIMING_FIELDS = /** @type {const} */ ([ 'transition_after_sec', ]) +function slotProfileRowShouldKeep(nextRow) { + if (!nextRow || typeof nextRow !== 'object') return false + const mode = normalizeAdvanceMode(nextRow.advance_mode) + if (mode !== 'timed') return true + return SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null) +} + +function writeSlotProfilesV1Arr(profileDraft, arr) { + const sorted = [...arr].sort((a, b) => Number(a.slot_index) - Number(b.slot_index)) + if (sorted.length === 0) delete profileDraft.slot_profiles_v1 + else profileDraft.slot_profiles_v1 = sorted +} + +/** Steuert Ende der Arbeitsphase: Zeit, Wiederholungsziel oder nur manuell weiter. */ +export function patchSlotAdvanceMode(profileDraft, slotIndex, modeRaw) { + const ix = + typeof slotIndex === 'number' && Number.isFinite(slotIndex) + ? slotIndex + : parseInt(String(slotIndex), 10) + if (!Number.isFinite(ix)) return + + const mode = normalizeAdvanceMode(modeRaw) + let arr = Array.isArray(profileDraft.slot_profiles_v1) ? [...profileDraft.slot_profiles_v1] : [] + const 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 (mode === 'timed') delete nextRow.advance_mode + else { + nextRow.advance_mode = mode + delete nextRow.load_sec + } + + let nextArr + if (!slotProfileRowShouldKeep(nextRow)) { + nextArr = found >= 0 ? arr.filter((_, i) => i !== found) : arr + } else if (found >= 0) { + nextArr = [...arr] + nextArr[found] = nextRow + } else { + nextArr = [...arr, nextRow] + } + + writeSlotProfilesV1Arr(profileDraft, nextArr) +} + /** '', null = Feld entfernen; sonst gültige Zahl setzen */ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) { if (!SLOT_TIMING_FIELDS.includes(field)) return @@ -270,22 +332,19 @@ export function patchSlotTimingField(profileDraft, slotIndex, field, rawInput) { else nextRow[field] = n } - const hasTiming = SLOT_TIMING_FIELDS.some((k) => nextRow[k] !== undefined && nextRow[k] !== null) + const keep = slotProfileRowShouldKeep(nextRow) if (found >= 0) { - if (!hasTiming) { + if (!keep) { arr = arr.filter((_, i) => i !== found) } else { arr[found] = nextRow } - } else if (hasTiming) { + } else if (keep) { 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 + writeSlotProfilesV1Arr(profileDraft, arr) } /** Rotierender Zirkel: typische Ableitungen (setzt Sekunden konkret). */