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 1m4s
- Updated app version to 0.8.102, reflecting recent enhancements in combination exercises. - Introduced structured method profiles for combination exercises, allowing for detailed planning and coaching support. - Enhanced frontend components to display method profiles in the Exercise and Combination Coach views. - Updated documentation to include new specifications and implementation details for method archetypes and profiles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.6 KiB
JavaScript
176 lines
6.6 KiB
JavaScript
import React, { useMemo, useState } from 'react'
|
||
import { archetypeCoachHint, combinationArchetypeLabel } from '../constants/combinationArchetypes'
|
||
import {
|
||
METHOD_PROFILE_GUI_FIELDS,
|
||
parseProfileJson,
|
||
setFullProfileRawJson,
|
||
updateProfileGuided,
|
||
} 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)
|
||
}
|
||
|
||
/**
|
||
* Kombination: geführtes Ablaufprofil + optionales Roh-JSON.
|
||
*/
|
||
export default function CombinationMethodProfileEditor({
|
||
methodArchetype,
|
||
methodProfileJson,
|
||
onChangeMethodProfileJson,
|
||
}) {
|
||
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 profileObj = parseState.ok ? parseState.obj : {}
|
||
|
||
const applyGuided = (key, value, kind) => {
|
||
if (kind === 'bool') {
|
||
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
|
||
if (!res.ok) return
|
||
onChangeMethodProfileJson(res.json)
|
||
return
|
||
}
|
||
if (value === '' || value === undefined || value === null) {
|
||
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
|
||
if (!res.ok) return
|
||
onChangeMethodProfileJson(res.json)
|
||
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)
|
||
}
|
||
|
||
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 || ''))
|
||
}
|
||
|
||
return (
|
||
<div style={{ marginTop: '10px' }}>
|
||
{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 Archetyp, um das Ablaufprofil strukturiert zu erfassen — oder nur das JSON weiter unten.
|
||
</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' }}>
|
||
{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 ? (
|
||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px' }}>
|
||
Für diesen Archetyp gibt es keine vorgegebenen Profilfelder — nutze die Freitexte der Kombination oder Roh‑JSON bei Bedarf.
|
||
</p>
|
||
) : null}
|
||
|
||
<details
|
||
style={{
|
||
marginTop: '4px',
|
||
borderRadius: '8px',
|
||
border: '1px solid var(--border)',
|
||
padding: '8px 10px',
|
||
background: 'var(--surface)',
|
||
}}
|
||
onToggle={(ev) => {
|
||
if (ev.target.open) openAdvanced()
|
||
}}
|
||
>
|
||
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>Erweitert: JSON direkt bearbeiten</summary>
|
||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 6px', lineHeight: 1.4 }}>
|
||
Zusätzliche Schlüssel (Piloten). Geführte Felder können dieselben Schlüssel beim nächsten Speichern überlagern.
|
||
</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>
|
||
</div>
|
||
)
|
||
}
|