AP1.13a: Read-only Gate-Graph aus Abhaengigkeiten.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled

Initiative-API fuer Roadmap-Dependencies, SVG-Layout mit Liste/Graph-Umschalter auf der Zielzustaende-Seite.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 15:50:20 +02:00
parent 83fdb9a2a2
commit 7f03504c2d
10 changed files with 712 additions and 2 deletions

View File

@ -115,6 +115,19 @@ def list_initiative_roadmap_items(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/dependencies")
def list_initiative_roadmap_dependencies(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_dependencies_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
def create_initiative_roadmap_item(
initiative_id: str,

View File

@ -484,6 +484,42 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
conn.close()
def list_dependencies_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
d.dependency_type, d.created_at
FROM roadmap_item_dependencies d
INNER JOIN roadmap_items fi
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
INNER JOIN roadmaps r
ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id
WHERE d.tenant_id = %s AND r.initiative_id = %s
ORDER BY d.created_at ASC
""",
(tenant_id, initiative_id),
)
items = []
for row in cur.fetchall():
dep = dict(row)
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
dep[key] = str(dep[key])
if dep.get("created_at"):
dep["created_at"] = dep["created_at"].isoformat()
items.append(dep)
return items
finally:
conn.close()
def add_dependency(
*,
tenant_id: str,

View File

@ -120,3 +120,29 @@ def test_steering_snapshot_reads_roadmap_items(client):
assert snap.get("roadmap_items")
assert snap.get("upcoming_milestones")
assert len(snap["upcoming_roadmap_items"]) >= 1
def test_initiative_roadmap_dependencies_list(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
created = client.post(
f"/api/roadmap-items/{gate_a['id']}/dependencies",
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
headers=_auth(token),
)
assert created.status_code == 201
listed = client.get(
f"/api/initiatives/{initiative_id}/roadmap/dependencies",
headers=_auth(token),
)
assert listed.status_code == 200
deps = listed.json()
assert len(deps) == 1
assert deps[0]["from_item_id"] == gate_a["id"]
assert deps[0]["to_item_id"] == gate_b["id"]
assert deps[0]["dependency_type"] == "requires"

View File

@ -4,6 +4,10 @@ export function listInitiativeRoadmapItems(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
}
export function listInitiativeRoadmapDependencies(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
}
export function createInitiativeRoadmapItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
method: 'POST',

View File

@ -0,0 +1,186 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { 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 { LoadingState } from './LoadingState.jsx'
import { ErrorState } from './ErrorState.jsx'
import { EmptyState } from './EmptyState.jsx'
function edgePath(edge) {
const midY = (edge.y1 + edge.y2) / 2
return `M ${edge.x1} ${edge.y1} C ${edge.x1} ${midY}, ${edge.x2} ${midY}, ${edge.x2} ${edge.y2}`
}
function truncateTitle(title, max = 28) {
if (!title || title.length <= max) return title || 'Gate'
return `${title.slice(0, max - 1)}`
}
export function GateMapView({ initiativeId, items }) {
const navigate = useNavigate()
const [dependencies, setDependencies] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (!initiativeId) {
setDependencies([])
setLoading(false)
return undefined
}
let cancelled = false
setLoading(true)
setError(null)
listInitiativeRoadmapDependencies(initiativeId)
.then((data) => {
if (!cancelled) setDependencies(Array.isArray(data) ? data : [])
})
.catch((err) => {
if (!cancelled) setError(err.message || 'Abhängigkeiten konnten nicht geladen werden.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [initiativeId])
const layout = useMemo(
() => computeGateGraphLayout(items, dependencies),
[items, dependencies],
)
if (!items.length) {
return (
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)
}
if (loading) {
return <LoadingState message="Gate-Graph wird geladen …" />
}
if (error) {
return <ErrorState message={error} />
}
return (
<section className="card gate-map-view">
<div className="section-header">
<div>
<h2>Zielzustände (Graph)</h2>
<p className="section-lead muted">
Read-only Übersicht der Gate-Abhängigkeiten Bearbeitung auf der Gate-Detailseite
(AP1.13b).
</p>
</div>
</div>
<div className="gate-map-view__legend muted">
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--requires" />
Voraussetzung
</span>
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--blocks" />
Blockiert
</span>
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
Bezug
</span>
</div>
<div className="gate-map-view__canvas-wrap">
<svg
className="gate-map-view__canvas"
width={layout.width}
height={layout.height}
role="img"
aria-label="Gate-Abhängigkeitsgraph"
>
<defs>
<marker
id="gate-map-arrow-requires"
markerWidth="8"
markerHeight="8"
refX="6"
refY="4"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 Z" className="gate-map-view__arrow gate-map-view__arrow--requires" />
</marker>
<marker
id="gate-map-arrow-blocks"
markerWidth="8"
markerHeight="8"
refX="6"
refY="4"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 Z" className="gate-map-view__arrow gate-map-view__arrow--blocks" />
</marker>
</defs>
{layout.edges.map((edge) => (
<path
key={edge.id}
d={edgePath(edge)}
className={`gate-map-view__edge gate-map-view__edge--${edge.dependency_type}`}
markerEnd={
edge.dependency_type === 'related'
? undefined
: `url(#gate-map-arrow-${edge.dependency_type})`
}
/>
))}
{layout.nodes.map((node) => {
const item = node.item
return (
<g
key={node.id}
className={`gate-map-view__node gate-map-view__node--${item.status || 'planned'}`}
transform={`translate(${node.x}, ${node.y})`}
role="link"
tabIndex={0}
aria-label={`${item.title} öffnen`}
onClick={() => navigate(gatePath(node.id))}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
navigate(gatePath(node.id))
}
}}
>
<rect
className="gate-map-view__node-box"
width={node.width}
height={node.height}
rx="8"
ry="8"
/>
<text className="gate-map-view__node-title" x="12" y="28">
{truncateTitle(item.title)}
</text>
<text className="gate-map-view__node-meta" x="12" y="50">
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}
</text>
</g>
)
})}
</svg>
</div>
{dependencies.length === 0 && (
<p className="muted gate-map-view__hint">
Noch keine Kanten Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den
Gate-Detailseiten gepflegt werden (AP1.13b).
</p>
)}
</section>
)
}

View File

@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
import { GateMapView } from './GateMapView.jsx'
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
export function GatesPlanPanel({
initiativeId,
items,
canManage,
onCreate,
onReorder,
onDelete,
busy,
}) {
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
useEffect(() => {
setViewMode(resolveGatesViewMode(initiativeId, items.length))
}, [initiativeId, items.length])
function handleViewChange(mode) {
setViewMode(mode)
storeGatesViewMode(initiativeId, mode)
}
return (
<div className="gates-plan-panel">
<div className="gates-plan-panel__toolbar" role="tablist" aria-label="Zielzustände Ansicht">
<button
type="button"
role="tab"
aria-selected={viewMode === 'list'}
className={
'gates-plan-panel__tab' + (viewMode === 'list' ? ' gates-plan-panel__tab--active' : '')
}
onClick={() => handleViewChange('list')}
>
Liste
</button>
<button
type="button"
role="tab"
aria-selected={viewMode === 'graph'}
className={
'gates-plan-panel__tab' + (viewMode === 'graph' ? ' gates-plan-panel__tab--active' : '')
}
onClick={() => handleViewChange('graph')}
>
Graph
</button>
</div>
{viewMode === 'list' ? (
<RoadmapPlanSection
initiativeId={initiativeId}
items={items}
canManage={canManage}
onCreate={onCreate}
onReorder={onReorder}
onDelete={onDelete}
busy={busy}
/>
) : (
<GateMapView initiativeId={initiativeId} items={items} />
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
export function InitiativePlanPage() {
const {
@ -20,7 +20,7 @@ export function InitiativePlanPage() {
return (
<>
{error && <p className="error">{error}</p>}
<RoadmapPlanSection
<GatesPlanPanel
initiativeId={initiativeId}
items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')}

View File

@ -0,0 +1,194 @@
/** Layout-Konstanten für read-only Gate-Graph (AP1.13a). */
export const GATE_NODE_WIDTH = 200
export const GATE_NODE_HEIGHT = 72
export const GATE_LAYER_GAP_Y = 56
export const GATE_NODE_GAP_X = 48
export const GATE_GRAPH_PADDING = 32
/**
* @typedef {{ id: string, title?: string, status?: string, sort_order?: number }} GateNode
* @typedef {{ from_item_id: string, to_item_id: string, dependency_type: string }} GateDependency
* @typedef {{ id: string, x: number, y: number, width: number, height: number, item: GateNode }} PositionedGateNode
* @typedef {{ id: string, fromId: string, toId: string, dependency_type: string, x1: number, y1: number, x2: number, y2: number }} GateGraphEdge
*/
/**
* Kanten für Schichtung: requires = Voraussetzung abhängiges Gate;
* blocks = Blocker geblocktes Gate.
*
* @param {GateDependency[]} dependencies
* @returns {{ from: string, to: string, dependency_type: string }[]}
*/
export function buildLayoutEdges(dependencies) {
const edges = []
for (const dep of dependencies) {
if (dep.dependency_type === 'requires') {
edges.push({
from: dep.to_item_id,
to: dep.from_item_id,
dependency_type: dep.dependency_type,
})
} else if (dep.dependency_type === 'blocks') {
edges.push({
from: dep.from_item_id,
to: dep.to_item_id,
dependency_type: dep.dependency_type,
})
}
}
return edges
}
/**
* @param {GateNode[]} items
* @param {GateDependency[]} dependencies
* @returns {{ nodes: PositionedGateNode[], edges: GateGraphEdge[], width: number, height: number }}
*/
export function computeGateGraphLayout(items, dependencies) {
if (!items.length) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const sortedItems = [...items].sort(
(a, b) =>
(a.sort_order ?? 0) - (b.sort_order ?? 0) ||
(a.title || '').localeCompare(b.title || '') ||
a.id.localeCompare(b.id),
)
const layoutEdges = buildLayoutEdges(dependencies)
const layerById = new Map()
for (const item of sortedItems) {
layerById.set(item.id, 0)
}
const maxIterations = sortedItems.length + 1
for (let i = 0; i < maxIterations; i += 1) {
let changed = false
for (const edge of layoutEdges) {
const fromLayer = layerById.get(edge.from) ?? 0
const toLayer = layerById.get(edge.to) ?? 0
const nextLayer = fromLayer + 1
if (nextLayer > toLayer) {
layerById.set(edge.to, nextLayer)
changed = true
}
}
if (!changed) break
}
const layers = new Map()
for (const item of sortedItems) {
const layer = layerById.get(item.id) ?? 0
if (!layers.has(layer)) layers.set(layer, [])
layers.get(layer).push(item)
}
const layerIndices = [...layers.keys()].sort((a, b) => a - b)
const maxLayerWidth = Math.max(
...layerIndices.map((layer) => layers.get(layer).length),
1,
)
const nodes = []
for (const layer of layerIndices) {
const row = layers.get(layer)
const rowWidth =
row.length * GATE_NODE_WIDTH + Math.max(0, row.length - 1) * GATE_NODE_GAP_X
const canvasWidth =
maxLayerWidth * GATE_NODE_WIDTH +
Math.max(0, maxLayerWidth - 1) * GATE_NODE_GAP_X +
GATE_GRAPH_PADDING * 2
const startX = GATE_GRAPH_PADDING + Math.max(0, (canvasWidth - rowWidth) / 2 - GATE_GRAPH_PADDING)
row.forEach((item, index) => {
nodes.push({
id: item.id,
item,
width: GATE_NODE_WIDTH,
height: GATE_NODE_HEIGHT,
x: startX + index * (GATE_NODE_WIDTH + GATE_NODE_GAP_X),
y: GATE_GRAPH_PADDING + layer * (GATE_NODE_HEIGHT + GATE_LAYER_GAP_Y),
})
})
}
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const edges = dependencies
.map((dep) => {
const sourceNode =
dep.dependency_type === 'requires'
? nodeById.get(dep.to_item_id)
: nodeById.get(dep.from_item_id)
const targetNode =
dep.dependency_type === 'requires'
? nodeById.get(dep.from_item_id)
: nodeById.get(dep.to_item_id)
if (!sourceNode || !targetNode) return null
const center = (node) => ({
x: node.x + node.width / 2,
y: node.y + node.height / 2,
})
if (dep.dependency_type === 'related') {
const from = center(sourceNode)
const to = center(targetNode)
return {
id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
fromId: dep.from_item_id,
toId: dep.to_item_id,
dependency_type: dep.dependency_type,
x1: from.x,
y1: from.y,
x2: to.x,
y2: to.y,
}
}
return {
id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`,
fromId: dep.from_item_id,
toId: dep.to_item_id,
dependency_type: dep.dependency_type,
x1: sourceNode.x + sourceNode.width / 2,
y1: sourceNode.y + sourceNode.height,
x2: targetNode.x + targetNode.width / 2,
y2: targetNode.y,
}
})
.filter(Boolean)
const width =
Math.max(...nodes.map((node) => node.x + node.width), GATE_NODE_WIDTH) + GATE_GRAPH_PADDING
const height =
Math.max(...nodes.map((node) => node.y + node.height), GATE_NODE_HEIGHT) + GATE_GRAPH_PADDING
return { nodes, edges, width, height }
}
/**
* @param {string} initiativeId
* @param {number} itemCount
* @returns {'list' | 'graph'}
*/
export function resolveGatesViewMode(initiativeId, itemCount) {
if (typeof window !== 'undefined' && initiativeId) {
const stored = window.localStorage.getItem(`kairo.plan.gates.view:${initiativeId}`)
if (stored === 'list' || stored === 'graph') {
return stored
}
}
return itemCount <= 5 ? 'list' : 'graph'
}
/**
* @param {string} initiativeId
* @param {'list' | 'graph'} mode
*/
export function storeGatesViewMode(initiativeId, mode) {
if (typeof window !== 'undefined' && initiativeId) {
window.localStorage.setItem(`kairo.plan.gates.view:${initiativeId}`, mode)
}
}

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { buildLayoutEdges, computeGateGraphLayout } from './gateGraphLayout.js'
describe('gateGraphLayout', () => {
it('layers requires dependencies with prerequisite above dependent', () => {
const items = [
{ id: 'a', title: 'A', sort_order: 0 },
{ id: 'b', title: 'B', sort_order: 1 },
]
const dependencies = [
{ id: 'd1', from_item_id: 'a', to_item_id: 'b', dependency_type: 'requires' },
]
expect(buildLayoutEdges(dependencies)).toEqual([
{ from: 'b', to: 'a', dependency_type: 'requires' },
])
const layout = computeGateGraphLayout(items, dependencies)
const nodeA = layout.nodes.find((node) => node.id === 'a')
const nodeB = layout.nodes.find((node) => node.id === 'b')
expect(nodeB.y).toBeLessThan(nodeA.y)
})
it('places items without dependencies on one row by sort_order', () => {
const items = [
{ id: 'b', title: 'B', sort_order: 10 },
{ id: 'a', title: 'A', sort_order: 0 },
]
const layout = computeGateGraphLayout(items, [])
expect(layout.nodes.map((node) => node.id)).toEqual(['a', 'b'])
expect(layout.nodes[0].y).toBe(layout.nodes[1].y)
expect(layout.nodes[0].x).toBeLessThan(layout.nodes[1].x)
})
})

View File

@ -1611,3 +1611,152 @@
font-size: 0.875rem;
}
.gates-plan-panel__toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.gates-plan-panel__tab {
border: 1px solid var(--jk-border, #dde3ea);
background: var(--jk-surface, #fff);
color: var(--jk-text-secondary, #445);
border-radius: var(--jk-radius-md, 8px);
padding: 0.4rem 0.85rem;
font-size: 0.875rem;
cursor: pointer;
}
.gates-plan-panel__tab--active {
background: var(--jk-surface-muted, #eef2f6);
color: var(--jk-text, #1a1a1a);
border-color: var(--jk-border-strong, #c5ced8);
font-weight: 600;
}
.gate-map-view__legend {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.8125rem;
}
.gate-map-view__legend-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gate-map-view__legend-line {
display: inline-block;
width: 1.75rem;
height: 0;
border-top-width: 2px;
border-top-style: solid;
}
.gate-map-view__legend-line--requires {
border-top-color: #2563eb;
}
.gate-map-view__legend-line--blocks {
border-top-color: #dc2626;
}
.gate-map-view__legend-line--related {
border-top-color: #64748b;
border-top-style: dashed;
}
.gate-map-view__canvas-wrap {
overflow: auto;
border: 1px solid var(--jk-border, #dde3ea);
border-radius: var(--jk-radius-md, 8px);
background: var(--jk-surface-subtle, #f8fafc);
}
.gate-map-view__canvas {
display: block;
min-width: 100%;
}
.gate-map-view__edge {
fill: none;
stroke-width: 2;
}
.gate-map-view__edge--requires {
stroke: #2563eb;
}
.gate-map-view__edge--blocks {
stroke: #dc2626;
}
.gate-map-view__edge--related {
stroke: #64748b;
stroke-dasharray: 6 4;
}
.gate-map-view__arrow--requires {
fill: #2563eb;
}
.gate-map-view__arrow--blocks {
fill: #dc2626;
}
.gate-map-view__node {
cursor: pointer;
}
.gate-map-view__node-box {
fill: #fff;
stroke: var(--jk-border-strong, #c5ced8);
stroke-width: 1.5;
}
.gate-map-view__node--active .gate-map-view__node-box,
.gate-map-view__node--planned .gate-map-view__node-box {
stroke: #2563eb;
}
.gate-map-view__node--at_risk .gate-map-view__node-box {
stroke: #d97706;
}
.gate-map-view__node--reached .gate-map-view__node-box {
stroke: #16a34a;
fill: #f0fdf4;
}
.gate-map-view__node--discarded .gate-map-view__node-box,
.gate-map-view__node--moved .gate-map-view__node-box {
stroke: #94a3b8;
fill: #f8fafc;
}
.gate-map-view__node-title {
fill: var(--jk-text, #1a1a1a);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
.gate-map-view__node-meta {
fill: var(--jk-text-secondary, #445);
font-size: 12px;
pointer-events: none;
}
.gate-map-view__node:focus-visible .gate-map-view__node-box {
stroke: #2563eb;
stroke-width: 2.5;
}
.gate-map-view__hint {
margin-top: 0.75rem;
font-size: 0.875rem;
}