shinkan-jinkendo/frontend/src/utils/combinationMethodProfileUi.js
Lars 3898e8bc2c
All checks were successful
Deploy Development / deploy (push) Successful in 38s
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 59s
feat(training-planning): enhance planning method profile handling and UI updates
- Integrated PsycopgJson for improved handling of planning method profiles in the backend.
- Updated CombinationPlanBracket to display primary load labels for better clarity in the UI.
- Enhanced TrainingUnitSectionsEditor and utility functions to ensure proper serialization of planning profiles, preventing potential errors during API interactions.
- Improved CSS for combo plan brackets to enhance visual alignment and presentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:28:37 +02:00

580 lines
19 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: [],
})
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 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)
}
/** 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`)
} else if (adv === 'rep') {
bits.push('ZielWdh.')
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 ${r.intra_rep_rest_sec}s`)
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`
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} UmlaufRunden`)
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} SequenzDurchläufe`)
if (mp.block_intro_sec != null && mp.block_intro_sec !== '') bits.push(`BlockIntro ${mp.block_intro_sec}s`)
break
case 'time_domain_interval':
if (mp.work_seconds != null && mp.work_seconds !== '') bits.push(`${mp.work_seconds}s IntervallArbeit`)
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} IntervallZyklen`)
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} ParcoursRunden`)
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 === 'ZielWdh.'
}
/**
* Stationszeile für Lesetext: SlotZeiten + bei Bedarf globale Eckdaten (ZirkelSekunden, 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 }