shinkan-jinkendo/frontend/src/components/CombinationMethodProfileEditor.jsx
Lars d3ddc52118
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s
feat(combo-planning): enhance combination profile handling and UI improvements
- Updated CombinationCoachSlots to integrate a new function for formatting inline profile values, improving data display consistency.
- Refactored CombinationMethodProfileEditor to streamline slot index handling and enhance title clarity.
- Improved CombinationPlanBracket by removing unnecessary elements for a cleaner UI.
- Enhanced ExerciseFullContent to support additional catalog method profile snapshots, improving exercise detail accuracy.
- Updated CSS for combo plan brackets to enhance visual presentation and alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:42:01 +02:00

488 lines
20 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.

import React, { useMemo, useState } from 'react'
import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
setFullProfileRawJson,
updateProfileGuided,
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
patchSlotAdvanceMode,
normalizeAdvanceMode,
parseComboRepSeriesCountUi,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
function clampInt(n, min, max) {
if (!Number.isFinite(n)) return null
let x = n
if (typeof min === 'number' && x < min) x = min
if (typeof max === 'number' && x > max) x = max
return Math.round(x)
}
/** Archetypen mit klar bezifferbarer Stationslogik · alle mit Slot-Liste sinnvoll */
const ARCHETYPES_WITH_SLOT_TIMING = new Set([
'circuit_rotate_time',
'sequence_linear',
'station_parcour',
'time_domain_interval',
'circuit_all_parallel',
'pair_superset',
'free_method_block',
])
/**
* Kombination: geführtes method_profile (+ optional Stationszeilen, ohne JSON für Trainer).
*
* @param {boolean} [props.plannerMode] — z. B. PlanungsOverride: keine RohJSONSektion.
* @param {boolean} [props.allowExpertJson] — wenn true und nicht plannerMode: RohJSON (Support).
* @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] — für SlotFelder aus der Übung
*/
export default function CombinationMethodProfileEditor({
methodArchetype,
methodProfileJson,
onChangeMethodProfileJson,
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
omitPerSlotTiming = false,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
const fields = Array.isArray(fieldsGui) ? fieldsGui : null
const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
const [rawOpenError, setRawOpenError] = useState(null)
const [rawDraft, setRawDraft] = useState(null)
const [presetHint, setPresetHint] = useState(null)
const profileObj = parseState.ok ? parseState.obj : {}
const outlineSorted = useMemo(() => {
if (!comboSlotsOutline || !Array.isArray(comboSlotsOutline) || comboSlotsOutline.length === 0) return []
return sortCombinationSlotsForDisplay(comboSlotsOutline)
}, [comboSlotsOutline])
const showSlotTiming =
!omitPerSlotTiming && ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
const lookupSlotTiming = (slotIndex) =>
slotRowsModel.find((r) => Number(r.slot_index) === Number(slotIndex)) || {}
const applyGuided = (key, value, kind) => {
if (kind === 'bool') {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
if (value === '' || value === undefined || value === null) {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
const num = typeof value === 'number' ? value : parseInt(String(value), 10)
if (!Number.isFinite(num)) return
const def = METHOD_PROFILE_GUI_FIELDS[arch]?.find((f) => f.key === key && f.kind === 'int')
const c = clampInt(num, def?.min, def?.max)
if (c == null) return
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
}
const onSlotField = (slotIx, field, rawStr) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotTimingField(d, slotIx, field, rawStr)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const onSlotAdvanceChange = (slotIx, rawMode) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotAdvanceMode(d, slotIx, rawMode)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const onSlotRepSeriesCount = (slotIx, rawStr) => {
const trimmed = String(rawStr ?? '').trim()
const effective = trimmed === '' ? '1' : trimmed
const pn = parseInt(effective, 10)
const clearIntra = !Number.isFinite(pn) || pn < 2
const patched = patchMethodProfile(methodProfileJson || '{}', (d) => {
patchSlotTimingField(d, slotIx, 'rep_series_count', effective)
if (clearIntra) patchSlotTimingField(d, slotIx, 'intra_rep_rest_sec', '')
})
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const runIntervalPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyIntervalDomainQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const archeLabel = arch ? combinationArchetypeLabel(arch) : null
const openAdvanced = () => {
setRawOpenError(null)
const p = parseProfileJson(methodProfileJson || '{}')
setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
}
const showExpertSection = allowExpertJson && !plannerMode
return (
<div style={{ marginTop: '10px' }}>
{presetHint ? (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '0 0 8px', lineHeight: 1.4 }}>{presetHint}</p>
) : null}
{arch ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.48, margin: '0 0 12px' }}>
<strong style={{ color: 'var(--text1)' }}>
Coach &amp; Planung:{' '}
{archeLabel && archeLabel !== arch ? `${archeLabel} · ` : ''}
</strong>
{archetypeCoachHint(arch)}
</p>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 12px' }}>
Wähle einen MethodenArchetyp besonders beim <strong>freien Methodenblock</strong> stehen alle
typischen StationsZeiten zur Verfügung. Ohne Archetyp keine geführten Eingaben.
</p>
)}
{!parseState.ok ? (
<p style={{ color: 'var(--danger)', fontSize: '13px', marginBottom: '10px' }}>{parseState.error}</p>
) : null}
{fields && fields.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{arch === 'circuit_rotate_time' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('transition_equals_work')}>
Wechsel Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_equals_work')}>
RundenPause Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_two_thirds_work')}>
RundenPause Arbeit
</button>
</div>
) : null}
{arch === 'time_domain_interval' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_equals_work')}>
Erholung = Belastung
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_two_thirds_work')}>
Erholung Belastung
</button>
</div>
) : null}
{fields.map((def) => {
if (def.kind === 'bool') {
const ck = !!profileObj[def.key]
return (
<label
key={def.key}
style={{ display: 'flex', gap: '8px', alignItems: 'center', cursor: 'pointer', fontSize: '0.9rem' }}
>
<input type="checkbox" checked={ck} onChange={(e) => applyGuided(def.key, e.target.checked, 'bool')} />
<span>{def.label}</span>
</label>
)
}
const v = profileObj[def.key]
const str =
v === undefined || v === null ? '' : typeof v === 'number' && Number.isFinite(v) ? String(v) : String(v)
return (
<div className="form-row" key={def.key}>
<label className="form-label" style={{ fontSize: '12px' }}>
{def.label}
</label>
<input
type="number"
className="form-input"
min={def.min}
max={def.max}
value={str}
placeholder="optional"
onChange={(e) => {
const t = e.target.value
if (t.trim() === '') applyGuided(def.key, '', 'int')
else applyGuided(def.key, parseInt(t, 10), 'int')
}}
/>
</div>
)
})}
</div>
) : arch && fields && fields.length === 0 ? (
<div style={{ marginBottom: '12px' }}>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
Dieser Archetyp ist für <strong>maximal flexible</strong> Stationsblöcke gedacht die ZeitEckdaten sind
unten je Station möglich. Freitexte der Kombination beschreiben alles Organisatorische, was nicht in
Sekunden gefasst wird.
</p>
</div>
) : null}
{showSlotTiming ? (
<div
style={{
marginTop: '6px',
marginBottom: '14px',
padding: '12px 14px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
Pro Station / Slot Steuerung &amp; Sekunden
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
<strong>Steuerung:</strong> zeitlich (ArbeitsCountdown), Zielzahl Wiederholungen oder Coachgeführt ohne
Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar.&nbsp;Felder können leer bleiben z.&nbsp;B. nutzt der
Zirkel erst die globalen ArbeitSekunden.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{outlineSorted.map((slot, ordIdx) => {
const siRaw = slot.slot_index
const si =
siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
const ttl = ((slot.title || '').trim() || `Station ${ordIdx + 1}`).trim()
const slotAdv = normalizeAdvanceMode(row.advance_mode)
const serieLabel =
slotAdv === 'timed' ? 'Wdh. ohne Wechsel' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const showInterSeriesPause = showMultiSeries && serienUi >= 2
const intraLabel = slotAdv === 'timed' ? 'Pause zwischen Wdh.' : 'Pause zw. Serien'
return (
<div
key={`slot-timing-${si}`}
style={{
padding: '10px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div style={{ marginBottom: '10px', fontWeight: 700, color: 'var(--text1)', fontSize: '0.9rem' }}>{ttl}</div>
<div className="form-row" style={{ marginBottom: '10px', maxWidth: '22rem' }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Steuerung
</label>
<select
className="form-input"
style={{ fontSize: '0.8125rem' }}
value={slotAdv}
onChange={(e) => onSlotAdvanceChange(si, e.target.value)}
>
<option value="timed">Zeit (Arbeit in Sekunden)</option>
<option value="rep">Wiederholungen (Ziel)</option>
<option value="manual">Coach (ohne Arbeitsuhr)</option>
</select>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
{slotAdv === 'timed' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.load_sec != null ? String(row.load_sec) : ''}
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
{serieLabel}
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder={slotAdv === 'manual' ? 'optional' : 'oft 1'}
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
/>
</div>
{showMultiSeries ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label
className="form-label"
style={{ fontSize: '11px' }}
title="Wie oft die Wdh.-Zahl pro Serie hintereinander (mit Pause zwischen den Serien)?"
>
Serien
</label>
<input
type="number"
min={slotAdv === 'rep' ? 1 : undefined}
className="form-input"
placeholder="1"
value={String(parseComboRepSeriesCountUi(row.rep_series_count))}
onChange={(e) => onSlotRepSeriesCount(si, e.target.value)}
/>
</div>
) : null}
{slotAdv === 'timed' || showInterSeriesPause ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
{intraLabel} (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.intra_rep_rest_sec != null ? String(row.intra_rep_rest_sec) : ''}
onChange={(e) => onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
/>
</div>
) : null}
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wechsel (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder=""
value={row.transition_after_sec != null ? String(row.transition_after_sec) : ''}
onChange={(e) => onSlotField(si, 'transition_after_sec', e.target.value)}
/>
</div>
</div>
{showMultiSeries && serienUi < 2 ? (
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.38 }}>
<strong>Wechsel (s)</strong> zur <strong>nächsten Station</strong>. Pause zw. Serien nur ab&nbsp;2
Serien.
</p>
) : null}
</div>
)
})}
</div>
</div>
) : null}
{showExpertSection ? (
<details
style={{
marginTop: '4px',
borderRadius: '8px',
border: '1px solid var(--border)',
padding: '8px 10px',
background: 'var(--surface2)',
}}
onToggle={(ev) => {
if (ev.target.open) openAdvanced()
}}
>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>
Support / Entwicklung: Rohdaten (JSON)
</summary>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 6px', lineHeight: 1.4 }}>
Für Migrationen und Sonderfälle. Geführte Felder setzen weiterhin gültige Standardschlüssel.
</p>
<textarea
className="form-input"
rows={8}
style={{ fontFamily: 'Consolas,monospace', fontSize: '12px' }}
value={rawDraft != null ? rawDraft : methodProfileJson || '{}'}
onChange={(e) => {
setRawDraft(e.target.value)
setRawOpenError(null)
}}
spellCheck={false}
onBlur={() => {
const src = rawDraft != null ? rawDraft : methodProfileJson
const res = setFullProfileRawJson(src || '{}')
if (!res.ok) {
setRawOpenError(res.error)
return
}
setRawOpenError(null)
setRawDraft(null)
onChangeMethodProfileJson(res.json)
}}
/>
{rawOpenError ? (
<p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p>
) : null}
</details>
) : null}
</div>
)
}