/** * 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' } /** * Modus aus Roh-Zeile (Legacy ohne advance_mode; oder nur Ziel‑Wdh. ohne Sekunden). */ export function inferAdvanceModeFromStoredSlotRow(row) { if (!row || typeof row !== 'object') return 'timed' const explicitRaw = row.advance_mode if (explicitRaw !== undefined && explicitRaw !== null && String(explicitRaw).trim() !== '') { const e = normalizeAdvanceMode(explicitRaw) return e === 'rep' || e === 'manual' ? e : 'timed' } const load = row.load_sec !== undefined && row.load_sec !== null && row.load_sec !== '' ? normalizeOptionalNonNegInt(row.load_sec) : undefined const reps = row.consecutive_reps !== undefined && row.consecutive_reps !== null && row.consecutive_reps !== '' ? normalizeOptionalPositiveInt(row.consecutive_reps) : undefined if (load != null && reps == null) return 'timed' if (reps != null && load == null) return 'rep' if (load != null && reps != null) return 'timed' 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: [], }) function shortenComboGuiCaption(label) { const t = (label || '').trim() if (!t) return '' const cut = t.split('(')[0].trim() return cut.length > 52 ? `${cut.slice(0, 50)}…` : cut } /** * Globale Archetyp-Felder aus method_profile für Lesetext (Vorschau, Druck). * Ignoriert slot_profiles_v1 (kommt separat je Station). */ export function describeGlobalComboProfile(archetypeKey, profileObj) { const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : '' if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return [] const defs = METHOD_PROFILE_GUI_FIELDS[arch] || [] const rows = [] for (const def of defs) { const val = profileObj[def.key] if (val === undefined || val === null || val === '') continue if (def.kind === 'bool') { const on = val === true || val === 'true' || val === 1 || val === '1' rows.push({ key: def.key, caption: shortenComboGuiCaption(def.label), detailLabel: def.label, value: on ? 'Ja' : 'Nein', }) } else { rows.push({ key: def.key, caption: shortenComboGuiCaption(def.label), detailLabel: def.label, value: String(val), }) } } return rows } /** * 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 inferredMode = inferAdvanceModeFromStoredSlotRow(row) const out = { slot_index: Number.isFinite(si) ? si : 0, advance_mode: inferredMode, 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) } /** Kurztext für Listen/strip (Coach „Plan:“ — gleiche Logik). */ export function summarizeSlotProfileBrief(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`) if (r.consecutive_reps != null) bits.push(`${r.consecutive_reps}× Wdh. ohne Wechsel zur nächsten Station`) } else if (adv === 'rep') { bits.push('Ziel‑Wdh.') const nSer = r.rep_series_count != null && r.rep_series_count >= 1 ? r.rep_series_count : 1 if (r.consecutive_reps != null) { if (nSer >= 2) bits.push(`${nSer} Serien à ${r.consecutive_reps}×`) else bits.push(`${r.consecutive_reps}×`) } } else { bits.push('Coach') const nSerMan = r.rep_series_count != null && r.rep_series_count >= 2 ? r.rep_series_count : 1 if (r.consecutive_reps != null) { if (nSerMan >= 2) bits.push(`${nSerMan} Serien à Richtwert ${r.consecutive_reps}×`) else bits.push(`Richtwert ${r.consecutive_reps}×`) } else if (r.rep_series_count != null && r.rep_series_count >= 2) { bits.push(`${r.rep_series_count} Serien`) } } if (r.intra_rep_rest_sec != null) { if (adv === 'timed') bits.push(`Pause zw. Wdh. ${r.intra_rep_rest_sec}s (nicht Stationswechsel)`) else if (adv === 'rep' && r.rep_series_count != null && r.rep_series_count >= 2) bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`) else if ( adv === 'manual' && r.rep_series_count != null && r.rep_series_count >= 2 ) { bits.push(`Pause zw. Serien ${r.intra_rep_rest_sec}s`) } } if (r.transition_after_sec != null) bits.push(`Wechsel ${r.transition_after_sec}s`) return bits.join(' · ') } /** * Kompakte Stations-Belastung für die Plan-Klammer (links): Sekunden oder Wdh., nicht Slot-ID. */ export function stationPrimaryLoadLabel(slotRow) { if (!slotRow || typeof slotRow !== 'object') return null const adv = slotRow.advance_mode || 'timed' if (adv === 'timed') { if (slotRow.load_sec != null) return `${slotRow.load_sec}s` if (slotRow.consecutive_reps != null) return `${slotRow.consecutive_reps}×` return null } if (adv === 'rep') { if (slotRow.consecutive_reps != null) return `${slotRow.consecutive_reps}×` return null } if (adv === 'manual') { if (slotRow.consecutive_reps != null) return `~${slotRow.consecutive_reps}×` return null } return null } function globalTimingHintsForArchetype(arch, mp) { if (!mp || typeof mp !== 'object' || Array.isArray(mp)) return [] const bits = [] switch (arch) { case 'circuit_rotate_time': if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s Arbeit je Station`) if (mp.transition_seconds != null && mp.transition_seconds !== '') bits.push(`Rotation ${mp.transition_seconds}s`) if (mp.rest_seconds != null && mp.rest_seconds !== '') bits.push(`Pause ${mp.rest_seconds}s`) if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Umlauf‑Runden`) break case 'sequence_linear': if (mp.hint_step_duration_sec != null && mp.hint_step_duration_sec !== '') bits.push(`~${mp.hint_step_duration_sec}s je Station`) if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Sequenz‑Durchläufe`) if (mp.block_intro_sec != null && mp.block_intro_sec !== '') bits.push(`Block‑Intro ${mp.block_intro_sec}s`) break case 'time_domain_interval': if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s Intervall‑Arbeit`) if (mp.rest_seconds != null && mp.rest_seconds !== '') bits.push(`${mp.rest_seconds}s Erholung`) if (mp.interval_rounds != null && mp.interval_rounds !== '') bits.push(`${mp.interval_rounds} Intervall‑Zyklen`) break case 'pair_superset': if (mp.work_seconds_per_side != null && mp.work_seconds_per_side !== '') bits.push(`${mp.work_seconds_per_side}s Arbeit`) if (mp.switch_seconds != null && mp.switch_seconds !== '') bits.push(`Wechsel ${mp.switch_seconds}s`) break case 'station_parcour': if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Parcours‑Runden`) break case 'circuit_all_parallel': if (mp.rounds != null && mp.rounds !== '') bits.push(`${mp.rounds} Runden`) if (mp.explain_before_seconds != null && mp.explain_before_seconds !== '') bits.push(`Erklärung ${mp.explain_before_seconds}s`) break default: break } return bits } function isWeakSlotTimingSummary(txt) { if (!txt || typeof txt !== 'string') return true const t = txt.trim() return t === 'Zeit' || t === 'Coach' || t === 'Ziel‑Wdh.' } /** * Stationszeile für Lesetext: Slot‑Zeiten + bei Bedarf globale Eckdaten (Zirkel‑Sekunden, Runden …). */ export function effectiveStationTimingSummary(archetypeKey, profileObj, slotRow) { const arch = typeof archetypeKey === 'string' ? archetypeKey.trim() : '' const mp = profileObj && typeof profileObj === 'object' && !Array.isArray(profileObj) ? profileObj : {} const slotTxt = summarizeSlotProfileBrief(slotRow) const hints = globalTimingHintsForArchetype(arch, mp) const hintStr = hints.join(' · ') if (!isWeakSlotTimingSummary(slotTxt)) { const extras = [] for (const h of hints) { if ( /Runden|Durchläufe|Zyklen|Umlauf/i.test(h) && slotTxt && !/Runden|Serien|×|\d+s Arbeit|\d+s Erholung|\d+s Intervall/i.test(slotTxt) ) { extras.push(h) } } return extras.length ? `${slotTxt} · ${extras.join(' · ')}` : slotTxt } if (hintStr) return hintStr if (slotTxt) return slotTxt return null } 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 }