From ee22b229705966c5aafcfc9470d288f0c652af14 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Jun 2026 15:42:29 +0200 Subject: [PATCH] Refactor Progression Graph Components and Consolidate UI - Updated the `ProgressionGraphSlotEditorSpec.md` to reflect UI consolidation, removing separate editors and integrating functionalities into `ExerciseProgressionGraphPanel`. - Refactored `ExerciseProgressionGraphPanel` to streamline the editing experience, removing unused state and logic for better performance. - Enhanced `ProgressionGraphEditor` to support embedded usage and trigger callbacks on save, improving integration with other components. - Simplified `ProgressionGraphEditPage` to redirect users to the exercises list with deep-linking support for selected graphs. - Incremented application version to reflect these updates. --- .../PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md | 10 +- .../ExerciseProgressionGraphPanel.jsx | 632 ++++-------------- .../src/components/ProgressionGraphEditor.jsx | 29 +- .../src/pages/ProgressionGraphEditPage.jsx | 24 +- 4 files changed, 145 insertions(+), 550 deletions(-) diff --git a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md index 2a59513..3f5d0cf 100644 --- a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md +++ b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md @@ -58,14 +58,16 @@ Zusätzlich optional: - `slot_contents[]` — `{ major_step_index, primary, siblings[] }` - `last_findings` — letzter `path_qa`-Snapshot -## UI & Routing +## UI (konsolidiert) -- **B.4:** Route `/progression-graphs/:id` — Slots links, Findings rechts +- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings) +- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel +- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph) - **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel) -## Ersetzt schrittweise +## Ersetzt (Legacy, nicht mehr im Panel) -- Getrennte `ExerciseProgressionPathBuilder`-Wizard-UI + `ProgressionChainEditor` → integrierter `ProgressionGraphEditor` +- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden ## Implementierungsreihenfolge diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 330c80a..a8be046 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -1,16 +1,13 @@ /** - * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht, - * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback. + * Progressionsgraphen — eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' import api from '../utils/api' import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' -import ExercisePickerModal from './ExercisePickerModal' -import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder' -import ProgressionChainEditor from './ProgressionChainEditor' +import ProgressionGraphEditor from './ProgressionGraphEditor' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' const VIS_OPTIONS = [ @@ -25,83 +22,12 @@ function edgeTypeLabel(type) { return type || '—' } -/** Maximale lineare Segmente aus next_exercise-Kanten (jedes Segment deckt zusammenhängende „Pfade“ ab). */ -function maximalLinearChains(nextEdges) { - if (!nextEdges?.length) return [] - const outMap = new Map() - const inMap = new Map() - const nodeKey = (ex, v) => `${ex}:${v ?? ''}` - - for (const e of nextEdges) { - const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id) - const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id) - if (!outMap.has(f)) outMap.set(f, []) - outMap.get(f).push(e) - if (!inMap.has(t)) inMap.set(t, []) - inMap.get(t).push(e) - } - - const used = new Set() - const chains = [] - - for (const startEdge of nextEdges) { - if (used.has(startEdge.id)) continue - - const edgesSeq = [startEdge] - - let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id) - while (true) { - const preds = inMap.get(fk) - if (!preds || preds.length !== 1) break - const pred = preds[0] - if (used.has(pred.id)) break - edgesSeq.unshift(pred) - fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id) - } - - let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id) - while (true) { - const outs = outMap.get(tk) - if (!outs || outs.length !== 1) break - const nx = outs[0] - if (used.has(nx.id)) break - edgesSeq.push(nx) - tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id) - } - - edgesSeq.forEach((ed) => used.add(ed.id)) - - const first = edgesSeq[0] - const nodes = [ - { - exercise_id: first.from_exercise_id, - variant_id: first.from_exercise_variant_id ?? null, - title: first.from_exercise_title, - variant_name: first.from_variant_name ?? null, - }, - ] - for (const ed of edgesSeq) { - nodes.push({ - exercise_id: ed.to_exercise_id, - variant_id: ed.to_exercise_variant_id ?? null, - title: ed.to_exercise_title, - variant_name: ed.to_variant_name ?? null, - }) - } - chains.push({ nodes, edges: edgesSeq }) - } - return chains -} - -function emptyEndpoint() { - return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } -} - export default function ExerciseProgressionGraphPanel({ anchorExerciseId = null, anchorTitle = null, }) { const { user } = useAuth() + const location = useLocation() const isSuperadmin = user?.role === 'superadmin' const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) @@ -123,14 +49,6 @@ export default function ExerciseProgressionGraphPanel({ const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') - const [pickContext, setPickContext] = useState(null) - const chainEditorRef = useRef(null) - - const [relationKind, setRelationKind] = useState('progression') - const [firstEp, setFirstEp] = useState(emptyEndpoint) - const [secondEp, setSecondEp] = useState(emptyEndpoint) - const [edgeNotes, setEdgeNotes] = useState('') - const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') @@ -138,6 +56,13 @@ export default function ExerciseProgressionGraphPanel({ const [skillProfileLoading, setSkillProfileLoading] = useState(false) const [skillProfileError, setSkillProfileError] = useState('') + useEffect(() => { + const gid = location.state?.progressionGraphId + if (gid != null && Number.isFinite(Number(gid))) { + setSelectedGraphId(Number(gid)) + } + }, [location.state?.progressionGraphId]) + useEffect(() => { setSelectedGraphId(null) }, [tenantClubDepKey]) @@ -157,12 +82,6 @@ export default function ExerciseProgressionGraphPanel({ setEdges(Array.isArray(list) ? list : []) }, []) - const loadVariantsForExercise = useCallback(async (exerciseId) => { - if (!exerciseId) return [] - const ex = await api.getExercise(exerciseId) - return Array.isArray(ex?.variants) ? ex.variants : [] - }, []) - useEffect(() => { let cancelled = false ;(async () => { @@ -234,30 +153,6 @@ export default function ExerciseProgressionGraphPanel({ } }, [selectedGraphId, graphs, refreshEdges]) - useEffect(() => { - let cancelled = false - ;(async () => { - if (!firstEp.exerciseId) return - const vars = await loadVariantsForExercise(firstEp.exerciseId) - if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars })) - })() - return () => { - cancelled = true - } - }, [firstEp.exerciseId, loadVariantsForExercise]) - - useEffect(() => { - let cancelled = false - ;(async () => { - if (!secondEp.exerciseId) return - const vars = await loadVariantsForExercise(secondEp.exerciseId) - if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars })) - })() - return () => { - cancelled = true - } - }, [secondEp.exerciseId, loadVariantsForExercise]) - const filteredEdges = useMemo(() => { if (!filterAnchorOnly || anchorExerciseId == null) return edges return edges.filter( @@ -266,24 +161,6 @@ export default function ExerciseProgressionGraphPanel({ ) }, [edges, filterAnchorOnly, anchorExerciseId]) - const nextEdgesFiltered = useMemo( - () => filteredEdges.filter((e) => e.edge_type === 'next_exercise'), - [filteredEdges], - ) - const siblingEdgesFiltered = useMemo( - () => filteredEdges.filter((e) => e.edge_type === 'sibling'), - [filteredEdges], - ) - - 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() @@ -322,7 +199,7 @@ export default function ExerciseProgressionGraphPanel({ visibility: metaVisibility, }) await refreshGraphs() - alert('Graph gespeichert.') + alert('Graph-Metadaten gespeichert.') } catch (err) { alert(err.message || String(err)) } finally { @@ -332,7 +209,7 @@ export default function ExerciseProgressionGraphPanel({ const handleDeleteGraph = async () => { if (!selectedGraphId) return - if (!confirm('Diesen Progressionsgraphen und alle Kanten wirklich löschen?')) return + if (!window.confirm('Diesen Progressionsgraph wirklich löschen?')) return setBusy(true) try { await api.deleteExerciseProgressionGraph(selectedGraphId) @@ -345,49 +222,8 @@ export default function ExerciseProgressionGraphPanel({ } } - const handleAddEdge = async () => { - if (!selectedGraphId) { - alert('Zuerst einen Graphen wählen.') - return - } - if (!firstEp.exerciseId || !secondEp.exerciseId) { - alert('Beide Enden müssen eine Übung haben.') - return - } - if ( - firstEp.exerciseId === secondEp.exerciseId && - (firstEp.variantId == null || - secondEp.variantId == null || - firstEp.variantId === secondEp.variantId) - ) { - alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).') - return - } - const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise' - const notes = edgeNotes.trim() || null - const body = { - from_exercise_id: firstEp.exerciseId, - to_exercise_id: secondEp.exerciseId, - from_exercise_variant_id: firstEp.variantId || null, - to_exercise_variant_id: secondEp.variantId || null, - edge_type, - notes, - } - setBusy(true) - try { - await api.createExerciseProgressionEdge(selectedGraphId, body) - setEdgeNotes('') - await refreshEdges(selectedGraphId) - } catch (err) { - alert(err.message || String(err)) - } finally { - setBusy(false) - } - } - const handleDeleteEdge = async (edgeId) => { if (!selectedGraphId) return - if (!confirm('Kante löschen?')) return setBusy(true) try { await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId) @@ -420,58 +256,10 @@ export default function ExerciseProgressionGraphPanel({ } } - const swapEnds = () => { - const a = firstEp - setFirstEp(secondEp) - setSecondEp(a) - } - - const applyPickedExercise = async (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 - - if (pickContext?.kind === 'chain') { - await chainEditorRef.current?.applyExercise( - pickContext.draftKey, - pickContext.nodeIndex, - { - ...ex, - variants, - exercise_variant_id: variantId, - }, - ) - setPickContext(null) - return - } - if (pickContext?.kind === 'single') { - const patch = { - exerciseId: ex.id, - exerciseTitle: title, - variantId: variantId != null ? Number(variantId) : null, - variants, - } - if (pickContext.slot === 'first') setFirstEp(patch) - else setSecondEp(patch) - setPickContext(null) - } - } - - function formatNodeLine(n) { - return ( - <> - {n.title} - {n.variant_name ? ( - {` · ${n.variant_name}`} - ) : null} - - ) - } - - const pickerOpen = pickContext != null + const handleEditorSaved = useCallback(async () => { + if (!selectedGraphId) return + await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()]) + }, [selectedGraphId, refreshEdges, refreshGraphs]) return (
@@ -485,9 +273,8 @@ export default function ExerciseProgressionGraphPanel({ )}

