shinkan-jinkendo/frontend/src/utils/combinationMethodProfileUi.js

333 lines
9.8 KiB
JavaScript
Raw Normal View History

/**
* 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
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)
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 }