diff --git a/backend/steering/graph/join_branch.py b/backend/steering/graph/join_branch.py
new file mode 100644
index 0000000..b2b5ac6
--- /dev/null
+++ b/backend/steering/graph/join_branch.py
@@ -0,0 +1,36 @@
+"""Join- und Branch-Topologie — Reservierung für AP1.15c+.
+
+Heute blockiert `roadmap_engine.compute_initiative_graph_state` nur über
+`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema
+vorhanden, werden aber noch nicht für blocked/ready ausgewertet.
+
+Erweiterungspunkt: neue Auswertung hier kapseln, Engine ruft dann
+`apply_topology_to_blocked_map(...)` auf — ohne Router- oder UI-Sonderlogik.
+
+Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c
+DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
+
+# Designer AP1.15c; OR-Pfade nur nach validiertem Use Case + PO-Freigabe
+PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",)
+
+
+def apply_deferred_topology(
+ *,
+ dependencies: list[dict[str, Any]],
+ item_by_id: dict[str, dict[str, Any]],
+ blocked_by_map: dict[str, list[str]],
+) -> dict[str, list[str]]:
+ """
+ Erweitert blocked_by_map um parallel_group / optional_branch (AP1.15c).
+
+ Aktuell: No-Op — gibt blocked_by_map unverändert zurück.
+ """
+ _ = (dependencies, item_by_id)
+ return blocked_by_map
diff --git a/backend/steering/graph/roadmap_engine.py b/backend/steering/graph/roadmap_engine.py
index 16cd8ee..6aab5d0 100644
--- a/backend/steering/graph/roadmap_engine.py
+++ b/backend/steering/graph/roadmap_engine.py
@@ -10,6 +10,7 @@ from steering.graph.profiles import (
get_graph_profile,
graph_profile_as_dict,
)
+from steering.graph.join_branch import apply_deferred_topology
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
@@ -106,6 +107,12 @@ def compute_initiative_graph_state(
if prereq_id not in blocked_by_map[item_id]:
blocked_by_map[item_id].append(prereq_id)
+ blocked_by_map = apply_deferred_topology(
+ dependencies=dependencies,
+ item_by_id=item_by_id,
+ blocked_by_map=blocked_by_map,
+ )
+
item_states: dict[str, dict[str, Any]] = {}
blocked_items: list[str] = []
ready_items: list[str] = []
diff --git a/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md b/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md
index 94e5ab1..fa93f20 100644
--- a/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md
+++ b/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md
@@ -221,6 +221,34 @@ Plan-UI: **Detail-Modal/Route** `/initiatives/:id/plan/items/:itemId` — Checkl
| **AP1.14** (vorgeschlagen) | Plan-Snapshot, Ist-Overlay, Diff, Plan-Revision mit Audit |
| **AP1.15** (vorgeschlagen) | Zielzustands-Modellierungswerkzeug (Canvas-Designer) |
+| **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage |
+| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
+| **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer |
+
+---
+
+## Join / Branch — bewusst zurückgestellt, vorbereitet (PO 2026-07-11)
+
+**Entscheidung:** Volle Verzweigungslogik (Parallel-Join, optionale Äste, ggf. Alternativpfade/OR) wird **nicht** im aktuellen Slice implementiert. Die Praxis soll zeigen, ob OR-Pfade nötig sind (z. B. persönliche Entwicklung). Bis dahin reicht **AND-Join** über Join-Gates mit mehreren `requires`-Kanten.
+
+**Geplant (AP1.15c+):**
+
+| Element | Semantik | Modell |
+|---------|----------|--------|
+| AND-Join | Alle Voraussetzungen `reached` | Join-Gate + mehrere `requires` (**heute**) |
+| Parallel | Gleichzeitige Stränge markieren | `parallel_group` + `group_key` |
+| Optional | Ast zählt nur wenn „committed“ | `optional_branch` |
+| OR / Alternativpfad | Eine von mehreren Voraussetzungen reicht | **Später**, nur bei validiertem Use Case |
+| Split/Join (UI) | Lesbarkeit im Designer | Visuelle Hilfen — **keine** Workflow-Knoten |
+
+**Vorbereitung der Implementierung (ab jetzt):**
+
+- Kantentypen bleiben in `roadmap_item_dependencies.dependency_type` — **keine** Joint-Tabelle.
+- Graph Engine: Blocking-Logik bleibt in `backend/steering/graph/`; Erweiterung über `join_branch.py` (Reservierung AP1.15c).
+- Designer: nur `requires` / `blocks` / `related` bis AP1.15c; deferred Typen in `gateGraphTopology.js`.
+- Ist-Read-Models (`blocked`, `ready`) bleiben **getrennt** vom Plan-Designer (Steuerung vs. Modellierung).
+
+**Guardrail unverändert:** Zielzustands-Graph — kein Workflow, kein OR-Knoten ohne PO-Freigabe.
---
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index dfe4d83..1175161 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -105,8 +105,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
| Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt |
-| Zielzustands-Designer (Joint/Parallel) | ✗ | AP1.4d + AP1.15; AP1.13 nur Auto-Layout |
-| Plan-Revision / Graph-Historie | ✗ | AP1.14 vorgeschlagen |
+| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a Designer MVP; Join/Branch → AP1.15c (deferred, vorbereitet) |
+| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
| Decision bei `moved` | ✗ | |
diff --git a/docs/product/Kairo_Plan_Mode_Design_v0.1.md b/docs/product/Kairo_Plan_Mode_Design_v0.1.md
index f673a78..c4b510f 100644
--- a/docs/product/Kairo_Plan_Mode_Design_v0.1.md
+++ b/docs/product/Kairo_Plan_Mode_Design_v0.1.md
@@ -243,6 +243,9 @@ AP1.13b Kanten-CRUD auf Gate-Detail (kein Designer)
AP1.4d Graph Engine: parallel_group, edge_kind, blocked/ready
AP1.14 Plan-Snapshot / Ist-Overlay / Plan-Revision (vorgeschlagen)
AP1.15 Zielzustands-Modellierungswerkzeug (Designer, Mitai-Pattern)
+AP1.15a Designer MVP (Pan/Zoom, Kanten, Layout)
+AP1.15b Designer UX (Drag-Kanten, Gate anlegen, Klick-Panel)
+AP1.15c Join/Branch-Topologie (parallel_group, optional_branch, Engine) — deferred, vorbereitet
AP1.5d Tasks in Outline-Knoten „Arbeit“
AP1.10 Archetyp + EFS für Initiative-Profil-Modal (parallel ab 10a)
```
diff --git a/frontend/src/components/GateDesignerNodePanel.jsx b/frontend/src/components/GateDesignerNodePanel.jsx
new file mode 100644
index 0000000..5d87a33
--- /dev/null
+++ b/frontend/src/components/GateDesignerNodePanel.jsx
@@ -0,0 +1,83 @@
+import { Link } from 'react-router-dom'
+import { gatePath } from '../utils/routes.js'
+import {
+ MILESTONE_STATUS_LABELS,
+ ROADMAP_ITEM_TYPE_LABELS,
+} from '../constants/status.js'
+import { StatusBadge } from './StatusBadge.jsx'
+
+function formatDate(value) {
+ if (!value) return null
+ try {
+ return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
+ 'de-DE',
+ )
+ } catch {
+ return value
+ }
+}
+
+export function GateDesignerNodePanel({
+ item,
+ criteriaProgress,
+ onClose,
+}) {
+ if (!item) return null
+
+ const progress = criteriaProgress?.[item.id]
+ const closed = progress?.closed ?? 0
+ const total = progress?.total ?? 0
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/GateGraphDesigner.jsx b/frontend/src/components/GateGraphDesigner.jsx
index d3222c6..4b1a237 100644
--- a/frontend/src/components/GateGraphDesigner.jsx
+++ b/frontend/src/components/GateGraphDesigner.jsx
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
import {
addRoadmapItemDependency,
deleteRoadmapDependency,
+ listInitiativeRoadmapCriteriaProgress,
listInitiativeRoadmapDependencies,
} from '../api/roadmap.js'
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
-import { gatePath } from '../utils/routes.js'
-import { computeGateGraphLayout } from '../plan/gateGraphLayout.js'
+import { computeGateGraphLayout, GATE_NODE_HEIGHT, GATE_NODE_WIDTH } from '../plan/gateGraphLayout.js'
import {
applyManualGatePositions,
clampDesignerZoom,
@@ -16,21 +15,13 @@ import {
loadManualGatePositions,
storeManualGatePositions,
} from '../plan/gateGraphDesigner.js'
+import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS } from '../plan/gateGraphTopology.js'
import { LoadingState } from './LoadingState.jsx'
import { ErrorState } from './ErrorState.jsx'
import { EmptyState } from './EmptyState.jsx'
-
-const EDGE_TYPE_LABELS = {
- requires: 'Voraussetzung',
- blocks: 'Blockiert',
- related: 'Bezug',
-}
-
-const CONNECT_HINTS = {
- requires: 'Schritt 1: Voraussetzung wählen · Schritt 2: abhängiges Gate',
- blocks: 'Schritt 1: Blocker wählen · Schritt 2: geblocktes Gate',
- related: 'Schritt 1: erstes Gate · Schritt 2: zweites Gate',
-}
+import { Modal } from './Modal.jsx'
+import { RoadmapItemForm } from './RoadmapItemForm.jsx'
+import { GateDesignerNodePanel } from './GateDesignerNodePanel.jsx'
function edgePath(edge) {
const midY = (edge.y1 + edge.y2) / 2
@@ -42,13 +33,6 @@ function truncateTitle(title, max = 28) {
return `${title.slice(0, max - 1)}…`
}
-/**
- * @param {DOMRect} rect
- * @param {{ x: number, y: number }} pan
- * @param {number} zoom
- * @param {number} clientX
- * @param {number} clientY
- */
function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
return {
x: (clientX - rect.left - pan.x) / zoom,
@@ -56,30 +40,50 @@ function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
}
}
+function nodeAtPoint(nodes, point) {
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
+ const node = nodes[i]
+ if (
+ point.x >= node.x &&
+ point.x <= node.x + node.width &&
+ point.y >= node.y &&
+ point.y <= node.y + node.height
+ ) {
+ return node
+ }
+ }
+ return null
+}
+
export function GateGraphDesigner({
initiativeId,
items,
canManage = false,
+ busy: busyProp = false,
+ onCreate,
onGraphChanged,
}) {
- const navigate = useNavigate()
const viewportRef = useRef(null)
const [dependencies, setDependencies] = useState([])
+ const [criteriaProgress, setCriteriaProgress] = useState({})
const [manualPositions, setManualPositions] = useState(() =>
loadManualGatePositions(initiativeId),
)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
- const [busy, setBusy] = useState(false)
- const [tool, setTool] = useState('pan')
+ const [busyLocal, setBusyLocal] = useState(false)
const [edgeType, setEdgeType] = useState('requires')
- const [connectFromId, setConnectFromId] = useState(null)
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+ const [showCreate, setShowCreate] = useState(false)
+ const [createPosition, setCreatePosition] = useState(null)
+ const [edgeDrag, setEdgeDrag] = useState(null)
const [pan, setPan] = useState({ x: 24, y: 24 })
const [zoom, setZoom] = useState(1)
const [draggingNodeId, setDraggingNodeId] = useState(null)
const dragOffsetRef = useRef({ x: 0, y: 0 })
const panStartRef = useRef(null)
+ const busy = busyProp || busyLocal
const reloadDependencies = useCallback(async () => {
if (!initiativeId) {
@@ -93,13 +97,20 @@ export function GateGraphDesigner({
useEffect(() => {
if (!initiativeId) {
setDependencies([])
+ setCriteriaProgress({})
setLoading(false)
return undefined
}
let cancelled = false
setLoading(true)
setError(null)
- reloadDependencies()
+ Promise.all([
+ reloadDependencies(),
+ listInitiativeRoadmapCriteriaProgress(initiativeId).catch(() => ({})),
+ ])
+ .then(([_, progress]) => {
+ if (!cancelled) setCriteriaProgress(progress || {})
+ })
.catch((err) => {
if (!cancelled) {
setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.')
@@ -111,12 +122,12 @@ export function GateGraphDesigner({
return () => {
cancelled = true
}
- }, [initiativeId, reloadDependencies])
+ }, [initiativeId, reloadDependencies, items])
useEffect(() => {
setManualPositions(loadManualGatePositions(initiativeId))
- setConnectFromId(null)
setSelectedEdgeId(null)
+ setSelectedNodeId(null)
}, [initiativeId])
const baseLayout = useMemo(
@@ -129,6 +140,11 @@ export function GateGraphDesigner({
[baseLayout, manualPositions],
)
+ const selectedItem = useMemo(
+ () => items.find((item) => item.id === selectedNodeId) || null,
+ [items, selectedNodeId],
+ )
+
async function notifyGraphChanged() {
await reloadDependencies()
onGraphChanged?.()
@@ -136,7 +152,7 @@ export function GateGraphDesigner({
async function handleCreateEdge(fromId, toId) {
if (!fromId || !toId || fromId === toId) return
- setBusy(true)
+ setBusyLocal(true)
setError(null)
try {
let fromItemId = fromId
@@ -149,18 +165,20 @@ export function GateGraphDesigner({
to_item_id: toItemId,
dependency_type: edgeType,
})
- setConnectFromId(null)
await notifyGraphChanged()
} catch (err) {
setError(err?.message || 'Kante konnte nicht angelegt werden.')
} finally {
- setBusy(false)
+ setBusyLocal(false)
}
}
+ const handleCreateEdgeRef = useRef(handleCreateEdge)
+ handleCreateEdgeRef.current = handleCreateEdge
+
async function handleDeleteEdge(edgeId) {
if (!edgeId || !canManage) return
- setBusy(true)
+ setBusyLocal(true)
setError(null)
try {
await deleteRoadmapDependency(edgeId)
@@ -169,35 +187,69 @@ export function GateGraphDesigner({
} catch (err) {
setError(err?.message || 'Kante konnte nicht entfernt werden.')
} finally {
- setBusy(false)
+ setBusyLocal(false)
+ }
+ }
+
+ function openCreateAt(point) {
+ setCreatePosition({
+ x: Math.max(0, point.x - GATE_NODE_WIDTH / 2),
+ y: Math.max(0, point.y - GATE_NODE_HEIGHT / 2),
+ })
+ setShowCreate(true)
+ }
+
+ async function handleCreateGateSubmit(payload) {
+ if (!onCreate) return
+ setBusyLocal(true)
+ setError(null)
+ try {
+ const created = await onCreate(payload)
+ if (created?.id && createPosition) {
+ setManualPositions((prev) => {
+ const next = { ...prev, [created.id]: createPosition }
+ storeManualGatePositions(initiativeId, next)
+ return next
+ })
+ setSelectedNodeId(created.id)
+ }
+ setShowCreate(false)
+ setCreatePosition(null)
+ onGraphChanged?.()
+ } catch (err) {
+ setError(err?.message || 'Gate konnte nicht angelegt werden.')
+ } finally {
+ setBusyLocal(false)
}
}
function handleNodeClick(nodeId, event) {
event.stopPropagation()
- if (busy) return
+ if (busy || edgeDrag) return
+ setSelectedNodeId(nodeId)
+ setSelectedEdgeId(null)
+ }
- if (tool === 'connect' && canManage) {
- if (!connectFromId) {
- setConnectFromId(nodeId)
- setSelectedEdgeId(null)
- return
- }
- if (connectFromId === nodeId) {
- setConnectFromId(null)
- return
- }
- handleCreateEdge(connectFromId, nodeId)
- return
- }
-
- if (event.detail === 2 && tool === 'pan') {
- navigate(gatePath(nodeId))
- }
+ function handlePortPointerDown(node, event) {
+ if (!canManage || event.button !== 0 || busy) return
+ event.stopPropagation()
+ const rect = viewportRef.current?.getBoundingClientRect()
+ if (!rect) return
+ const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
+ setEdgeDrag({
+ fromNodeId: node.id,
+ fromX: node.x + node.width / 2,
+ fromY: node.y + node.height,
+ toX: point.x,
+ toY: point.y,
+ })
+ setSelectedNodeId(null)
+ setSelectedEdgeId(null)
}
function handleNodePointerDown(node, event) {
- if (!canManage || tool !== 'pan' || event.button !== 0) return
+ if (!canManage || event.button !== 0) return
+ if (event.target.classList?.contains('gate-graph-designer__port')) return
event.stopPropagation()
const rect = viewportRef.current?.getBoundingClientRect()
if (!rect) return
@@ -214,11 +266,9 @@ export function GateGraphDesigner({
if (target.classList?.contains('gate-map-view__edge')) return
setSelectedEdgeId(null)
- if (tool === 'connect') {
- setConnectFromId(null)
- return
- }
- if (tool !== 'pan' || draggingNodeId) return
+ setSelectedNodeId(null)
+
+ if (draggingNodeId || edgeDrag) return
panStartRef.current = {
startX: event.clientX,
@@ -228,8 +278,26 @@ export function GateGraphDesigner({
}
}
+ function handleViewportDoubleClick(event) {
+ if (!canManage || !onCreate) return
+ const rect = viewportRef.current?.getBoundingClientRect()
+ if (!rect) return
+ const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
+ openCreateAt(point)
+ }
+
useEffect(() => {
function handlePointerMove(event) {
+ if (edgeDrag) {
+ const rect = viewportRef.current?.getBoundingClientRect()
+ if (!rect) return
+ const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
+ setEdgeDrag((current) =>
+ current ? { ...current, toX: point.x, toY: point.y } : null,
+ )
+ return
+ }
+
if (draggingNodeId && canManage) {
const rect = viewportRef.current?.getBoundingClientRect()
if (!rect) return
@@ -254,7 +322,18 @@ export function GateGraphDesigner({
}
}
- function handlePointerUp() {
+ function handlePointerUp(event) {
+ if (edgeDrag) {
+ const rect = viewportRef.current?.getBoundingClientRect()
+ if (rect) {
+ const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY)
+ const targetNode = nodeAtPoint(layout.nodes, point)
+ if (targetNode && targetNode.id !== edgeDrag.fromNodeId) {
+ handleCreateEdgeRef.current(edgeDrag.fromNodeId, targetNode.id)
+ }
+ }
+ setEdgeDrag(null)
+ }
setDraggingNodeId(null)
panStartRef.current = null
}
@@ -265,7 +344,7 @@ export function GateGraphDesigner({
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
- }, [canManage, draggingNodeId, initiativeId, pan, zoom])
+ }, [canManage, draggingNodeId, edgeDrag, initiativeId, layout.nodes, pan, zoom])
function handleWheel(event) {
event.preventDefault()
@@ -280,14 +359,54 @@ export function GateGraphDesigner({
setZoom(1)
}
- if (!items.length) {
+ function defaultCreatePoint() {
+ const rect = viewportRef.current?.getBoundingClientRect()
+ if (!rect) return { x: 80, y: 80 }
+ return clientToGraphPoint(
+ rect,
+ pan,
+ zoom,
+ rect.left + rect.width / 2,
+ rect.top + rect.height / 2,
+ )
+ }
+
+ if (loading && items.length > 0) {
+ return
- Zielzustands-Graph modellieren — Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify - und Kriterien bleiben auf der Gate-Detailseite. + Zielzustands-Graph modellieren — Knoten verschieben, Kanten vom unteren Anker ziehen, + Klick öffnet Infos. Verify und Kriterien auf der Gate-Detailseite.
- {CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'} - {connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''} + Vom unteren Punkt eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein + Gate an. Klick auf Gate öffnet das Info-Panel.
)} - {!canManage && ( -- Nur Ansicht — zum Modellieren fehlt die Berechtigung „Plan verwalten“. -
- )} - -- Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert. -
- )} +