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
- 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>
488 lines
20 KiB
JavaScript
488 lines
20 KiB
JavaScript
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. Planungs‑Override: keine Roh‑JSON‑Sektion.
|
||
* @param {boolean} [props.allowExpertJson] — wenn true und nicht plannerMode: Roh‑JSON (Support).
|
||
* @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] — für Slot‑Felder 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 & Planung:{' '}
|
||
{archeLabel && archeLabel !== arch ? `${archeLabel} · ` : ''}
|
||
</strong>
|
||
{archetypeCoachHint(arch)}
|
||
</p>
|
||
) : (
|
||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 12px' }}>
|
||
Wähle einen Methoden‑Archetyp — besonders beim <strong>freien Methodenblock</strong> stehen alle
|
||
typischen Stations‑Zeiten 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')}>
|
||
Runden‑Pause ≈ Arbeit
|
||
</button>
|
||
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_two_thirds_work')}>
|
||
Runden‑Pause ≈ ⅔ 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 Zeit‑Eckdaten 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 & Sekunden
|
||
</div>
|
||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
|
||
<strong>Steuerung:</strong> zeitlich (Arbeits‑Countdown), Zielzahl Wiederholungen oder Coach‑geführt ohne
|
||
Arbeitsuhr. Pausen/Wechsel bleiben unabhängig planbar. Felder können leer bleiben — z. B. nutzt der
|
||
Zirkel erst die globalen Arbeit‑Sekunden.
|
||
</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 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>
|
||
)
|
||
}
|