All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m31s
- Introduced a primary chain selection in the Exercise Progression Graph Panel to streamline exercise path management. - Updated the ProgressionChainEditor to support single path mode, allowing users to manage a single progression path more effectively. - Enhanced the ExerciseProgressionPathBuilder with improved logic for merging graph nodes into path steps and filtering gap offers. - Updated UI elements for better clarity and user experience, including new notifications and styling adjustments. - Incremented application version to reflect these updates.
517 lines
17 KiB
JavaScript
517 lines
17 KiB
JavaScript
/**
|
|
* Bearbeitbare Darstellung linearer Progressions-Reihen im Graphen.
|
|
*/
|
|
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
|
|
function emptyNode() {
|
|
return {
|
|
exerciseId: null,
|
|
exerciseTitle: '',
|
|
variantId: null,
|
|
variantName: null,
|
|
variants: [],
|
|
}
|
|
}
|
|
|
|
function chainToDraft(chain) {
|
|
return {
|
|
key: `chain-${chain.edges[0]?.id ?? 'x'}`,
|
|
edgeIds: chain.edges.map((e) => e.id),
|
|
segmentNotes: chain.edges.map((e) => e.notes || ''),
|
|
nodes: chain.nodes.map((n) => ({
|
|
exerciseId: n.exercise_id,
|
|
exerciseTitle: n.title || `Übung #${n.exercise_id}`,
|
|
variantId: n.variant_id ?? null,
|
|
variantName: n.variant_name ?? null,
|
|
variants: [],
|
|
})),
|
|
dirty: false,
|
|
isNew: false,
|
|
}
|
|
}
|
|
|
|
function newChainDraft() {
|
|
const key = `new-${Date.now()}`
|
|
return {
|
|
key,
|
|
edgeIds: [],
|
|
segmentNotes: [],
|
|
nodes: [emptyNode(), emptyNode()],
|
|
dirty: true,
|
|
isNew: true,
|
|
}
|
|
}
|
|
|
|
function formatNodeLabel(node) {
|
|
if (!node.exerciseId) return '— Übung wählen —'
|
|
return (
|
|
<>
|
|
<Link to={`/exercises/${node.exerciseId}`}>{node.exerciseTitle}</Link>
|
|
{node.variantName ? (
|
|
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${node.variantName}`}</span>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
|
|
{
|
|
graphId,
|
|
chains = [],
|
|
busy = false,
|
|
anchorExerciseId = null,
|
|
anchorTitle = null,
|
|
onRefresh,
|
|
onPickExercise,
|
|
loadVariantsForExercise,
|
|
singlePathMode = false,
|
|
},
|
|
ref,
|
|
) {
|
|
const [drafts, setDrafts] = useState([])
|
|
const [savingKey, setSavingKey] = useState(null)
|
|
|
|
const chainSignature = useMemo(
|
|
() =>
|
|
chains
|
|
.map((c) => c.edges.map((e) => e.id).join(','))
|
|
.join('|'),
|
|
[chains],
|
|
)
|
|
|
|
useEffect(() => {
|
|
setDrafts(chains.map(chainToDraft))
|
|
}, [chainSignature, chains])
|
|
|
|
const patchDraft = useCallback((key, patchFn) => {
|
|
setDrafts((prev) =>
|
|
prev.map((d) => {
|
|
if (d.key !== key) return d
|
|
const next = patchFn(d)
|
|
return { ...next, dirty: true }
|
|
}),
|
|
)
|
|
}, [])
|
|
|
|
const moveNode = (key, idx, dir) => {
|
|
patchDraft(key, (d) => {
|
|
const j = idx + dir
|
|
if (j < 0 || j >= d.nodes.length) return d
|
|
const nodes = [...d.nodes]
|
|
const t = nodes[idx]
|
|
nodes[idx] = nodes[j]
|
|
nodes[j] = t
|
|
return { ...d, nodes }
|
|
})
|
|
}
|
|
|
|
const removeNode = (key, idx) => {
|
|
patchDraft(key, (d) => {
|
|
if (d.nodes.length <= 2) return d
|
|
const nodes = d.nodes.filter((_, i) => i !== idx)
|
|
const segmentNotes = d.segmentNotes.slice(0, Math.max(0, nodes.length - 1))
|
|
return { ...d, nodes, segmentNotes }
|
|
})
|
|
}
|
|
|
|
const setVariant = (key, idx, variantId) => {
|
|
patchDraft(key, (d) => ({
|
|
...d,
|
|
nodes: d.nodes.map((n, i) => {
|
|
if (i !== idx) return n
|
|
const v = (n.variants || []).find((x) => Number(x.id) === Number(variantId))
|
|
return {
|
|
...n,
|
|
variantId: variantId === '' || variantId == null ? null : Number(variantId),
|
|
variantName: v?.variant_name || null,
|
|
}
|
|
}),
|
|
}))
|
|
}
|
|
|
|
const applyExerciseToNode = useCallback(async (key, idx, ex) => {
|
|
const title = ex.title || `Übung #${ex.id}`
|
|
const variants =
|
|
Array.isArray(ex.variants) && ex.variants.length
|
|
? ex.variants
|
|
: await loadVariantsForExercise(ex.id)
|
|
const variantId = ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
|
|
patchDraft(key, (d) => ({
|
|
...d,
|
|
nodes: d.nodes.map((n, i) =>
|
|
i === idx
|
|
? {
|
|
exerciseId: ex.id,
|
|
exerciseTitle: title,
|
|
variantId: variantId != null ? Number(variantId) : null,
|
|
variantName:
|
|
variantId != null
|
|
? variants.find((v) => Number(v.id) === Number(variantId))?.variant_name || null
|
|
: null,
|
|
variants,
|
|
}
|
|
: n,
|
|
),
|
|
}))
|
|
}, [patchDraft, loadVariantsForExercise])
|
|
|
|
const insertNodeAfter = (key, idx) => {
|
|
patchDraft(key, (d) => {
|
|
const nodes = [...d.nodes]
|
|
nodes.splice(idx + 1, 0, emptyNode())
|
|
return { ...d, nodes }
|
|
})
|
|
onPickExercise({ kind: 'chain', draftKey: key, nodeIndex: idx + 1 })
|
|
}
|
|
|
|
const addNewChain = () => {
|
|
setDrafts((prev) => [...prev, newChainDraft()])
|
|
}
|
|
|
|
const discardDraft = (key) => {
|
|
setDrafts((prev) => {
|
|
const draft = prev.find((d) => d.key === key)
|
|
if (!draft) return prev
|
|
if (draft.isNew) return prev.filter((d) => d.key !== key)
|
|
const original = chains.find((c) => `chain-${c.edges[0]?.id}` === key)
|
|
if (!original) return prev.filter((d) => d.key !== key)
|
|
return prev.map((d) => (d.key === key ? chainToDraft(original) : d))
|
|
})
|
|
}
|
|
|
|
const deleteChain = async (draft) => {
|
|
if (!graphId) return
|
|
if (draft.isNew) {
|
|
setDrafts((prev) => prev.filter((d) => d.key !== draft.key))
|
|
return
|
|
}
|
|
if (!draft.edgeIds.length) return
|
|
if (!window.confirm(`Reihe mit ${draft.nodes.length} Schritten löschen?`)) return
|
|
setSavingKey(draft.key)
|
|
try {
|
|
await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
|
|
await onRefresh()
|
|
} catch (e) {
|
|
alert(e.message || String(e))
|
|
} finally {
|
|
setSavingKey(null)
|
|
}
|
|
}
|
|
|
|
const saveDraft = async (draft) => {
|
|
if (!graphId) return
|
|
const steps = draft.nodes.filter((n) => n.exerciseId != null)
|
|
if (steps.length < 2) {
|
|
alert('Mindestens zwei Schritte mit gewählter Übung.')
|
|
return
|
|
}
|
|
const n = steps.length - 1
|
|
let segment_notes = draft.segmentNotes.slice(0, n)
|
|
while (segment_notes.length < n) segment_notes.push(null)
|
|
segment_notes = segment_notes.slice(0, n)
|
|
|
|
setSavingKey(draft.key)
|
|
try {
|
|
if (!draft.isNew && draft.edgeIds.length) {
|
|
await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
|
|
}
|
|
await api.createExerciseProgressionSequence(graphId, {
|
|
steps: steps.map((s) => ({
|
|
exercise_id: s.exerciseId,
|
|
variant_id: s.variantId || null,
|
|
})),
|
|
segment_notes,
|
|
})
|
|
await onRefresh()
|
|
} catch (e) {
|
|
alert(e.message || String(e))
|
|
} finally {
|
|
setSavingKey(null)
|
|
}
|
|
}
|
|
|
|
const ensureVariantsLoaded = async (key, idx) => {
|
|
const draft = drafts.find((d) => d.key === key)
|
|
const node = draft?.nodes[idx]
|
|
if (!node?.exerciseId || (node.variants || []).length) return
|
|
const variants = await loadVariantsForExercise(node.exerciseId)
|
|
setDrafts((prev) =>
|
|
prev.map((d) => {
|
|
if (d.key !== key) return d
|
|
return {
|
|
...d,
|
|
nodes: d.nodes.map((n, i) => (i === idx ? { ...n, variants } : n)),
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
applyExercise: applyExerciseToNode,
|
|
}),
|
|
[applyExerciseToNode],
|
|
)
|
|
|
|
if (!graphId) return null
|
|
|
|
return (
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '10px',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: '12px',
|
|
}}
|
|
>
|
|
<div>
|
|
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
|
|
{singlePathMode ? 'Manuell bearbeiten' : 'Reihen im Graph'}
|
|
</h4>
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.45 }}>
|
|
Schritt 1 → 2 → …: Reihenfolge ändern, Übungen tauschen oder dazwischen einfügen, dann speichern.
|
|
</p>
|
|
</div>
|
|
{!singlePathMode || drafts.length === 0 ? (
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={addNewChain}>
|
|
{singlePathMode ? '+ Pfad anlegen' : '+ Neue Reihe'}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{drafts.length === 0 ? (
|
|
<p style={{ color: 'var(--text2)', margin: 0, fontSize: '13px' }}>
|
|
{singlePathMode
|
|
? 'Noch kein gespeicherter Pfad — manuell anlegen oder mit dem KI-Planer unten.'
|
|
: 'Noch keine Reihen in diesem Graph.'}
|
|
</p>
|
|
) : (
|
|
drafts.map((draft, chainIdx) => (
|
|
<div
|
|
key={draft.key}
|
|
style={{
|
|
marginBottom: '16px',
|
|
padding: '14px',
|
|
borderRadius: '10px',
|
|
border: `1px solid ${
|
|
draft.dirty
|
|
? 'color-mix(in srgb, var(--accent) 50%, var(--border))'
|
|
: 'var(--border)'
|
|
}`,
|
|
background: 'var(--surface2)',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '8px',
|
|
alignItems: 'center',
|
|
marginBottom: '12px',
|
|
}}
|
|
>
|
|
<strong style={{ fontSize: '13px' }}>
|
|
{singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`}
|
|
</strong>
|
|
{draft.dirty ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
Ungespeichert
|
|
</span>
|
|
) : null}
|
|
{draft.isNew ? (
|
|
<span className="exercise-tag">Neu</span>
|
|
) : (
|
|
<span className="exercise-tag">{draft.nodes.length} Schritte</span>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
|
{draft.nodes.map((node, idx) => (
|
|
<React.Fragment key={`${draft.key}-node-${idx}`}>
|
|
{idx > 0 ? (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '4px 0 4px 12px',
|
|
color: 'var(--accent)',
|
|
fontSize: '12px',
|
|
fontWeight: 700,
|
|
}}
|
|
aria-hidden
|
|
>
|
|
↓ Nachfolger
|
|
</div>
|
|
) : null}
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
|
gap: '10px',
|
|
alignItems: 'end',
|
|
padding: '10px 12px',
|
|
borderRadius: '8px',
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface)',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Schritt {idx + 1}</label>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '8px',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<span style={{ fontSize: '13px', flex: '1 1 140px' }}>
|
|
{formatNodeLabel(node)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
disabled={busy || savingKey === draft.key}
|
|
onClick={() =>
|
|
onPickExercise({ kind: 'chain', draftKey: draft.key, nodeIndex: idx })
|
|
}
|
|
>
|
|
{node.exerciseId ? 'Tauschen…' : 'Übung…'}
|
|
</button>
|
|
{anchorExerciseId != null ? (
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
disabled={busy || savingKey === draft.key}
|
|
onClick={async () => {
|
|
const variants = await loadVariantsForExercise(anchorExerciseId)
|
|
await applyExerciseToNode(draft.key, idx, {
|
|
id: anchorExerciseId,
|
|
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
|
variants,
|
|
})
|
|
}}
|
|
>
|
|
Kontext-Übung
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Variante</label>
|
|
<select
|
|
className="form-input"
|
|
disabled={!node.exerciseId || busy || savingKey === draft.key}
|
|
value={node.variantId ?? ''}
|
|
onFocus={() => ensureVariantsLoaded(draft.key, idx)}
|
|
onChange={(e) =>
|
|
setVariant(
|
|
draft.key,
|
|
idx,
|
|
e.target.value === '' ? null : parseInt(e.target.value, 10),
|
|
)
|
|
}
|
|
>
|
|
<option value="">Gesamte Übung</option>
|
|
{(node.variants || []).map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
{v.variant_name || `Variante #${v.id}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
disabled={busy || savingKey === draft.key || idx === 0}
|
|
onClick={() => moveNode(draft.key, idx, -1)}
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
disabled={busy || savingKey === draft.key || idx >= draft.nodes.length - 1}
|
|
onClick={() => moveNode(draft.key, idx, 1)}
|
|
>
|
|
↓
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
disabled={busy || savingKey === draft.key}
|
|
onClick={() => insertNodeAfter(draft.key, idx)}
|
|
>
|
|
+ Einfügen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
disabled={busy || savingKey === draft.key || draft.nodes.length <= 2}
|
|
onClick={() => removeNode(draft.key, idx)}
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '12px' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={busy || savingKey === draft.key || !draft.dirty}
|
|
onClick={() => saveDraft(draft)}
|
|
>
|
|
{savingKey === draft.key ? 'Speichern …' : singlePathMode ? 'Pfad speichern' : 'Reihe speichern'}
|
|
</button>
|
|
{draft.dirty ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={busy || savingKey === draft.key}
|
|
onClick={() => discardDraft(draft.key)}
|
|
>
|
|
Verwerfen
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{
|
|
fontSize: '12px',
|
|
background: 'var(--danger)',
|
|
color: '#fff',
|
|
border: 'none',
|
|
}}
|
|
disabled={busy || savingKey === draft.key}
|
|
onClick={() => deleteChain(draft)}
|
|
>
|
|
{singlePathMode ? 'Pfad löschen' : 'Reihe löschen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export default ProgressionChainEditor
|