From 8d5f0b533c6d43eaa58bdeb75236c774b74855cc Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Jun 2026 11:17:05 +0200 Subject: [PATCH] Enhance Exercise Progression Graph Panel and Path Builder with New Features - 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. --- .../ExerciseProgressionGraphPanel.jsx | 67 ++++--- .../ExerciseProgressionPathBuilder.jsx | 182 ++++++++++++++++-- .../src/components/ProgressionChainEditor.jsx | 30 +-- 3 files changed, 224 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index a124300..e62a75f 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -277,6 +277,13 @@ export default function ExerciseProgressionGraphPanel({ const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered]) + const primaryChain = useMemo(() => { + if (!flowChains.length) return null + return flowChains.reduce((best, chain) => + chain.nodes.length >= best.nodes.length ? chain : best, + ) + }, [flowChains]) + const handleCreateGraph = async (e) => { e.preventDefault() const name = newGraphName.trim() @@ -566,17 +573,42 @@ export default function ExerciseProgressionGraphPanel({ {selectedGraphId && ( <> - refreshEdges(selectedGraphId)} - onPickExercise={setPickContext} - loadVariantsForExercise={loadVariantsForExercise} - /> +
+

Progressionspfad

+

+ Der Graph enthält typischerweise einen linearen Pfad (Übung → Übung). Unten + manuell bearbeiten oder mit dem KI-Planer erweitern — Speichern im KI-Wizard ersetzt den Pfad. +

+ + refreshEdges(selectedGraphId)} + onPickExercise={setPickContext} + loadVariantsForExercise={loadVariantsForExercise} + singlePathMode + /> + + e.id) ?? null} + onSaved={async () => { + await refreshEdges(selectedGraphId) + }} + /> +

Schwestern & Alternativen

@@ -671,19 +703,6 @@ export default function ExerciseProgressionGraphPanel({
-
- KI: Neuen Pfad planen (4 Schritte) -
- { - await refreshEdges(selectedGraphId) - }} - /> -
-
- { + const node = graphNodes[i] + if (!node?.exercise_id) return row + if (row.exerciseId != null) return row + return { + ...row, + exerciseId: Number(node.exercise_id), + exerciseTitle: node.title || `Übung #${node.exercise_id}`, + variantId: node.variant_id != null ? Number(node.variant_id) : null, + variants: row.variants || [], + isFromGraph: true, + reasons: [...(row.reasons || []), 'Aus bestehendem Graph übernommen'], + } + }) +} + +function filterGapOffersForGraph(offers, pathRows, graphNodes) { + if (!Array.isArray(offers) || !offers.length) return offers + const graphIds = new Set( + (graphNodes || []).map((n) => Number(n.exercise_id)).filter(Number.isFinite), + ) + const graphTitles = new Set( + (graphNodes || []).map((n) => normalizeTitleKey(n.title)).filter(Boolean), + ) + const pathIds = new Set( + (pathRows || []).map((r) => r.exerciseId).filter((id) => id != null), + ) + + return offers.filter((offer) => { + const hint = normalizeTitleKey(offer?.title_hint) + const majorIdx = + offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null + + if (majorIdx != null && Number.isFinite(majorIdx) && graphNodes?.[majorIdx]) { + const gid = Number(graphNodes[majorIdx].exercise_id) + if (graphIds.has(gid) && pathIds.has(gid)) return false + } + + for (const gid of graphIds) { + if (pathIds.has(gid) && hint) { + const node = graphNodes.find((n) => Number(n.exercise_id) === gid) + const nodeTitle = normalizeTitleKey(node?.title) + if (nodeTitle && (nodeTitle === hint || nodeTitle.includes(hint) || hint.includes(nodeTitle))) { + return false + } + } + } + + for (const t of graphTitles) { + if (hint && (t === hint || t.includes(hint) || hint.includes(t))) return false + } + return true + }) +} + +function SavedGraphPathStrip({ nodes, hasDraft }) { + if (!Array.isArray(nodes) || nodes.length === 0) { + return ( +
+ Im Graph gespeichert: noch kein Pfad — der erste + Speichervorgang legt die Übungsfolge an. +
+ ) + } + return ( +
+ + Im Graph gespeichert ({nodes.length} Schritte) + {hasDraft ? ( + + — KI-Entwurf unten; Speichern ersetzt diesen Pfad + + ) : null} + +
    + {nodes.map((node, idx) => ( +
  1. + {node.title || `Übung #${node.exercise_id}`} + {node.variant_name ? ( + {` · ${node.variant_name}`} + ) : null} +
  2. + ))} +
+
+ ) +} + const PATH_STEPS_HARD_MAX = 10 const WIZARD_STEPS = [ @@ -399,6 +516,8 @@ export default function ExerciseProgressionPathBuilder({ graphId, disabled = false, onSaved, + graphChainNodes = null, + graphChainEdgeIds = null, }) { const [goalQuery, setGoalQuery] = useState('') const [startSituation, setStartSituation] = useState('') @@ -887,7 +1006,7 @@ export default function ExerciseProgressionPathBuilder({ setActiveOffer(null) const title = (created.title || quickTitle || 'Übung').trim() setPathInsertNotice( - `„${title}" wurde in den KI-Pfad eingefügt. Speichern Sie jetzt mit «Pfad in Graph speichern» — die Reihe erscheint dann oben unter «Reihen im Graph».`, + `„${title}" wurde in den KI-Entwurf eingefügt. Mit «Pfad im Graph speichern» wird der gesamte Pfad übernommen.`, ) setWizardStep(4) } catch (e) { @@ -910,17 +1029,18 @@ export default function ExerciseProgressionPathBuilder({ if (rows.length < 2) { throw new Error('Zu wenig Schritte im Vorschlag.') } - setPathSteps(rows) + const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes) + const rawGaps = Array.isArray(res?.gap_fill_offers) + ? res.gap_fill_offers + : Array.isArray(qa?.gap_fill_offers) + ? qa.gap_fill_offers + : [] + const gaps = filterGapOffersForGraph(rawGaps, mergedRows, graphChainNodes) + setPathSteps(mergedRows) setTargetSummary(res?.target_profile_summary || null) setSemanticBrief(res?.semantic_brief_summary || null) setPathQa(qa) - setGapFillOffers( - Array.isArray(res?.gap_fill_offers) - ? res.gap_fill_offers - : Array.isArray(qa?.gap_fill_offers) - ? qa.gap_fill_offers - : [], - ) + setGapFillOffers(gaps) setProgressionRoadmap(res?.progression_roadmap || null) setPathSkillExpectations(res?.path_skill_expectations || null) setRoadmapDirty(false) @@ -1125,6 +1245,12 @@ export default function ExerciseProgressionPathBuilder({ setSaving(true) setError('') try { + const edgeIds = Array.isArray(graphChainEdgeIds) + ? graphChainEdgeIds.filter((id) => Number.isFinite(Number(id))) + : [] + if (edgeIds.length > 0) { + await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds) + } const planningArtifact = buildPlanningArtifact() await api.createExerciseProgressionSequence(Number(graphId), { steps: steps.map((s) => ({ @@ -1148,12 +1274,19 @@ export default function ExerciseProgressionPathBuilder({ if (typeof onSaved === 'function') await onSaved() const msg = skippedAi > 0 - ? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).` - : `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.` + ? `Pfad gespeichert (${n} Kante(n)). ${skippedAi} KI-Vorschlag/Vorschläge noch nicht angelegt.` + : edgeIds.length > 0 + ? `Progressionspfad aktualisiert (${n} Kante(n)).` + : `Progressionspfad angelegt (${n} Kante(n)).` alert(msg) } catch (e) { console.error(e) - setError(e.message || 'Speichern fehlgeschlagen') + const detail = e?.message || String(e) + setError( + detail.includes('409') || detail.toLowerCase().includes('duplikat') + ? 'Speichern fehlgeschlagen: Pfad-Konflikt. Bitte erneut versuchen — bestehende Kanten werden beim Speichern ersetzt.' + : detail || 'Speichern fehlgeschlagen', + ) } finally { setSaving(false) } @@ -1161,17 +1294,20 @@ export default function ExerciseProgressionPathBuilder({ return (
-

KI: Pfad zum Ziel

+

Progressionspfad planen (KI)

- In vier Schritten: Ziel festlegen → Roadmap bearbeiten → Übungen matchen → Lücken schließen und speichern. + Ein Graph hat einen linearen Pfad. Oben der gespeicherte Stand, darunter der KI-Entwurf in vier Schritten. + Speichern übernimmt den Entwurf und ersetzt den bisherigen Pfad.

+ 0} /> + {step.roadmapLearningGoal ? ( @@ -1922,8 +2059,8 @@ export default function ExerciseProgressionPathBuilder({ color: 'var(--accent-dark)', }} > - Wichtig: Der KI-Pfad ist noch nicht im Graph gespeichert. KI-Übungen werden erst nach - «Pfad in Graph speichern» als neue Reihe oben sichtbar. + Wichtig: Der KI-Entwurf ist noch nicht gespeichert. «Pfad im Graph speichern» übernimmt + ihn und ersetzt den oben gezeigten Pfad. {pathInsertNotice ?

{pathInsertNotice}

: null}
@@ -2042,6 +2179,7 @@ export default function ExerciseProgressionPathBuilder({ — noch nicht angelegt )} {step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''} + {step.isFromGraph ? ' · bereits im Graph' : ''} ))} @@ -2072,7 +2210,11 @@ export default function ExerciseProgressionPathBuilder({ disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2} onClick={savePathToGraph} > - {saving ? 'Speichern …' : 'Pfad in Graph speichern'} + {saving + ? 'Speichern …' + : graphChainEdgeIds?.length + ? 'Pfad im Graph speichern (ersetzen)' + : 'Pfad im Graph speichern'} + {!singlePathMode || drafts.length === 0 ? ( + + ) : null} {drafts.length === 0 ? (

- Noch keine Reihen in diesem Graph. Legen Sie eine neue Reihe an oder nutzen Sie den KI-Planer unten. + {singlePathMode + ? 'Noch kein gespeicherter Pfad — manuell anlegen oder mit dem KI-Planer unten.' + : 'Noch keine Reihen in diesem Graph.'}

) : ( drafts.map((draft, chainIdx) => ( @@ -310,7 +316,9 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor( marginBottom: '12px', }} > - Reihe {chainIdx + 1} + + {singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`} + {draft.dirty ? ( Ungespeichert @@ -471,7 +479,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor( disabled={busy || savingKey === draft.key || !draft.dirty} onClick={() => saveDraft(draft)} > - {savingKey === draft.key ? 'Speichern …' : 'Reihe speichern'} + {savingKey === draft.key ? 'Speichern …' : singlePathMode ? 'Pfad speichern' : 'Reihe speichern'} {draft.dirty ? (