AP1.6b: Journey um Gate-Fortschritt und Ist-Graph erweitern.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m23s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s

Fokus-Gates mit Kriterien-Checkliste und eingebetteter Graph-Sicht in Kontrolle; Abnahme-Reihenfolge in Roadmap dokumentiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 09:48:14 +02:00
parent a19c6abd26
commit b85a6ec0c7
8 changed files with 331 additions and 26 deletions

View File

@ -142,6 +142,17 @@ Phase L Validation AP0.10d (laufend PO) parallel
## 4. Nächste Pakete (verbindliche Reihenfolge — PO 2026-07-06)
### 4.0 Abnahme-Reihenfolge (Stand 2026-07-11)
| # | Paket | Status | Begründung |
|---|-------|--------|------------|
| 1 | **AP1.6b** Journey Gate-Fortschritt + Ist-Graph | ✓ | ADP-Lücke schließen; Plan/Ist in Kontrolle sichtbar |
| 2 | **AP1.9c/d** Cockpit + Kontrolle Polish | offen | Signale auf Portfolio-Kacheln, Filter |
| 3 | **AP1.5d** Rekursive Tasks (Rest) | → | Backend + Outline da; ggf. Task-Detail-Route |
| 4 | **AP1.8b** Situativer Kontext | deferred | Nach Portfolio-Prio (8a ✓) |
| 5 | **AP1.7** Agent Interface | offen | Nach Operating Model tragfähig |
| 6 | **AP1.15d** OR-Alternativpfade | deferred | Nur bei Use Case |
### AP1.5 — Hierarchie & Ist-Ebene ✓
**Ziel:** Operativer Plan zwischen Gates — Project, Task, Ausführung-Tab; Gates optional nutzbar.
@ -193,7 +204,12 @@ Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
**Nach AP1.5c** — Gates bekommen Bedeutung durch Ist-Bezug (auch auf Project-Ebenen).
**Version:** `0.15.0-ap1.6`
| Slice | Scope | Status |
|-------|--------|--------|
| **6a** | Plan/Ist-Links, Journey-Timeline, Gate-Beiträge | ✓ |
| **6b** | Journey: Fokus-Gates + Kriterien (read-only), eingebetteter Ist-Graph | ✓ |
**Version:** `0.15.0-ap1.6` / `0.15.0-ap1.6b`
---

View File

