From ae6c0893662e031969d4fd882376c5db489a8647 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 3 May 2026 18:07:52 +0200 Subject: [PATCH] chore: update versioning and enhance exercise progression graph functionality - Incremented application version to 0.8.7 and updated database schema version to 20260430034. - Enhanced exercise progression graph functionality by adding support for exercise variants as node endpoints and bulk creation of progression sequences. - Updated changelog to reflect new features and improvements related to progression graphs and API enhancements. --- ...034_exercise_progression_edge_variants.sql | 45 ++ .../routers/exercise_progression_graphs.py | 199 ++++- backend/version.py | 15 +- .../ExerciseProgressionGraphPanel.jsx | 760 ++++++++++++++---- frontend/src/utils/api.js | 16 + 5 files changed, 860 insertions(+), 175 deletions(-) create mode 100644 backend/migrations/034_exercise_progression_edge_variants.sql diff --git a/backend/migrations/034_exercise_progression_edge_variants.sql b/backend/migrations/034_exercise_progression_edge_variants.sql new file mode 100644 index 0000000..77586f9 --- /dev/null +++ b/backend/migrations/034_exercise_progression_edge_variants.sql @@ -0,0 +1,45 @@ +-- Migration 034: Progressionskanten optional mit Übungsvarianten (Knoten = Übung oder konkrete Variante). +-- UNIQUE und CHECK angepasst; Kanten innerhalb derselben Übung nur zwischen verschiedenen Varianten. + +ALTER TABLE exercise_progression_edges + ADD COLUMN IF NOT EXISTS from_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS to_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_progression_edges_from_variant ON exercise_progression_edges(from_exercise_variant_id) + WHERE from_exercise_variant_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_progression_edges_to_variant ON exercise_progression_edges(to_exercise_variant_id) + WHERE to_exercise_variant_id IS NOT NULL; + +DO $$ +BEGIN + ALTER TABLE exercise_progression_edges + DROP CONSTRAINT exercise_progression_edges_graph_id_from_exercise_id_to_exercise_id_edge_type_key; +EXCEPTION + WHEN undefined_object THEN NULL; +END $$; + +DO $$ +BEGIN + ALTER TABLE exercise_progression_edges DROP CONSTRAINT exercise_progression_edges_check; +EXCEPTION + WHEN undefined_object THEN NULL; +END $$; + +ALTER TABLE exercise_progression_edges ADD CONSTRAINT exercise_progression_edges_endpoints_distinct CHECK ( + (from_exercise_id <> to_exercise_id) + OR ( + from_exercise_variant_id IS NOT NULL + AND to_exercise_variant_id IS NOT NULL + AND from_exercise_variant_id <> to_exercise_variant_id + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS exercise_progression_edges_unique_endpoints +ON exercise_progression_edges ( + graph_id, + from_exercise_id, + COALESCE(from_exercise_variant_id, 0), + to_exercise_id, + COALESCE(to_exercise_variant_id, 0), + edge_type +); diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index cc27ac1..13326aa 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -1,11 +1,12 @@ """ -Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032. +Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034. +Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage. AuthZ analog training_plan_templates (_template_access / _has_planning_role). """ from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from psycopg2 import IntegrityError from auth import require_auth @@ -33,6 +34,8 @@ class ProgressionGraphUpdate(BaseModel): class ProgressionEdgeCreate(BaseModel): from_exercise_id: int = Field(..., gt=0) to_exercise_id: int = Field(..., gt=0) + from_exercise_variant_id: Optional[int] = Field(default=None) + to_exercise_variant_id: Optional[int] = Field(default=None) edge_type: str = Field(default="next_exercise", min_length=1, max_length=50) notes: Optional[str] = Field(None, max_length=4000) @@ -41,12 +44,41 @@ class ProgressionEdgeUpdate(BaseModel): notes: Optional[str] = Field(None, max_length=4000) +class SequenceStep(BaseModel): + exercise_id: int = Field(..., gt=0) + variant_id: Optional[int] = Field(default=None) + + +class ProgressionSequenceCreate(BaseModel): + steps: List[SequenceStep] = Field(..., min_length=2) + segment_notes: Optional[List[Optional[str]]] = None + """Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten.""" + + @model_validator(mode="after") + def check_segment_notes_len(self): + if self.segment_notes is not None and len(self.segment_notes) != len(self.steps) - 1: + raise ValueError( + f"segment_notes muss genau {len(self.steps) - 1} Einträge haben (len(steps)-1)" + ) + return self + + +class EdgeIdsBatch(BaseModel): + edge_ids: List[int] = Field(..., min_length=1) + + _EDGE_SELECT = """ - SELECT e.id, e.graph_id, e.from_exercise_id, e.to_exercise_id, e.edge_type, e.notes, e.created_at, - ef.title AS from_exercise_title, et.title AS to_exercise_title + SELECT e.id, e.graph_id, + e.from_exercise_id, e.from_exercise_variant_id, + e.to_exercise_id, e.to_exercise_variant_id, + e.edge_type, e.notes, e.created_at, + ef.title AS from_exercise_title, et.title AS to_exercise_title, + vf.variant_name AS from_variant_name, vt.variant_name AS to_variant_name FROM exercise_progression_edges e JOIN exercises ef ON ef.id = e.from_exercise_id JOIN exercises et ON et.id = e.to_exercise_id + LEFT JOIN exercise_variants vf ON vf.id = e.from_exercise_variant_id + LEFT JOIN exercise_variants vt ON vt.id = e.to_exercise_variant_id """ @@ -83,6 +115,57 @@ def _assert_exercises_exist(cur, *exercise_ids: int) -> None: ) +def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int]) -> None: + if variant_id is None: + return + cur.execute( + "SELECT exercise_id FROM exercise_variants WHERE id = %s", + (variant_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail=f"Unbekannte Variante: {variant_id}") + ev_ex = row["exercise_id"] if isinstance(row, dict) else row[0] + if ev_ex != exercise_id: + raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung") + + +def _insert_edge_row( + cur, + graph_id: int, + from_exercise_id: int, + from_variant_id: Optional[int], + to_exercise_id: int, + to_variant_id: Optional[int], + edge_type: str, + notes: Optional[str], +) -> dict: + cur.execute( + """ + INSERT INTO exercise_progression_edges ( + graph_id, + from_exercise_id, from_exercise_variant_id, + to_exercise_id, to_exercise_variant_id, + edge_type, notes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + graph_id, + from_exercise_id, + from_variant_id, + to_exercise_id, + to_variant_id, + edge_type, + notes, + ), + ) + new_id = cur.fetchone()["id"] + cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,)) + return r2d(cur.fetchone()) + + @router.get("/exercise-progression-graphs") def list_progression_graphs(session: dict = Depends(require_auth)): profile_id = session["profile_id"] @@ -252,40 +335,91 @@ def create_progression_edge( ): profile_id = session["profile_id"] role = session.get("role") - if body.from_exercise_id == body.to_exercise_id: - raise HTTPException(status_code=400, detail="from_exercise_id und to_exercise_id müssen unterschiedlich sein") with get_db() as conn: cur = get_cursor(conn) _graph_access(cur, graph_id, profile_id, role) _assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) + fv = body.from_exercise_variant_id + tv = body.to_exercise_variant_id + _assert_variant_for_exercise(cur, body.from_exercise_id, fv) + _assert_variant_for_exercise(cur, body.to_exercise_id, tv) et = (body.edge_type or "next_exercise").strip() or "next_exercise" notes = (body.notes or "").strip() or None try: - cur.execute( - """ - INSERT INTO exercise_progression_edges ( - graph_id, from_exercise_id, to_exercise_id, edge_type, notes - ) - VALUES (%s, %s, %s, %s, %s) - RETURNING id - """, - (graph_id, body.from_exercise_id, body.to_exercise_id, et, notes), + row = _insert_edge_row( + cur, + graph_id, + body.from_exercise_id, + fv, + body.to_exercise_id, + tv, + et, + notes, ) - new_id = cur.fetchone()["id"] - cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,)) - row = r2d(cur.fetchone()) conn.commit() except IntegrityError as e: conn.rollback() raise HTTPException( status_code=409, - detail="Kante existiert bereits (graph_id, von, nach, edge_type)", + detail="Kante existiert bereits oder Endpunkte unzulässig (gleiche Übung ohne zwei Varianten)", ) from e return row +@router.post("/exercise-progression-graphs/{graph_id}/edges/sequence", status_code=201) +def create_progression_sequence( + graph_id: int, + body: ProgressionSequenceCreate, + session: dict = Depends(require_auth), +): + """Legt n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an.""" + profile_id = session["profile_id"] + role = session.get("role") + steps = body.steps + n_seg = len(steps) - 1 + seg_notes = body.segment_notes + + created: List[dict] = [] + with get_db() as conn: + cur = get_cursor(conn) + _graph_access(cur, graph_id, profile_id, role) + + ex_ids = [s.exercise_id for s in steps] + _assert_exercises_exist(cur, *ex_ids) + + try: + for i in range(n_seg): + a, b = steps[i], steps[i + 1] + _assert_variant_for_exercise(cur, a.exercise_id, a.variant_id) + _assert_variant_for_exercise(cur, b.exercise_id, b.variant_id) + note = None + if seg_notes is not None: + raw = seg_notes[i] + note = (raw or "").strip() or None if raw is not None else None + row = _insert_edge_row( + cur, + graph_id, + a.exercise_id, + a.variant_id, + b.exercise_id, + b.variant_id, + "next_exercise", + note, + ) + created.append(row) + conn.commit() + except IntegrityError as e: + conn.rollback() + raise HTTPException( + status_code=409, + detail="Sequenz konnte nicht vollständig angelegt werden (Duplikat oder ungültige Endpunkte). Keine Teilmenge gespeichert.", + ) from e + + return {"created": created, "count": len(created)} + + @router.put("/exercise-progression-graphs/{graph_id}/edges/{edge_id}") def update_progression_edge( graph_id: int, @@ -344,3 +478,30 @@ def delete_progression_edge( raise HTTPException(status_code=404, detail="Kante nicht gefunden") conn.commit() return {"ok": True} + + +@router.post("/exercise-progression-graphs/{graph_id}/edges/delete-batch") +def delete_progression_edges_batch( + graph_id: int, + body: EdgeIdsBatch, + session: dict = Depends(require_auth), +): + """Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt).""" + profile_id = session["profile_id"] + role = session.get("role") + ids = body.edge_ids + clean_ids = list(dict.fromkeys(ids)) + + with get_db() as conn: + cur = get_cursor(conn) + _graph_access(cur, graph_id, profile_id, role) + cur.execute( + f""" + DELETE FROM exercise_progression_edges + WHERE graph_id = %s AND id IN ({",".join(["%s"] * len(clean_ids))}) + """, + (graph_id, *clean_ids), + ) + deleted = cur.rowcount + conn.commit() + return {"ok": True, "deleted": deleted} diff --git a/backend/version.py b/backend/version.py index fade40a..85fbbe6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.6" +APP_VERSION = "0.8.7" BUILD_DATE = "2026-04-30" -DB_SCHEMA_VERSION = "20260430033" +DB_SCHEMA_VERSION = "20260430034" MODULE_VERSIONS = { "auth": "1.0.0", @@ -11,7 +11,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.2.0", # Progressionsgraph Übung→Übung (Migration 032, Router exercise_progression_graphs) + "exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034) "training_units": "0.1.0", "training_programs": "0.1.0", "planning": "0.3.0", @@ -23,6 +23,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.7", + "date": "2026-04-30", + "changes": [ + "DB 034: Progressionskanten mit optionalen Varianten-Endpunkten", + "API: POST …/edges/sequence (Reihe auf einmal); POST …/edges/delete-batch", + "Frontend Progressions-UI: Sequenz-Editor, Ketten-Ansicht, Variantenwahl", + ], + }, { "version": "0.8.6", "date": "2026-04-30", diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 296fd53..5314bc2 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -1,6 +1,6 @@ /** - * Verwaltung mehrerer Progressionsgraphen (Übung → Übung) mit Kantentypen Nachfolger / Schwester. - * Varianten-Ketten bleiben unter „Übungsvarianten“ der jeweiligen Übung. + * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht, + * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' @@ -19,6 +19,82 @@ 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 emptySeqStep() { + return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } +} + +function emptyEndpoint() { + return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } +} + export default function ExerciseProgressionGraphPanel({ anchorExerciseId = null, anchorTitle = null, @@ -36,15 +112,19 @@ export default function ExerciseProgressionGraphPanel({ const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') + const [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()]) + const [sequenceBulkNotes, setSequenceBulkNotes] = useState('') + const [pickContext, setPickContext] = useState(null) + const [relationKind, setRelationKind] = useState('progression') - const [firstEx, setFirstEx] = useState(null) - const [secondEx, setSecondEx] = useState(null) + const [firstEp, setFirstEp] = useState(emptyEndpoint) + const [secondEp, setSecondEp] = useState(emptyEndpoint) const [edgeNotes, setEdgeNotes] = useState('') - const [pickSlot, setPickSlot] = useState(null) const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') + const [uiTab, setUiTab] = useState('overview') const refreshGraphs = useCallback(async () => { const list = await api.listExerciseProgressionGraphs() @@ -61,6 +141,12 @@ 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 () => { @@ -106,6 +192,30 @@ 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( @@ -114,6 +224,17 @@ 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 handleCreateGraph = async (e) => { e.preventDefault() const name = newGraphName.trim() @@ -175,35 +296,109 @@ export default function ExerciseProgressionGraphPanel({ } } + const patchSeqStep = (idx, patch) => { + setSequenceSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s))) + } + + const addSeqStep = () => setSequenceSteps((prev) => [...prev, emptySeqStep()]) + + const removeSeqStep = (idx) => { + setSequenceSteps((prev) => { + if (prev.length <= 2) return prev + return prev.filter((_, i) => i !== idx) + }) + } + + const moveSeqStep = (idx, dir) => { + setSequenceSteps((prev) => { + const j = idx + dir + if (j < 0 || j >= prev.length) return prev + const next = [...prev] + const t = next[idx] + next[idx] = next[j] + next[j] = t + return next + }) + } + + const submitSequence = async () => { + if (!selectedGraphId) { + alert('Zuerst einen Graphen wählen.') + return + } + const steps = sequenceSteps.filter((s) => s.exerciseId != null) + if (steps.length < 2) { + alert('Mindestens zwei Schritte mit gewählter Übung.') + return + } + const n = steps.length - 1 + const noteRaw = sequenceBulkNotes.trim() + const segment_notes = Array.from({ length: n }, () => (noteRaw ? noteRaw : null)) + + setBusy(true) + try { + await api.createExerciseProgressionSequence(selectedGraphId, { + steps: steps.map((s) => ({ + exercise_id: s.exerciseId, + variant_id: s.variantId || null, + })), + segment_notes, + }) + setSequenceBulkNotes('') + await refreshEdges(selectedGraphId) + alert(`${n} Nachfolger-Kante(n) angelegt.`) + } catch (err) { + alert(err.message || String(err)) + } finally { + setBusy(false) + } + } + + const deleteChain = async (edgeObjs) => { + if (!selectedGraphId || !edgeObjs?.length) return + if (!confirm(`${edgeObjs.length} Kante(n) dieser Reihe löschen?`)) return + setBusy(true) + try { + await api.deleteExerciseProgressionEdgesBatch( + selectedGraphId, + edgeObjs.map((e) => e.id), + ) + await refreshEdges(selectedGraphId) + } catch (err) { + alert(err.message || String(err)) + } finally { + setBusy(false) + } + } + const handleAddEdge = async () => { if (!selectedGraphId) { - alert('Zuerst einen Graphen wählen oder anlegen.') + alert('Zuerst einen Graphen wählen.') return } - if (!firstEx?.id || !secondEx?.id) { - alert('Beide Übungen auswählen.') + if (!firstEp.exerciseId || !secondEp.exerciseId) { + alert('Beide Enden müssen eine Übung haben.') return } - if (firstEx.id === secondEx.id) { - alert('Es müssen zwei verschiedene Übungen sein.') + 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 = - relationKind === 'sibling' - ? { - from_exercise_id: firstEx.id, - to_exercise_id: secondEx.id, - edge_type, - notes, - } - : { - from_exercise_id: firstEx.id, - to_exercise_id: secondEx.id, - edge_type: 'next_exercise', - notes, - } + 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) @@ -252,18 +447,51 @@ export default function ExerciseProgressionGraphPanel({ } const swapEnds = () => { - const a = firstEx - setFirstEx(secondEx) - setSecondEx(a) + const a = firstEp + setFirstEp(secondEp) + setSecondEp(a) } - const onPicked = (ex) => { - const row = { id: ex.id, title: ex.title || `Übung #${ex.id}` } - if (pickSlot === 'first') setFirstEx(row) - else if (pickSlot === 'second') setSecondEx(row) - setPickSlot(null) + const applyPickedExercise = async (ex) => { + const title = ex.title || `Übung #${ex.id}` + const variants = await loadVariantsForExercise(ex.id) + + if (pickContext?.kind === 'sequence') { + patchSeqStep(pickContext.index, { + exerciseId: ex.id, + exerciseTitle: title, + variantId: null, + variants, + }) + setPickContext(null) + return + } + if (pickContext?.kind === 'single') { + const patch = { + exerciseId: ex.id, + exerciseTitle: title, + 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 + return (
{anchorExerciseId != null && ( @@ -276,9 +504,10 @@ export default function ExerciseProgressionGraphPanel({ )}

- Graphen bilden einen gerichteten Wald aus Übungen: Nachfolger ist „zuerst A, dann B“; - Schwestern markieren Alternativen oder parallele Entwicklungsschritte. Fortschritt{' '} - innerhalb einer Übung (Varianten, Progressionsstufen) pflegst du unter „Übungsvarianten“. + Pro Graph mehrere Reihen und Alternativen: eine{' '} + Sequenz legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an. + Optional pro Schritt eine Variante — sie wirkt wie ein eigener Knoten. Verzweigungen und + Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten.

{loadErr && ( @@ -311,7 +540,7 @@ export default function ExerciseProgressionGraphPanel({ disabled={busy || !selectedGraphId} onClick={() => refreshEdges(selectedGraphId)} > - Kanten neu laden + Aktualisieren - {anchorExerciseId != null && ( - - )} -
- -
- -
- - {secondEx ? ( - <> - {secondEx.title} - (#{secondEx.id}) - - ) : ( - — nicht gewählt — - )} - - - {anchorExerciseId != null && ( - - )} -
-
- - - {relationKind === 'progression' && ( - - )} - -
- -