/** * Geführtes method_profile für Kombinationsübungen — Felder nach method_archetype. * Unbekannte JSON-Schlüssel bleiben beim Zusammenführen erhalten (Erweiterbarkeit). */ 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' } /** UI: Serien-Anzahl aus Formularfeld; leer/ungültig ⇒ 1 (eine Serie). */ export function parseComboRepSeriesCountUi(raw) { if (raw === '' || raw === undefined || raw === null) return 1 const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : parseInt(String(raw).trim(), 10) if (!Number.isFinite(n) || n < 1) return 1 return n } function parseProfileJson(raw) { if (typeof raw !== 'string' || !raw.trim()) return { ok: true, obj: {} } try { const p = JSON.parse(raw) if (!p || typeof p !== 'object' || Array.isArray(p)) { return { ok: false, error: 'Ablaufprofil muss ein JSON-Objekt sein.' } } return { ok: true, obj: { ...p } } } catch { return { ok: false, error: 'Ablaufprofil (JSON): Syntax ungültig.' } } } /** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({ sequence_linear: [ { key: 'rounds', kind: 'int', label: 'Anzahl Gesamtdurchläufe (komplette Sequenz, alle Stationen nacheinander)', min: 1, max: 999, }, { key: 'hint_step_duration_sec', kind: 'int', label: 'Orientierung: Sekunden je Station/Schritt (optional)', min: 5, max: INT_MAX, }, { key: 'block_intro_sec', kind: 'int', label: 'Einführung / Demon am Block Gesamt (Sek., optional)', min: 0, max: INT_MAX, }, ], circuit_rotate_time: [ { key: 'rounds', kind: 'int', label: 'Anzahl Gesamtdurchläufe (jede Station pro Sportler mehrfach beim Umlauf, z. B. 4 Stationen × 2 = zwei komplette Runden)', min: 1, max: 999, }, { key: 'work_seconds', kind: 'int', label: 'Arbeitszeit pro Station (Sek.)', min: 5, max: INT_MAX, }, { key: 'transition_seconds', kind: 'int', label: 'Wechsel / Rotation (Sek., optional)', min: 0, max: INT_MAX, }, { key: 'rest_seconds', kind: 'int', label: 'Pause zwischen Runden oder Stationen-Folgen (Sek., optional)', min: 0, max: INT_MAX, }, ], circuit_all_parallel: [ { key: 'rounds', kind: 'int', label: 'Anzahl Durchläufe (wenn alle parallel dieselbe Rundenlogik haben, optional)', min: 1, max: 999, }, { key: 'explain_before_seconds', kind: 'int', label: 'Zeitfenster Vorab‑Erklärung aller Stationen (Sek., optional)', min: 0, max: INT_MAX, }, { key: 'simultaneous_start', kind: 'bool', label: 'Alle Stationen starten zusammen nach Erklärung', }, ], station_parcour: [ { key: 'rounds', kind: 'int', label: 'Anzahl Durchläufe des Parcours (Start Station 1, alle Bahnpunkte, Wiederholung bei Bedarf)', min: 1, max: 999, }, { key: 'allow_free_visit_order', kind: 'bool', label: 'Reihenfolge der Besuche frei (Parcours / Abhaken-Logik später im Coach)', }, ], pair_superset: [ { key: 'switch_seconds', kind: 'int', label: 'Orientierung: Wechselpause A↔B (Sek., optional)', min: 0, max: INT_MAX, }, { key: 'work_seconds_per_side', kind: 'int', label: 'Arbeit pro Rolle oder Seite (Sek., optional)', min: 5, max: INT_MAX, }, ], time_domain_interval: [ { key: 'work_seconds', kind: 'int', label: 'Intervall: Belastungszeit (Sek.)', min: 5, max: INT_MAX, }, { key: 'rest_seconds', kind: 'int', label: 'Intervall: Erholungszeit (Sek., optional)', min: 0, max: INT_MAX, }, { key: 'interval_rounds', kind: 'int', label: 'Anzahl Wiederholungen der Intervalldomäne (komplette Zyklen Arbeit/Pause)', min: 1, max: 999, }, ], free_method_block: [], }) /** * Aktualisiert method_profile unter Beibehaltung nicht-GUI Schlüssel. */ export function updateProfileGuided(archetype, rawJson, key, parsedValue, kind) { const arch = typeof archetype === 'string' ? archetype.trim() : '' const parsed = parseProfileJson(rawJson) if (!parsed.ok) return parsed const next = { ...parsed.obj } if (kind === 'bool') { if (parsedValue) next[key] = true else delete next[key] } else if (kind === 'int') { if (parsedValue === null || parsedValue === undefined || parsedValue === '') { delete next[key] } else { const n = typeof parsedValue === 'number' ? parsedValue : parseInt(String(parsedValue), 10) if (!Number.isFinite(n)) delete next[key] else next[key] = n } } const outJson = JSON.stringify(next) return { ok: true, obj: next, json: outJson === '{}' ? '{}' : outJson } } export function setFullProfileRawJson(rawEditable) { const parsed = parseProfileJson(rawEditable) if (!parsed.ok) return parsed const j = JSON.stringify(parsed.obj) 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) 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), rep_series_count: normalizeOptionalPositiveInt(row.rep_series_count), intra_rep_rest_sec: normalizeOptionalNonNegInt(row.intra_rep_rest_sec), transition_after_sec: normalizeOptionalNonNegInt(row.transition_after_sec), } return out }).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', 'rep_series_count', 'intra_rep_rest_sec', '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 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' || field === 'rep_series_count') { 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 keep = slotProfileRowShouldKeep(nextRow) if (found >= 0) { if (!keep) { arr = arr.filter((_, i) => i !== found) } else { arr[found] = nextRow } } else if (keep) { arr.push(nextRow) } writeSlotProfilesV1Arr(profileDraft, 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 }