shinkan-jinkendo/frontend/src/utils/combinationMethodProfileUi.js
Lars cf9932990e
All checks were successful
Deploy Development / deploy (push) Successful in 42s
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
feat(version): bump to 0.8.109 and enhance combination exercise features
- Updated app version to 0.8.109, reflecting recent improvements in combination exercise handling.
- Introduced `rep_series_count` for slot profiles, allowing for multiple series in `rep` and `manual` modes, enhancing flexibility in exercise configurations.
- Updated the CombinationMethodProfileEditor and CombinationCoachSlots components to support and display the new series count feature.
- Enhanced ExerciseFormPage to manage series count and intra-series pauses effectively, improving user experience.
- Documented changes in the changelog for better tracking of feature enhancements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 08:58:41 +02:00

403 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 VorabErklä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 ProfilObjekt.
*/
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 }