- Ein Graph = ein linearer Primärpfad (Roadmap-Slots) plus optionale{' '} - Schwestern. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '} - Slot-Editor öffnen — unten weiterhin Kurzansicht und KI-Wizard. + Ein Progressionsgraph = Roadmap mit Slots (Lernziel + Hauptübung + Schwestern). + Roadmap, KI-Match und Bewertung sind in einer Ansicht — kein separater Wizard.

{loadErr && ( @@ -525,18 +312,12 @@ export default function ExerciseProgressionGraphPanel({ - {selectedGraphId ? ( - - Slot-Editor öffnen - - ) : null}
-
+

Neuen Graphen anlegen

@@ -576,107 +357,20 @@ export default function ExerciseProgressionGraphPanel({ checked={filterAnchorOnly} onChange={(e) => setFilterAnchorOnly(e.target.checked)} /> - Nur Reihen und Kanten, die diese Übung betreffen + Technische Kantenliste: nur Kanten mit dieser Übung )} - {selectedGraphId && ( + {selectedGraphId ? ( <> -
-

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

- {siblingEdgesFiltered.length === 0 ? ( -

Keine Schwester-Kanten im aktuellen Filter.

- ) : ( -
    - {siblingEdgesFiltered.map((row) => ( -
  • - - {formatNodeLine({ - exercise_id: row.from_exercise_id, - variant_id: row.from_exercise_variant_id, - title: row.from_exercise_title, - variant_name: row.from_variant_name, - })} - · Schwester · - {formatNodeLine({ - exercise_id: row.to_exercise_id, - variant_id: row.to_exercise_variant_id, - title: row.to_exercise_title, - variant_name: row.to_variant_name, - })} - {row.notes ? ( - - {row.notes} - - ) : null} - - -
  • - ))} -
- )} -
- -
+
Graph-Einstellungen (Name, Sichtbarkeit)
@@ -714,7 +408,7 @@ export default function ExerciseProgressionGraphPanel({ -
- Einzelkante (Nachfolger oder Schwester) -
-
- - -
- - {['first', 'second'].map((slot) => { - const ep = slot === 'first' ? firstEp : secondEp - const setEp = slot === 'first' ? setFirstEp : setSecondEp - return ( -
- -
- - {ep.exerciseId ? ( - <> - {ep.exerciseTitle} - (#{ep.exerciseId}) - - ) : ( - — Übung wählen — - )} - - - {anchorExerciseId != null && ( - - )} -
- -
- ) - })} - - {relationKind === 'progression' && ( - - )} - -
- -