@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren
**Stand:** 2026-07-10 (nach AP1.13)
**Stand:** 2026-07-11 (nach AP1.6b)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
@ -87,6 +87,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Workspace (Portfolio aller Initiativen) | ✓ | Portfolio-Widget |
| Initiative-Übersicht (operative Steuerung) | ✓ | AP1.2c |
| Initiative Unterseiten (Plan, Inbox, …) | ✓ | Shell + Pflege verschoben |
| Journey (Kontrolle / Initiative) | ◐ | AP1.6b: Fokus-Gates + Kriterien read-only, Ist-Graph eingebettet |
| Next-Action-Widget konfigurierbar (Workspace + Initiative) | ✓ | scope portfolio/initiative |
| Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` |
| Action Detail-Seite | ✗ | |
@ -104,7 +105,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Zieltermin | ◐ | `target_date` |
| 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 |
| Plan-Graph vs. Ist-Overlay | ◐ | AP1.13 Designer + AP1.6b Journey Ist-Graph (read-only); Plan/Ist-Diff AP1.14 |
| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a/b Designer; AP1.15c parallel/optional in Engine+Designer; OR deferred |
| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |

View File

@ -18,7 +18,7 @@ function truncateTitle(title, max = 28) {
return `${title.slice(0, max - 1)}`
}
export function GateMapView({ initiativeId, items, graphState: graphStateProp }) {
export function GateMapView({ initiativeId, items, graphState: graphStateProp, embedded = false }) {
const navigate = useNavigate()
const [dependencies, setDependencies] = useState([])
const [graphStateLocal, setGraphStateLocal] = useState(null)
@ -80,8 +80,12 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
return <ErrorState message={error} />
}
const Wrapper = embedded ? 'div' : 'section'
const wrapperClass = embedded ? 'gate-map-view gate-map-view--embedded' : 'card gate-map-view'
return (
<section className="card gate-map-view">
<Wrapper className={wrapperClass}>
{!embedded && (
<div className="section-header">
<div>
<h2>Zielzustände (Graph)</h2>
@ -91,6 +95,7 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
</p>
</div>
</div>
)}
<div className="gate-map-view__legend muted">
<span className="gate-map-view__legend-item">
@ -209,6 +214,6 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
Abhängigkeiten anlegen; ohne Kanten gelten Gates nach Reihenfolge.
</p>
)}
</section>
</Wrapper>
)
}

View File

@ -0,0 +1,25 @@
import { GateMapView } from './GateMapView.jsx'
export function JourneyGateMapSection({ initiativeId, items, graphState, loading }) {
if (!items.length) return null
return (
<section className="card journey-gate-map">
<h2>Zielzustands-Graph (Ist)</h2>
<p className="section-lead muted">
Baum der Gate-Abhängigkeiten mit Ist-Status keine Planungsbearbeitung (Kontrolle /
Journey).
</p>
{loading ? (
<p className="muted">Graph wird geladen </p>
) : (
<GateMapView
initiativeId={initiativeId}
items={items}
graphState={graphState}
embedded
/>
)}
</section>
)
}

View File

@ -0,0 +1,142 @@
import { Link } from 'react-router-dom'
import { gateTitleById } from './GateSelect.jsx'
import { gatePath } from '../utils/routes.js'
import { CRITERION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
export function GateSteeringHint({ graphState, itemId, siblingItems = [] }) {
if (!graphState?.items?.[itemId]) return null
const itemState = graphState.items[itemId]
const enforceBlocking = graphState.graph_profile?.enforce_gate_blocking !== false
const blockedBy = itemState.blocked_by || []
const wouldBlock = itemState.would_block ?? itemState.blocked
if (itemState.ready) {
return <span className="status-pill status-pill--ready">Bereit</span>
}
if (wouldBlock && blockedBy.length > 0) {
const blockerTitle =
blockedBy.length === 1 ? gateTitleById(siblingItems, blockedBy[0]) : null
const title =
blockerTitle != null
? `Wartet auf: ${blockerTitle}`
: `Wartet auf ${blockedBy.length} Voraussetzungen`
const className = enforceBlocking
? 'status-pill status-pill--blocked'
: 'status-pill status-pill--wait'
const label = enforceBlocking ? 'Blockiert' : 'Wartet'
return (
<span className={className} title={title}>
{label}
</span>
)
}
return null
}
function sortGatesForJourney(items, graphState) {
const terminal = new Set(['reached', 'moved', 'discarded'])
const open = items.filter((item) => !terminal.has(item.status))
return [...open].sort((a, b) => {
const aState = graphState?.items?.[a.id]
const bState = graphState?.items?.[b.id]
const aReady = aState?.ready ? 0 : 1
const bReady = bState?.ready ? 0 : 1
if (aReady !== bReady) return aReady - bReady
const aWait = aState?.would_block || aState?.blocked ? 1 : 0
const bWait = bState?.would_block || bState?.blocked ? 1 : 0
if (aWait !== bWait) return aWait - bWait
return (a.sort_order ?? 0) - (b.sort_order ?? 0)
})
}
export { sortGatesForJourney }
export function JourneyGateProgressSection({
items = [],
graphState,
criteriaProgress = {},
criteriaByGate = {},
loading = false,
}) {
if (loading) {
return (
<section className="card journey-gate-progress">
<p className="muted">Zielzustands-Fortschritt wird geladen </p>
</section>
)
}
if (!items.length) {
return null
}
const focusGates = sortGatesForJourney(items, graphState).slice(0, 5)
return (
<section className="card journey-gate-progress">
<h2>Zielzustands-Fortschritt</h2>
<p className="section-lead muted">
Nächste Gates und Kriterien im Ist Schritt für Schritt bis Verify. Vollständige Pflege
auf der Gate-Detailseite.
</p>
<ul className="item-list journey-gate-progress-list">
{focusGates.map((gate) => {
const progress = criteriaProgress[gate.id]
const criteria = criteriaByGate[gate.id] || []
const openCriteria = criteria.filter(
(c) => !['satisfied', 'waived', 'deferred'].includes(c.status),
)
return (
<li key={gate.id} className="list-item card-list-item journey-gate-progress-item">
<div className="list-item-main">
<div className="journey-gate-progress-item__head">
<Link to={gatePath(gate.id)} className="link-button">
<strong>{gate.title}</strong>
</Link>
<StatusBadge kind="milestone" status={gate.status} />
<GateSteeringHint
graphState={graphState}
itemId={gate.id}
siblingItems={items}
/>
</div>
{progress?.total > 0 && (
<p className="muted list-item-sub">
Kriterien: {progress.closed}/{progress.total} erfüllt
</p>
)}
{criteria.length > 0 && (
<ol className="journey-gate-criteria-list">
{criteria.map((criterion) => (
<li
key={criterion.id}
className={
'journey-gate-criterion' +
(['satisfied', 'waived', 'deferred'].includes(criterion.status)
? ' journey-gate-criterion--done'
: '')
}
>
<span className="journey-gate-criterion__title">{criterion.title}</span>
<span className="muted journey-gate-criterion__status">
{CRITERION_STATUS_LABELS[criterion.status] || criterion.status}
</span>
</li>
))}
</ol>
)}
{openCriteria.length === 0 && criteria.length > 0 && gate.status !== 'reached' && (
<p className="muted list-item-sub">Alle Kriterien bearbeitet Verify prüfen.</p>
)}
</div>
</li>
)
})}
</ul>
</section>
)
}

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'
import { actionPath, gatePath } from '../utils/routes.js'
const KIND_LABELS = {
decision: 'Entscheidung',
@ -40,26 +41,17 @@ export function JourneyTimeline({ events, initiativeId, roadmapItems = [] }) {
{event.summary && <p className="list-item-desc muted">{event.summary}</p>}
<div className="journey-event-links">
{event.roadmap_item_id && gateTitles[event.roadmap_item_id] && (
<Link
to={`/initiatives/${initiativeId}/plan/items/${event.roadmap_item_id}`}
className="link-button"
>
<Link to={gatePath(event.roadmap_item_id)} className="link-button">
Gate: {gateTitles[event.roadmap_item_id]}
</Link>
)}
{event.kind === 'action' && (
<Link
to={`/initiatives/${initiativeId}/actions/${event.entity_id}`}
className="link-button"
>
<Link to={actionPath(event.entity_id)} className="link-button">
Arbeitspaket öffnen
</Link>
)}
{event.kind === 'gate' && (
<Link
to={`/initiatives/${initiativeId}/plan/items/${event.entity_id}`}
className="link-button"
>
<Link to={gatePath(event.entity_id)} className="link-button">
Gate öffnen
</Link>
)}

View File

@ -1,11 +1,18 @@
import { useCallback, useEffect, useState } from 'react'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { listInitiativeJourney } from '../../api/journey.js'
import {
listInitiativeRoadmapCriteriaProgress,
listInitiativeRoadmapGraphState,
listRoadmapItemCriteria,
} from '../../api/roadmap.js'
import { DecisionsSection } from '../../components/DecisionsSection.jsx'
import { ReviewsSection } from '../../components/ReviewsSection.jsx'
import { RecurringSection } from '../../components/RecurringSection.jsx'
import { EvidenceSection } from '../../components/EvidenceSection.jsx'
import { JourneyTimeline } from '../../components/JourneyTimeline.jsx'
import { JourneyGateProgressSection, sortGatesForJourney } from '../../components/JourneyGateProgressSection.jsx'
import { JourneyGateMapSection } from '../../components/JourneyGateMapSection.jsx'
export function InitiativeJourneyPage() {
const ops = useInitiativeOperations()
@ -13,6 +20,10 @@ export function InitiativeJourneyPage() {
const [journeyEvents, setJourneyEvents] = useState([])
const [journeyLoading, setJourneyLoading] = useState(true)
const [journeyError, setJourneyError] = useState(null)
const [graphState, setGraphState] = useState(null)
const [criteriaProgress, setCriteriaProgress] = useState({})
const [criteriaByGate, setCriteriaByGate] = useState({})
const [gateMetaLoading, setGateMetaLoading] = useState(true)
const loadJourney = useCallback(async () => {
if (!id) return
@ -28,6 +39,53 @@ export function InitiativeJourneyPage() {
}
}, [id])
useEffect(() => {
if (!id || !roadmapItems.length) {
setGraphState(null)
setCriteriaProgress({})
setCriteriaByGate({})
setGateMetaLoading(false)
return undefined
}
let cancelled = false
setGateMetaLoading(true)
Promise.all([
listInitiativeRoadmapGraphState(id),
listInitiativeRoadmapCriteriaProgress(id),
])
.then(async ([graph, progress]) => {
if (cancelled) return
setGraphState(graph || null)
setCriteriaProgress(progress || {})
const sortedFocus = sortGatesForJourney(roadmapItems, graph || null)
.slice(0, 5)
.map((item) => item.id)
const criteriaEntries = await Promise.all(
sortedFocus.map((gateId) =>
listRoadmapItemCriteria(gateId)
.then((criteria) => [gateId, Array.isArray(criteria) ? criteria : []])
.catch(() => [gateId, []]),
),
)
if (cancelled) return
setCriteriaByGate(Object.fromEntries(criteriaEntries))
})
.catch(() => {
if (!cancelled) {
setGraphState(null)
setCriteriaProgress({})
setCriteriaByGate({})
}
})
.finally(() => {
if (!cancelled) setGateMetaLoading(false)
})
return () => {
cancelled = true
}
}, [id, roadmapItems])
useEffect(() => {
loadJourney()
}, [loadJourney, ops.decisions, ops.reviews, ops.evidenceItems, ops.actions])
@ -40,6 +98,21 @@ export function InitiativeJourneyPage() {
<>
{error && <p className="error">{error}</p>}
<JourneyGateProgressSection
items={roadmapItems}
graphState={graphState}
criteriaProgress={criteriaProgress}
criteriaByGate={criteriaByGate}
loading={gateMetaLoading}
/>
<JourneyGateMapSection
initiativeId={id}
items={roadmapItems}
graphState={graphState}
loading={gateMetaLoading}
/>
<section className="card journey-timeline-section">
<h2>Journey</h2>
<p className="section-lead muted">

View File

@ -1255,6 +1255,52 @@
box-shadow: none;
}
.journey-gate-progress-list {
margin-top: 0.75rem;
}
.journey-gate-progress-item__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
.journey-gate-criteria-list {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
display: grid;
gap: 0.35rem;
}
.journey-gate-criterion {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.75rem;
}
.journey-gate-criterion--done {
opacity: 0.72;
}
.journey-gate-criterion__title {
flex: 1 1 12rem;
}
.journey-gate-criterion__status {
font-size: 0.85rem;
}
.journey-gate-map .gate-map-view--embedded {
margin-top: 0.5rem;
}
.journey-gate-map .gate-map-view--embedded > h2,
.journey-gate-map .gate-map-view--embedded > .section-lead {
display: none;
}
.next-action-embedded {
margin-top: 1rem;
}
@ -1850,6 +1896,11 @@
color: #047857;
}
.status-pill--wait {
background: #fffbeb;
color: #b45309;
}
.status-pill--blocked {
background: #fef2f2;
color: #b91c1c;