Enhance Exercise Progression Graph Panel and Path Builder with New Features
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.
This commit is contained in:
Lars 2026-06-10 11:17:05 +02:00
parent 800189ff8f
commit 8d5f0b533c
3 changed files with 224 additions and 55 deletions

View File

@ -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 && (
<>
<ProgressionChainEditor
ref={chainEditorRef}
graphId={selectedGraphId}
chains={flowChains}
busy={busy}
anchorExerciseId={anchorExerciseId}
anchorTitle={anchorTitle}
onRefresh={async () => refreshEdges(selectedGraphId)}
onPickExercise={setPickContext}
loadVariantsForExercise={loadVariantsForExercise}
/>
<div
className="card"
style={{
marginBottom: '12px',
borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))',
}}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Progressionspfad</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Der Graph enthält typischerweise <strong>einen linearen Pfad</strong> (Übung Übung). Unten
manuell bearbeiten oder mit dem KI-Planer erweitern Speichern im KI-Wizard ersetzt den Pfad.
</p>
<ProgressionChainEditor
ref={chainEditorRef}
graphId={selectedGraphId}
chains={primaryChain ? [primaryChain] : []}
busy={busy}
anchorExerciseId={anchorExerciseId}
anchorTitle={anchorTitle}
onRefresh={async () => refreshEdges(selectedGraphId)}
onPickExercise={setPickContext}
loadVariantsForExercise={loadVariantsForExercise}
singlePathMode
/>
<ExerciseProgressionPathBuilder
graphId={selectedGraphId}
disabled={busy}
graphChainNodes={primaryChain?.nodes ?? null}
graphChainEdgeIds={primaryChain?.edges?.map((e) => e.id) ?? null}
onSaved={async () => {
await refreshEdges(selectedGraphId)
}}
/>
</div>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Schwestern & Alternativen</h3>
@ -671,19 +703,6 @@ export default function ExerciseProgressionGraphPanel({
</div>
</details>
<details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>KI: Neuen Pfad planen (4 Schritte)</summary>
<div style={{ marginTop: '14px' }}>
<ExerciseProgressionPathBuilder
graphId={selectedGraphId}
disabled={busy}
onSaved={async () => {
await refreshEdges(selectedGraphId)
}}
/>
</div>
</details>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."

View File

@ -2,6 +2,7 @@
* Planungs-KI Phase C3/E3: Ziel Übungspfad vorschlagen Lücken mit KI anlegen in Graph speichern.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
@ -134,6 +135,122 @@ const OFFER_SOURCE_LABELS = {
roadmap_unfilled: 'Roadmap-Stufe',
}
function normalizeTitleKey(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
}
function mergeGraphIntoPathSteps(pathRows, graphNodes) {
if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows
return pathRows.map((row, i) => {
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 (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px dashed var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Im Graph gespeichert:</strong> noch kein Pfad der erste
Speichervorgang legt die Übungsfolge an.
</div>
)
}
return (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '8px', color: 'var(--accent-dark)' }}>
Im Graph gespeichert ({nodes.length} Schritte)
{hasDraft ? (
<span style={{ fontWeight: 400, color: 'var(--text3)', marginLeft: '8px' }}>
KI-Entwurf unten; Speichern ersetzt diesen Pfad
</span>
) : null}
</strong>
<ol style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.55 }}>
{nodes.map((node, idx) => (
<li key={`saved-${node.exercise_id}-${node.variant_id}-${idx}`}>
<Link to={`/exercises/${node.exercise_id}`}>{node.title || `Übung #${node.exercise_id}`}</Link>
{node.variant_name ? (
<span style={{ color: 'var(--text3)' }}>{` · ${node.variant_name}`}</span>
) : null}
</li>
))}
</ol>
</div>
)
}
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 (
<div
className="card"
style={{
marginBottom: '12px',
borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid var(--border)',
}}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Progressionspfad planen (KI)</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
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.
</p>
<SavedGraphPathStrip nodes={graphChainNodes} hasDraft={pathSteps.length > 0} />
<PlanningWizardStepper
currentStep={wizardStep}
maxReachable={maxReachableStep}
@ -1759,6 +1895,7 @@ export default function ExerciseProgressionPathBuilder({
: ''}
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
{step.isOffTopic ? ' (themenfremd)' : ''}
{step.isFromGraph ? ' (im Graph)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
</label>
{step.roadmapLearningGoal ? (
@ -1922,8 +2059,8 @@ export default function ExerciseProgressionPathBuilder({
color: 'var(--accent-dark)',
}}
>
<strong>Wichtig:</strong> Der KI-Pfad ist noch nicht im Graph gespeichert. KI-Übungen werden erst nach
«Pfad in Graph speichern» als neue Reihe oben sichtbar.
<strong>Wichtig:</strong> Der KI-Entwurf ist noch nicht gespeichert. «Pfad im Graph speichern» übernimmt
ihn und ersetzt den oben gezeigten Pfad.
{pathInsertNotice ? <p style={{ margin: '8px 0 0' }}>{pathInsertNotice}</p> : null}
</div>
@ -2042,6 +2179,7 @@ export default function ExerciseProgressionPathBuilder({
<span style={{ color: 'var(--danger)' }}> noch nicht angelegt</span>
)}
{step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''}
{step.isFromGraph ? ' · bereits im Graph' : ''}
</li>
))}
</ol>
@ -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'}
</button>
<button
type="button"

View File

@ -66,6 +66,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
onRefresh,
onPickExercise,
loadVariantsForExercise,
singlePathMode = false,
},
ref,
) {
@ -258,7 +259,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
if (!graphId) return null
return (
<div className="card" style={{ marginBottom: '12px' }}>
<div style={{ marginBottom: '16px' }}>
<div
style={{
display: 'flex',
@ -270,20 +271,25 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
}}
>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Reihen im Graph</h3>
<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 }}>
Jede Reihe ist ein linearer Pfad: Schritt 1 2 Reihenfolge ändern, Übungen tauschen oder
dazwischen einfügen, dann speichern.
Schritt 1 2 : Reihenfolge ändern, Übungen tauschen oder dazwischen einfügen, dann speichern.
</p>
</div>
<button type="button" className="btn btn-primary" disabled={busy} onClick={addNewChain}>
+ Neue Reihe
</button>
{!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' }}>
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.'}
</p>
) : (
drafts.map((draft, chainIdx) => (
@ -310,7 +316,9 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
marginBottom: '12px',
}}
>
<strong style={{ fontSize: '13px' }}>Reihe {chainIdx + 1}</strong>
<strong style={{ fontSize: '13px' }}>
{singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`}
</strong>
{draft.dirty ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
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'}
</button>
{draft.dirty ? (
<button
@ -495,7 +503,7 @@ const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
disabled={busy || savingKey === draft.key}
onClick={() => deleteChain(draft)}
>
Reihe löschen
{singlePathMode ? 'Pfad löschen' : 'Reihe löschen'}
</button>
</div>
</div>