AP1.2 + AP1.4: Signals konsolidieren, RoadmapItem mit Gate-Verify
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

Entfernt operating_phase aus dem Snapshot zugunsten von Lifecycle + signals.
Führt methodenneutrale RoadmapItems (Migration 010), Verify-Pfad und Plan-UI ein.
Milestone-API bleibt als Compat-Wrapper; Version 0.13.0-ap1.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 18:45:36 +02:00
parent a5d9eeb338
commit 09433b5d28
25 changed files with 1582 additions and 343 deletions

View File

@ -5,28 +5,17 @@ Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steer
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal, Optional from typing import Any, Optional
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from db import get_connection from db import get_connection
from data_layer.attention import get_next_action_candidates_for_initiative from data_layer.attention import get_next_action_candidates_for_initiative
from steering.context import get_steering_context_dto from steering.context import get_steering_context_dto
from steering.signals.snapshot_signals import derive_initiative_signals
from services.initiatives import get_initiative from services.initiatives import get_initiative
from tenant_context import TenantContext from tenant_context import TenantContext
OperatingPhase = Literal[
"capture",
"triage",
"structure",
"commit",
"execute",
"verify",
"review",
"adapt",
"closure",
]
OPEN_BLOCKER = ("open", "in_progress") OPEN_BLOCKER = ("open", "in_progress")
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required") OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE = ("active", "paused") ACTIVE_INITIATIVE = ("active", "paused")
@ -40,51 +29,6 @@ def _iso(value: Any) -> Optional[str]:
return value.isoformat() if value else None return value.isoformat() if value else None
def _derive_operating_phase(
*,
initiative_status: str,
open_actions: int,
blocked_actions: int,
review_required_actions: int,
open_blockers: int,
backlog_new: int,
planned_reviews_due: int,
) -> tuple[OperatingPhase, list[str]]:
"""Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
signals: list[str] = []
if initiative_status in ("completed", "archived"):
return "closure", ["initiative_terminal"]
if open_actions == 0 and backlog_new > 0:
signals.append("backlog_awaiting_commit")
return "triage", signals
if open_actions == 0 and backlog_new == 0:
signals.append("no_open_work")
return "structure", signals
if blocked_actions > 0 or open_blockers > 0:
if blocked_actions:
signals.append("actions_blocked")
if open_blockers:
signals.append("open_blockers")
return "execute", signals
if review_required_actions > 0 or planned_reviews_due > 0:
if review_required_actions:
signals.append("actions_need_review")
if planned_reviews_due:
signals.append("reviews_due")
return "review", signals
if open_actions > 0:
signals.append("work_in_progress")
return "execute", signals
return "adapt", signals
def get_initiative_steering_snapshot( def get_initiative_steering_snapshot(
ctx: TenantContext, *, initiative_id: str ctx: TenantContext, *, initiative_id: str
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
@ -155,14 +99,16 @@ def get_initiative_steering_snapshot(
cur.execute( cur.execute(
""" """
SELECT id, title, status, target_date, created_at, updated_at SELECT id, title, status, target_date, item_type, sequencing_mode,
FROM milestones sort_order, created_at, updated_at
WHERE tenant_id = %s AND initiative_id = %s FROM roadmap_items ri
ORDER BY updated_at DESC JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
ORDER BY ri.sort_order ASC, ri.updated_at DESC
""", """,
(ctx.tenant_id, initiative_id), (ctx.tenant_id, initiative_id),
) )
milestones_raw = [dict(r) for r in cur.fetchall()] roadmap_items_raw = [dict(r) for r in cur.fetchall()]
cur.execute( cur.execute(
""" """
@ -270,14 +216,17 @@ def get_initiative_steering_snapshot(
} }
) )
milestones = [] roadmap_items: list[dict[str, Any]] = []
for m in milestones_raw: for m in roadmap_items_raw:
mid = _sid(m["id"]) mid = _sid(m["id"])
milestones.append( roadmap_items.append(
{ {
"id": mid, "id": mid,
"title": m["title"], "title": m["title"],
"status": m["status"], "status": m["status"],
"item_type": m["item_type"],
"sequencing_mode": m["sequencing_mode"],
"sort_order": m.get("sort_order", 0),
"target_date": m["target_date"].isoformat() if m.get("target_date") else None, "target_date": m["target_date"].isoformat() if m.get("target_date") else None,
"reviews": reviews_by_milestone.get(mid, []), "reviews": reviews_by_milestone.get(mid, []),
} }
@ -288,7 +237,7 @@ def get_initiative_steering_snapshot(
) )
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted")) backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
phase, phase_signals = _derive_operating_phase( signals = derive_initiative_signals(
initiative_status=initiative["status"], initiative_status=initiative["status"],
open_actions=open_actions, open_actions=open_actions,
blocked_actions=blocked_actions, blocked_actions=blocked_actions,
@ -301,7 +250,7 @@ def get_initiative_steering_snapshot(
upcoming_milestones = sorted( upcoming_milestones = sorted(
[ [
m m
for m in milestones for m in roadmap_items
if m["status"] in ("planned", "active", "at_risk") if m["status"] in ("planned", "active", "at_risk")
], ],
key=lambda m: (m["target_date"] is None, m["target_date"] or ""), key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
@ -322,10 +271,9 @@ def get_initiative_steering_snapshot(
"method_key": steering["method_key"], "method_key": steering["method_key"],
"method_label": steering.get("method_label"), "method_label": steering.get("method_label"),
"method_version": steering["method_version"], "method_version": steering["method_version"],
"operating_phase": phase, "signals": signals,
"operating_phase_deprecated": True,
"phase_signals": phase_signals,
"upcoming_milestones": upcoming_milestones, "upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions, "next_actions": next_actions,
"counts": { "counts": {
"actions_open": open_actions, "actions_open": open_actions,
@ -341,7 +289,8 @@ def get_initiative_steering_snapshot(
"unlinked_reviews": len(unlinked_reviews), "unlinked_reviews": len(unlinked_reviews),
}, },
"actions": actions, "actions": actions,
"milestones": milestones, "roadmap_items": roadmap_items,
"milestones": roadmap_items,
"initiative_level": { "initiative_level": {
"blockers": unlinked_blockers, "blockers": unlinked_blockers,
"evidence": unlinked_evidence, "evidence": unlinked_evidence,

View File

@ -68,6 +68,7 @@ from routers import ( # noqa: E402
initiatives, initiatives,
me, me,
milestones, milestones,
roadmap,
prompts, prompts,
recurring, recurring,
reviews, reviews,
@ -85,6 +86,8 @@ app.include_router(actions.router)
app.include_router(blockers.router) app.include_router(blockers.router)
app.include_router(backlog.router) app.include_router(backlog.router)
app.include_router(milestones.router) app.include_router(milestones.router)
app.include_router(roadmap.initiative_router)
app.include_router(roadmap.items_router)
app.include_router(evidence.router) app.include_router(evidence.router)
app.include_router(decisions.router) app.include_router(decisions.router)
app.include_router(reviews.router) app.include_router(reviews.router)

View File

@ -0,0 +1,86 @@
-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral)
CREATE TABLE roadmaps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL DEFAULT 'Plan',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id)
);
CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id);
CREATE TABLE roadmap_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
item_type VARCHAR(32) NOT NULL DEFAULT 'milestone'
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')),
title VARCHAR(255) NOT NULL,
goal_description TEXT NOT NULL DEFAULT '',
definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb,
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential'
CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')),
target_date DATE NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id);
CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status);
CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type);
CREATE TABLE roadmap_item_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires'
CHECK (dependency_type IN ('requires', 'blocks', 'related')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type)
);
CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id);
CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id);
ALTER TABLE backlog_items
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
ALTER TABLE actions
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
-- Ein Plan pro Initiative
INSERT INTO roadmaps (tenant_id, initiative_id, title)
SELECT tenant_id, id, 'Plan' FROM initiatives;
-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs)
INSERT INTO roadmap_items (
id, tenant_id, roadmap_id, item_type, title, goal_description, status,
sequencing_mode, target_date, sort_order, created_at, updated_at
)
SELECT
m.id,
m.tenant_id,
r.id,
'milestone',
m.title,
m.goal_description,
m.status,
'sequential',
m.target_date,
0,
m.created_at,
m.updated_at
FROM milestones m
JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id;

198
backend/routers/roadmap.py Normal file
View File

@ -0,0 +1,198 @@
"""Roadmap API — AP1.4."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import roadmap as roadmap_service
from tenant_context import TenantContext
router = APIRouter(tags=["roadmap"])
class RoadmapItemCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
item_type: Literal["milestone", "review_gate", "maturity_stage"] = "milestone"
goal_description: str = ""
definition_of_done: list[Any] = Field(default_factory=list)
status: Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
] = "planned"
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
target_date: Optional[date] = None
sort_order: int = 0
class RoadmapItemUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage"]] = None
goal_description: Optional[str] = None
definition_of_done: Optional[list[Any]] = None
status: Optional[
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
] = None
sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None
target_date: Optional[date] = None
clear_target_date: bool = False
sort_order: Optional[int] = None
class DependencyCreateRequest(BaseModel):
to_item_id: str
dependency_type: Literal["requires", "blocks", "related"] = "requires"
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
@initiative_router.get("/{initiative_id}/roadmap")
def get_initiative_roadmap(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
roadmap = roadmap_service.get_roadmap_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
if not roadmap:
return {"initiative_id": initiative_id, "items": []}
items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
return {**roadmap, "items": items}
@initiative_router.get("/{initiative_id}/roadmap/items")
def list_initiative_roadmap_items(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_roadmap_items_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,
body: RoadmapItemCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.create_roadmap_item(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
item_type=body.item_type,
goal_description=body.goal_description,
definition_of_done=body.definition_of_done,
status=body.status,
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}")
def get_roadmap_item(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id)
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
return item
@items_router.patch("/{item_id}")
def update_roadmap_item(
item_id: str,
body: RoadmapItemUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = roadmap_service.update_roadmap_item(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
title=body.title,
item_type=body.item_type,
goal_description=body.goal_description,
definition_of_done=body.definition_of_done,
status=body.status,
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
return item
@items_router.delete("/{item_id}", status_code=204)
def delete_roadmap_item(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
if not roadmap_service.delete_roadmap_item(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
@items_router.post("/{item_id}/verify-reached")
def verify_roadmap_item_reached(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.verify_reached(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}/dependencies")
def list_roadmap_item_dependencies(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/dependencies", status_code=201)
def add_roadmap_item_dependency(
item_id: str,
body: DependencyCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.add_dependency(
tenant_id=ctx.tenant_id,
from_item_id=item_id,
to_item_id=body.to_item_id,
dependency_type=body.dependency_type,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -85,6 +85,18 @@ def _milestone_in_initiative(
""", """,
(milestone_id, tenant_id, initiative_id), (milestone_id, tenant_id, initiative_id),
) )
if cur.fetchone():
return True
cur.execute(
"""
SELECT 1
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
AND ri.item_type = 'milestone'
""",
(milestone_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None return cur.fetchone() is not None
finally: finally:
conn.close() conn.close()

View File

@ -1,46 +1,31 @@
"""Milestone service — tenant-scoped CRUD (AP0.8d).""" """Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4)."""
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor from services import roadmap as roadmap_service
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
MilestoneStatus = Literal[ MilestoneStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded" "planned", "active", "at_risk", "reached", "moved", "discarded"
] ]
MILESTONE_STATUSES = frozenset( MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _as_milestone(item: dict[str, Any]) -> dict[str, Any]:
result = dict(row) return {
for key in ("id", "tenant_id", "initiative_id"): "id": item["id"],
if result.get(key): "tenant_id": item["tenant_id"],
result[key] = str(result[key]) "initiative_id": item["initiative_id"],
if result.get("target_date"): "title": item["title"],
result["target_date"] = ( "goal_description": item.get("goal_description", ""),
result["target_date"].isoformat() "status": item["status"],
if hasattr(result["target_date"], "isoformat") "target_date": item.get("target_date"),
else str(result["target_date"]) "created_at": item["created_at"],
) "updated_at": item["updated_at"],
if result.get("created_at"): }
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in MILESTONE_STATUSES:
raise ValueError(f"Ungültiger Meilenstein-Status: {status}")
def create_milestone( def create_milestone(
@ -53,80 +38,31 @@ def create_milestone(
target_date: Optional[date] = None, target_date: Optional[date] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
title = title.strip() item = roadmap_service.create_roadmap_item(
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
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(
"""
INSERT INTO milestones (
tenant_id, initiative_id, title, goal_description, status, target_date
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
""",
(tenant_id, initiative_id, title, goal_description, status, target_date),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"milestone.created",
user_id=user_id,
tenant_id=tenant_id, tenant_id=tenant_id,
details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title}, initiative_id=initiative_id,
title=title,
item_type="milestone",
goal_description=goal_description,
status=status,
target_date=target_date,
user_id=user_id,
) )
return row return _as_milestone(item)
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]: def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): items = roadmap_service.list_roadmap_items_for_initiative(
raise ValueError("Initiative nicht gefunden") tenant_id=tenant_id, initiative_id=initiative_id
)
conn = get_connection() return [_as_milestone(i) for i in items if i["item_type"] == "milestone"]
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
FROM milestones
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY target_date NULLS LAST, updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]: def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
conn = get_connection() item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
try: if not item or item["item_type"] != "milestone":
with conn.cursor(cursor_factory=RealDictCursor) as cur: return None
cur.execute( return _as_milestone(item)
"""
SELECT id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
FROM milestones
WHERE id = %s AND tenant_id = %s
""",
(milestone_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_milestone( def update_milestone(
@ -140,78 +76,17 @@ def update_milestone(
target_date: Optional[date] = None, target_date: Optional[date] = None,
clear_target_date: bool = False, clear_target_date: bool = False,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id) item = roadmap_service.update_roadmap_item(
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal_description is not None:
updates.append("goal_description = %s")
params.append(goal_description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([milestone_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE milestones
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"milestone.updated",
user_id=user_id,
tenant_id=tenant_id, tenant_id=tenant_id,
details={"milestone_id": milestone_id}, item_id=milestone_id,
user_id=user_id,
title=title,
goal_description=goal_description,
status=status,
target_date=target_date,
clear_target_date=clear_target_date,
) )
if status is not None and status != old_status: return _as_milestone(item) if item else None
log_audit(
"milestone.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"milestone_id": milestone_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_milestone( def delete_milestone(
@ -220,23 +95,9 @@ def delete_milestone(
milestone_id: str, milestone_id: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> bool: ) -> bool:
conn = get_connection() item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
try: if not item or item["item_type"] != "milestone":
with conn.cursor() as cur: return False
cur.execute( return roadmap_service.delete_roadmap_item(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id", tenant_id=tenant_id, item_id=milestone_id, user_id=user_id
(milestone_id, tenant_id), )
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"milestone.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"milestone_id": milestone_id},
)
return deleted

621
backend/services/roadmap.py Normal file
View File

@ -0,0 +1,621 @@
"""Roadmap service — tenant-scoped Plan/Gates (AP1.4)."""
from __future__ import annotations
import json
from datetime import date
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"]
RoadmapItemStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
SequencingMode = Literal["sequential", "parallel", "optional"]
DependencyType = Literal["requires", "blocks", "related"]
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
ROADMAP_ITEM_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"})
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
result["target_date"] = (
result["target_date"].isoformat()
if hasattr(result["target_date"], "isoformat")
else str(result["target_date"])
)
for ts in ("created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
dod = result.get("definition_of_done")
if isinstance(dod, str):
result["definition_of_done"] = json.loads(dod)
return result
def _validate_item_type(item_type: str) -> None:
if item_type not in ROADMAP_ITEM_TYPES:
raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}")
def _validate_status(status: str) -> None:
if status not in ROADMAP_ITEM_STATUSES:
raise ValueError(f"Ungültiger RoadmapItem-Status: {status}")
def _validate_sequencing_mode(mode: str) -> None:
if mode not in SEQUENCING_MODES:
raise ValueError(f"Ungültiger sequencing_mode: {mode}")
def _validate_dependency_type(dep_type: str) -> None:
if dep_type not in DEPENDENCY_TYPES:
raise ValueError(f"Ungültiger dependency_type: {dep_type}")
def _sync_milestone_compat_row(
cur,
*,
tenant_id: str,
initiative_id: str,
item: dict[str, Any],
) -> None:
"""Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop)."""
if item["item_type"] != "milestone":
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
(item["id"], tenant_id),
)
return
target_date = item.get("target_date")
if target_date and isinstance(target_date, str):
target_date = date.fromisoformat(target_date)
cur.execute(
"""
INSERT INTO milestones (
id, tenant_id, initiative_id, title, goal_description, status, target_date
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
goal_description = EXCLUDED.goal_description,
status = EXCLUDED.status,
target_date = EXCLUDED.target_date,
updated_at = NOW()
""",
(
item["id"],
tenant_id,
initiative_id,
item["title"],
item.get("goal_description", ""),
item["status"],
target_date,
),
)
def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> 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 id, tenant_id, initiative_id, title, created_at, updated_at
FROM roadmaps
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
if row:
return _serialize_row(dict(row))
cur.execute(
"""
INSERT INTO roadmaps (tenant_id, initiative_id, title)
VALUES (%s, %s, 'Plan')
RETURNING id, tenant_id, initiative_id, title, created_at, updated_at
""",
(tenant_id, initiative_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
return result
finally:
conn.close()
def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[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 id, tenant_id, initiative_id, title, created_at, updated_at
FROM roadmaps
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def list_roadmap_items_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not roadmap:
return []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s
""",
(item_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def create_roadmap_item(
*,
tenant_id: str,
initiative_id: str,
title: str,
item_type: RoadmapItemType = "milestone",
goal_description: str = "",
definition_of_done: Optional[list[Any]] = None,
status: RoadmapItemStatus = "planned",
sequencing_mode: SequencingMode = "sequential",
target_date: Optional[date] = None,
sort_order: int = 0,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_item_type(item_type)
_validate_status(status)
if status == "reached":
raise ValueError("Status 'reached' nur über verify-reached")
_validate_sequencing_mode(sequencing_mode)
roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id)
dod = definition_of_done if definition_of_done is not None else []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO roadmap_items (
tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(
tenant_id,
roadmap["id"],
item_type,
title,
goal_description,
json.dumps(dod),
status,
sequencing_mode,
target_date,
sort_order,
),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.created",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": row["id"],
"initiative_id": initiative_id,
"item_type": item_type,
"title": title,
},
)
return row
def update_roadmap_item(
*,
tenant_id: str,
item_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal_description: Optional[str] = None,
definition_of_done: Optional[list[Any]] = None,
item_type: Optional[RoadmapItemType] = None,
status: Optional[RoadmapItemStatus] = None,
sequencing_mode: Optional[SequencingMode] = None,
target_date: Optional[date] = None,
clear_target_date: bool = False,
sort_order: Optional[int] = None,
) -> Optional[dict[str, Any]]:
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal_description is not None:
updates.append("goal_description = %s")
params.append(goal_description)
if definition_of_done is not None:
updates.append("definition_of_done = %s::jsonb")
params.append(json.dumps(definition_of_done))
if item_type is not None:
_validate_item_type(item_type)
updates.append("item_type = %s")
params.append(item_type)
if status is not None:
_validate_status(status)
if status in TERMINAL_STATUSES and status == "reached":
raise ValueError(
"Status 'reached' nur über verify-reached — nicht direkt setzen"
)
if status in ("moved", "discarded"):
raise ValueError(
"Status moved/discarded erfordert Decision — noch nicht implementiert"
)
updates.append("status = %s")
params.append(status)
if sequencing_mode is not None:
_validate_sequencing_mode(sequencing_mode)
updates.append("sequencing_mode = %s")
params.append(sequencing_mode)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([item_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE roadmap_items
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
result["initiative_id"] = existing["initiative_id"]
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
item=result,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id},
)
if status is not None and status != old_status:
log_audit(
"roadmap_item.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_roadmap_item(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
(item_id, tenant_id),
)
cur.execute(
"DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id",
(item_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"roadmap_item.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id},
)
return deleted
def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
FROM roadmap_item_dependencies
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
ORDER BY created_at ASC
""",
(tenant_id, item_id, item_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,
from_item_id: str,
to_item_id: str,
dependency_type: DependencyType = "requires",
user_id: Optional[str] = None,
) -> dict[str, Any]:
_validate_dependency_type(dependency_type)
if from_item_id == to_item_id:
raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id)
to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id)
if not from_item or not to_item:
raise ValueError("RoadmapItem nicht gefunden")
if from_item["initiative_id"] != to_item["initiative_id"]:
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO roadmap_item_dependencies (
tenant_id, from_item_id, to_item_id, dependency_type
)
VALUES (%s, %s, %s, %s)
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
""",
(tenant_id, from_item_id, to_item_id, dependency_type),
)
row = dict(cur.fetchone())
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
row[key] = str(row[key])
row["created_at"] = row["created_at"].isoformat()
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.dependency_added",
user_id=user_id,
tenant_id=tenant_id,
details={
"from_item_id": from_item_id,
"to_item_id": to_item_id,
"dependency_type": dependency_type,
},
)
return row
def verify_reached(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> dict[str, Any]:
"""Gate-Verify: reached nur mit accepted Evidence, completed Review oder gate_override Decision."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
if item["status"] in TERMINAL_STATUSES:
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
initiative_id = item["initiative_id"]
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT 1 FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'accepted'
AND milestone_id = %s
LIMIT 1
""",
(tenant_id, initiative_id, item_id),
)
if cur.fetchone():
verify_reason = "evidence_accepted"
else:
cur.execute(
"""
SELECT 1 FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'completed'
AND (milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id),
)
if cur.fetchone():
verify_reason = "review_completed"
else:
cur.execute(
"""
SELECT 1 FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'decided'
AND title ILIKE %s
LIMIT 1
""",
(tenant_id, initiative_id, f"gate_override:{item_id}%"),
)
if cur.fetchone():
verify_reason = "gate_override_decision"
else:
raise ValueError(
"Verify fehlgeschlagen — accepted Evidence, completed Review "
"oder gate_override Decision erforderlich"
)
cur.execute(
"""
UPDATE roadmap_items
SET status = 'reached', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
row["verify_reason"] = verify_reason
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.reached",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
)
return row

View File

@ -256,19 +256,20 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
SELECT SELECT
'milestone_at_risk' AS kind, 'milestone_at_risk' AS kind,
'warning' AS severity, 'warning' AS severity,
m.title AS title, ri.title AS title,
'Meilenstein als gef├ñhrdet markiert' AS summary, 'Plan-Element als gefährdet markiert' AS summary,
'milestone' AS scope_type, 'milestone' AS scope_type,
m.id AS scope_id, ri.id AS scope_id,
m.initiative_id, r.initiative_id,
NULL::uuid AS action_id, NULL::uuid AS action_id,
NULL::uuid AS blocker_id, NULL::uuid AS blocker_id,
m.id AS milestone_id, ri.id AS milestone_id,
'milestone_at_risk' AS reason_code, 'milestone_at_risk' AS reason_code,
'milestones' AS data_source 'roadmap_items' AS data_source
FROM milestones m FROM roadmap_items ri
WHERE m.tenant_id = %s AND m.status = 'at_risk' JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
ORDER BY m.updated_at DESC WHERE ri.tenant_id = %s AND ri.status = 'at_risk'
ORDER BY ri.updated_at DESC
LIMIT 20 LIMIT 20
""", """,
(ctx.tenant_id,), (ctx.tenant_id,),

View File

@ -0,0 +1,47 @@
"""Initiative snapshot signals — erklärbare Heuristiken ohne operating_phase (AP1.2)."""
from __future__ import annotations
def derive_initiative_signals(
*,
initiative_status: str,
open_actions: int,
blocked_actions: int,
review_required_actions: int,
open_blockers: int,
backlog_new: int,
planned_reviews_due: int,
) -> list[str]:
"""Regelbasierte Signale für Steuerungs-Snapshot — kein Lifecycle-Ersatz."""
if initiative_status in ("completed", "archived"):
return ["initiative_terminal"]
signals: list[str] = []
if open_actions == 0 and backlog_new > 0:
signals.append("backlog_awaiting_commit")
return signals
if open_actions == 0 and backlog_new == 0:
signals.append("no_open_work")
return signals
if blocked_actions > 0:
signals.append("actions_blocked")
if open_blockers > 0:
signals.append("open_blockers")
if blocked_actions > 0 or open_blockers > 0:
return signals
if review_required_actions > 0:
signals.append("actions_need_review")
if planned_reviews_due > 0:
signals.append("reviews_due")
if review_required_actions > 0 or planned_reviews_due > 0:
return signals
if open_actions > 0:
signals.append("work_in_progress")
return signals

View File

@ -36,20 +36,22 @@ class ProductMilestoneDrivenStrategy:
""" """
SELECT SELECT
'review_milestone' AS kind, 'review_milestone' AS kind,
m.title AS title, ri.title AS title,
'Meilenstein prüfen oder Status aktualisieren' AS summary, 'Plan-Element prüfen oder Status aktualisieren' AS summary,
m.initiative_id, r.initiative_id,
NULL::uuid AS action_id, NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id, NULL::uuid AS backlog_item_id,
'milestone_at_risk_or_active' AS reason_code, 'milestone_at_risk_or_active' AS reason_code,
'Meilenstein prüfen' AS recommended_action 'Plan-Element prüfen' AS recommended_action
FROM milestones m FROM roadmap_items ri
WHERE m.tenant_id = %s AND m.initiative_id = %s JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
AND m.status IN ('at_risk', 'active') WHERE ri.tenant_id = %s AND r.initiative_id = %s
AND ri.item_type = 'milestone'
AND ri.status IN ('at_risk', 'active')
ORDER BY ORDER BY
CASE m.status WHEN 'at_risk' THEN 0 ELSE 1 END, CASE ri.status WHEN 'at_risk' THEN 0 ELSE 1 END,
m.target_date ASC NULLS LAST, ri.target_date ASC NULLS LAST,
m.updated_at DESC ri.updated_at DESC
LIMIT %s LIMIT %s
""", """,
(ctx.tenant_id, initiative_id, limit), (ctx.tenant_id, initiative_id, limit),

View File

@ -30,17 +30,8 @@ def test_steering_snapshot_graph(client):
) )
assert snap.status_code == 200 assert snap.status_code == 200
data = snap.json() data = snap.json()
assert data["operating_phase"] in ( assert isinstance(data.get("signals"), list)
"execute", assert "operating_phase" not in data
"triage",
"structure",
"review",
"adapt",
"commit",
"capture",
"verify",
"closure",
)
linked = next(a for a in data["actions"] if a["id"] == action["id"]) linked = next(a for a in data["actions"] if a["id"] == action["id"])
assert len(linked["blockers"]) == 1 assert len(linked["blockers"]) == 1
assert linked["open_blocker_count"] == 1 assert linked["open_blocker_count"] == 1

View File

@ -0,0 +1,44 @@
"""AP1.2 — Snapshot signals without operating_phase."""
from __future__ import annotations
from steering.signals.snapshot_signals import derive_initiative_signals
def test_derive_signals_backlog_awaiting_commit():
signals = derive_initiative_signals(
initiative_status="active",
open_actions=0,
blocked_actions=0,
review_required_actions=0,
open_blockers=0,
backlog_new=3,
planned_reviews_due=0,
)
assert signals == ["backlog_awaiting_commit"]
def test_derive_signals_work_in_progress():
signals = derive_initiative_signals(
initiative_status="active",
open_actions=2,
blocked_actions=0,
review_required_actions=0,
open_blockers=0,
backlog_new=0,
planned_reviews_due=0,
)
assert signals == ["work_in_progress"]
def test_derive_signals_terminal():
signals = derive_initiative_signals(
initiative_status="completed",
open_actions=0,
blocked_actions=0,
review_required_actions=0,
open_blockers=0,
backlog_new=0,
planned_reviews_due=0,
)
assert signals == ["initiative_terminal"]

View File

@ -0,0 +1,122 @@
"""AP1.4 — RoadmapItem & Quality Gate tests."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_initiative,
_login,
)
def _create_roadmap_item(client, token, initiative_id, **kwargs):
body = {"title": "Gate A", "item_type": "milestone", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/roadmap/items",
json=body,
headers=_auth(token),
)
def test_roadmap_item_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_roadmap_item(
client,
token,
initiative_id,
sequencing_mode="parallel",
item_type="maturity_stage",
)
assert created.status_code == 201
item = created.json()
assert item["item_type"] == "maturity_stage"
assert item["sequencing_mode"] == "parallel"
listed = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
headers=_auth(token),
)
assert listed.status_code == 200
assert len(listed.json()) >= 1
patched = client.patch(
f"/api/roadmap-items/{item['id']}",
json={"status": "at_risk"},
headers=_auth(token),
)
assert patched.status_code == 200
assert patched.json()["status"] == "at_risk"
deleted = client.delete(f"/api/roadmap-items/{item['id']}", headers=_auth(token))
assert deleted.status_code == 204
def test_verify_reached_requires_evidence(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_roadmap_item(client, token, initiative_id, status="active").json()
fail = client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
assert fail.status_code == 400
evidence = client.post(
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Nachweis",
"milestone_id": item["id"],
"status": "accepted",
},
headers=_auth(token),
)
assert evidence.status_code == 201
ok = client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
assert ok.status_code == 200
assert ok.json()["status"] == "reached"
assert ok.json()["verify_reason"] == "evidence_accepted"
def test_milestone_compat_api_uses_roadmap(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = client.post(
f"/api/initiatives/{initiative_id}/milestones",
json={"title": "Legacy MS", "status": "planned"},
headers=_auth(token),
)
assert created.status_code == 201
ms_id = created.json()["id"]
roadmap = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
headers=_auth(token),
).json()
assert any(i["id"] == ms_id and i["item_type"] == "milestone" for i in roadmap)
def test_steering_snapshot_reads_roadmap_items(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
_create_roadmap_item(client, token, initiative_id, status="active", target_date="2026-12-31")
snap = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
).json()
assert snap.get("roadmap_items")
assert snap.get("upcoming_milestones")
assert len(snap["upcoming_roadmap_items"]) >= 1

View File

@ -41,7 +41,8 @@ def test_steering_snapshot_has_lifecycle(client):
assert snap.status_code == 200 assert snap.status_code == 200
data = snap.json() data = snap.json()
assert "lifecycle_state" in data assert "lifecycle_state" in data
assert data.get("operating_phase_deprecated") is True assert "signals" in data
assert "operating_phase" not in data
assert data["lifecycle_state"] in ( assert data["lifecycle_state"] in (
"intake", "intake",
"planning", "planning",

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.12.0-ap1.2c" APP_VERSION = "0.13.0-ap1.4"
DB_SCHEMA_VERSION = "009" DB_SCHEMA_VERSION = "010"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -50,10 +50,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Review | ✓ | | | Review | ✓ | |
| RecurringElement | ✓ | | | RecurringElement | ✓ | |
| Task (unter Action) | ✗ | | | Task (unter Action) | ✗ | |
| Roadmap | ✗ | 📄 Target State | | Roadmap | ◐ | 1 pro Initiative; Migration 010 |
| RoadmapItem | ✗ | 📄; Milestone soll hierhin | | RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat |
| RoadmapItem Dependencies | ✗ | | | RoadmapItem Dependencies | ◐ | requires/blocks/related |
| Plan-Ist-Verknüpfung | ✗ | Backlog/Action → Gate | | Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 |
--- ---
@ -70,7 +70,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 | | Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
| AttentionItem | ◐ | | | AttentionItem | ◐ | |
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked | | Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
| operating_phase | ◐ | deprecated flag; noch im Snapshot | | operating_phase | ✗ | entfernt AP1.2; Snapshot nutzt `signals` + Lifecycle |
| signals (Snapshot) | ✓ | `backend/steering/signals/snapshot_signals.py` |
| Hook Orchestrator | ✗ | AP1.3 geplant | | Hook Orchestrator | ✗ | AP1.3 geplant |
| Structure Builder | ✗ | 📄 | | Structure Builder | ✗ | 📄 |
| DoD Engine | ✗ | 📄 | | DoD Engine | ✗ | 📄 |
@ -133,7 +134,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| AP | Erwartete Truth-Table-Änderung | | AP | Erwartete Truth-Table-Änderung |
|----|--------------------------------| |----|--------------------------------|
| AP1.2c | IA-Skeleton ✓ | | AP1.2c | IA-Skeleton ✓ |
| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ | | AP1.2 | operating_phase ✗, signals ✓ |
| AP1.4 | RoadmapItem ◐, Gate Verify ◐ |
| AP1.5 | Task ○, Project GUI ◐ | | AP1.5 | Task ○, Project GUI ◐ |
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ | | AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.12.0-ap1.2c", "version": "0.13.0-ap1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -0,0 +1,40 @@
import { apiFetch } from './client.js'
export function listInitiativeRoadmapItems(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
}
export function createInitiativeRoadmapItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateRoadmapItem(itemId, body) {
return apiFetch(`/api/roadmap-items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteRoadmapItem(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}`, { method: 'DELETE' })
}
export function verifyRoadmapItemReached(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/verify-reached`, {
method: 'POST',
})
}
export function listRoadmapItemDependencies(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`)
}
export function addRoadmapItemDependency(itemId, body) {
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`, {
method: 'POST',
body: JSON.stringify(body),
})
}

View File

@ -4,7 +4,7 @@ const AREAS = [
{ {
to: 'plan', to: 'plan',
title: 'Plan', title: 'Plan',
description: 'Meilensteine und Roadmap — überprüfbare Zielpunkte pflegen.', description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
}, },
{ {
to: 'execution', to: 'execution',
@ -34,8 +34,8 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
? `${counts.backlog} Backlog` ? `${counts.backlog} Backlog`
: area.to === 'execution' && counts.actionsOpen : area.to === 'execution' && counts.actionsOpen
? `${counts.actionsOpen} offen` ? `${counts.actionsOpen} offen`
: area.to === 'plan' && counts.milestones : area.to === 'plan' && counts.roadmapItems
? `${counts.milestones} Meilensteine` ? `${counts.roadmapItems} Plan-Elemente`
: null : null
return ( return (
<Link <Link

View File

@ -0,0 +1,220 @@
import { useState } from 'react'
import {
MILESTONE_STATUSES,
MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
SEQUENCING_MODE_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
(s) => !['reached', 'moved', 'discarded'].includes(s)
)
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 RoadmapPlanSection({
items,
canManage,
onCreate,
onUpdateStatus,
onUpdateItem,
onVerifyReached,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [goalDescription, setGoalDescription] = useState('')
const [targetDate, setTargetDate] = useState('')
const [itemType, setItemType] = useState('milestone')
const [sequencingMode, setSequencingMode] = useState('sequential')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({
title: title.trim(),
goal_description: goalDescription.trim(),
target_date: targetDate || undefined,
item_type: itemType,
sequencing_mode: sequencingMode,
})
setTitle('')
setGoalDescription('')
setTargetDate('')
setItemType('milestone')
setSequencingMode('sequential')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Plan</h2>
<p className="section-lead muted">
Roadmap-Elemente methodenneutral Gates, Reifegrade oder Review-Punkte.
Erreicht nur über Verify (Evidence, Review oder Decision).
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Plan-Element'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
<label>
Typ
<select value={itemType} onChange={(e) => setItemType(e.target.value)}>
{ITEM_TYPES.map((t) => (
<option key={t} value={t}>
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
</option>
))}
</select>
</label>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="z. B. MVP nutzbar im Alltag"
/>
</label>
<label>
Ziel / Definition of Done
<textarea
value={goalDescription}
onChange={(e) => setGoalDescription(e.target.value)}
rows={3}
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
/>
</label>
<label>
Abfolge
<select
value={sequencingMode}
onChange={(e) => setSequencingMode(e.target.value)}
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m] || m}
</option>
))}
</select>
</label>
<label>
Zieltermin
<input
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && (
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)}
<ul className="item-list milestone-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item milestone-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
<p className="muted list-item-sub">
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
{' · '}
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
</p>
{item.goal_description && (
<p className="list-item-desc">{item.goal_description}</p>
)}
{item.target_date && (
<p className="muted list-item-sub">
Zieltermin: {formatDate(item.target_date)}
</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="milestone" status={item.status} />
{canManage && item.status !== 'reached' && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Plan-Status"
>
{EDITABLE_STATUSES.map((s) => (
<option key={s} value={s}>
{MILESTONE_STATUS_LABELS[s]}
</option>
))}
</select>
<select
className="inline-select"
value={item.sequencing_mode}
onChange={(e) =>
onUpdateItem(item.id, { sequencing_mode: e.target.value })
}
aria-label="Abfolge"
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m]}
</option>
))}
</select>
{['planned', 'active', 'at_risk'].includes(item.status) && (
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onVerifyReached(item.id)}
>
Verify erreicht
</button>
)}
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -1,7 +1,5 @@
import { import {
LIFECYCLE_LABELS, LIFECYCLE_LABELS,
PHASE_LABELS,
PHASE_DESCRIPTIONS,
SIGNAL_LABELS, SIGNAL_LABELS,
NEXT_ACTION_KIND_LABELS, NEXT_ACTION_KIND_LABELS,
} from '../constants/operating.js' } from '../constants/operating.js'
@ -51,9 +49,7 @@ export function SteeringSnapshotPanel({
lifecycle_label, lifecycle_label,
method_key, method_key,
method_label, method_label,
operating_phase, signals = [],
operating_phase_deprecated,
phase_signals,
counts, counts,
upcoming_milestones = [], upcoming_milestones = [],
next_actions = [], next_actions = [],
@ -61,12 +57,7 @@ export function SteeringSnapshotPanel({
const displayLabel = const displayLabel =
lifecycle_label || lifecycle_label ||
LIFECYCLE_LABELS[lifecycle_state] || LIFECYCLE_LABELS[lifecycle_state] ||
PHASE_LABELS[operating_phase] || lifecycle_state
lifecycle_state ||
operating_phase
const phaseDesc = lifecycle_state
? null
: PHASE_DESCRIPTIONS[operating_phase] || ''
const statPills = [ const statPills = [
{ label: 'Offen', value: counts.actions_open, warn: false }, { label: 'Offen', value: counts.actions_open, warn: false },
@ -126,11 +117,9 @@ export function SteeringSnapshotPanel({
</ul> </ul>
</div> </div>
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>} {signals?.length > 0 && (
{phase_signals?.length > 0 && (
<p className="muted snapshot-signals"> <p className="muted snapshot-signals">
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')} {signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
</p> </p>
)} )}

View File

@ -60,6 +60,18 @@ export const MILESTONE_STATUS_LABELS = {
discarded: 'Verworfen', discarded: 'Verworfen',
} }
export const ROADMAP_ITEM_TYPE_LABELS = {
milestone: 'Meilenstein / Gate',
review_gate: 'Review-Gate',
maturity_stage: 'Reifegrad-Stufe',
}
export const SEQUENCING_MODE_LABELS = {
sequential: 'Sequenziell',
parallel: 'Parallel',
optional: 'Optional',
}
export const EVIDENCE_STATUS_LABELS = { export const EVIDENCE_STATUS_LABELS = {
submitted: 'Eingereicht', submitted: 'Eingereicht',
accepted: 'Akzeptiert', accepted: 'Akzeptiert',

View File

@ -28,11 +28,12 @@ import {
convertBacklogToAction, convertBacklogToAction,
} from '../api/backlog.js' } from '../api/backlog.js'
import { import {
listInitiativeMilestones, listInitiativeRoadmapItems,
createInitiativeMilestone, createInitiativeRoadmapItem,
updateMilestone, updateRoadmapItem,
deleteMilestone, deleteRoadmapItem,
} from '../api/milestones.js' verifyRoadmapItemReached,
} from '../api/roadmap.js'
import { import {
listInitiativeEvidence, listInitiativeEvidence,
createInitiativeEvidence, createInitiativeEvidence,
@ -74,7 +75,7 @@ export function InitiativeOperationsProvider({ children }) {
const [actions, setActions] = useState([]) const [actions, setActions] = useState([])
const [blockers, setBlockers] = useState([]) const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([]) const [backlogItems, setBacklogItems] = useState([])
const [milestones, setMilestones] = useState([]) const [roadmapItems, setRoadmapItems] = useState([])
const [evidenceItems, setEvidenceItems] = useState([]) const [evidenceItems, setEvidenceItems] = useState([])
const [decisions, setDecisions] = useState([]) const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([]) const [reviews, setReviews] = useState([])
@ -110,7 +111,7 @@ export function InitiativeOperationsProvider({ children }) {
if (canReadOm) { if (canReadOm) {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([]))) loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([]))) loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([]))) loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([])))
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([]))) loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([]))) loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([]))) loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
@ -316,10 +317,10 @@ export function InitiativeOperationsProvider({ children }) {
} }
} }
async function handleCreateMilestone(body) { async function handleCreateRoadmapItem(body) {
setFormBusy(true) setFormBusy(true)
try { try {
await createInitiativeMilestone(id, body) await createInitiativeRoadmapItem(id, body)
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -328,24 +329,50 @@ export function InitiativeOperationsProvider({ children }) {
} }
} }
async function handleMilestoneStatus(milestoneId, status) { async function handleRoadmapItemStatus(itemId, status) {
try { try {
await updateMilestone(milestoneId, { status }) await updateRoadmapItem(itemId, { status })
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
} }
async function handleDeleteMilestone(milestoneId) { async function handleUpdateRoadmapItem(itemId, body) {
try { try {
await deleteMilestone(milestoneId) await updateRoadmapItem(itemId, body)
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
} }
async function handleVerifyRoadmapItem(itemId) {
setFormBusy(true)
try {
await verifyRoadmapItemReached(itemId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteRoadmapItem(itemId) {
try {
await deleteRoadmapItem(itemId)
await load()
} catch (err) {
setError(err.message)
}
}
const milestones = useMemo(
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
[roadmapItems]
)
async function handleCreateEvidence(body) { async function handleCreateEvidence(body) {
setFormBusy(true) setFormBusy(true)
try { try {
@ -474,6 +501,7 @@ export function InitiativeOperationsProvider({ children }) {
blockers, blockers,
unlinkedBlockers, unlinkedBlockers,
backlogItems, backlogItems,
roadmapItems,
milestones, milestones,
evidenceItems, evidenceItems,
decisions, decisions,
@ -513,9 +541,14 @@ export function InitiativeOperationsProvider({ children }) {
handleBacklogStatus, handleBacklogStatus,
handleConvertBacklog, handleConvertBacklog,
handleDeleteBacklog, handleDeleteBacklog,
handleCreateMilestone, handleCreateRoadmapItem,
handleMilestoneStatus, handleRoadmapItemStatus,
handleDeleteMilestone, handleUpdateRoadmapItem,
handleVerifyRoadmapItem,
handleDeleteRoadmapItem,
handleCreateMilestone: handleCreateRoadmapItem,
handleMilestoneStatus: handleRoadmapItemStatus,
handleDeleteMilestone: handleDeleteRoadmapItem,
handleCreateEvidence, handleCreateEvidence,
handleEvidenceStatus, handleEvidenceStatus,
handleDeleteEvidence, handleDeleteEvidence,

View File

@ -16,6 +16,7 @@ export function InitiativeOverviewPage() {
capabilities, capabilities,
handleMethodChange, handleMethodChange,
backlogItems, backlogItems,
roadmapItems,
milestones, milestones,
} = useInitiativeOperations() } = useInitiativeOperations()
@ -69,7 +70,7 @@ export function InitiativeOverviewPage() {
initiativeId={initiativeId} initiativeId={initiativeId}
counts={{ counts={{
backlog: backlogItems.length, backlog: backlogItems.length,
milestones: milestones.length, roadmapItems: roadmapItems.length,
actionsOpen: counts.actions_open, actionsOpen: counts.actions_open,
}} }}
/> />

View File

@ -1,14 +1,16 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { MilestonesSection } from '../../components/MilestonesSection.jsx' import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
export function InitiativePlanPage() { export function InitiativePlanPage() {
const { const {
milestones, roadmapItems,
capabilities, capabilities,
formBusy, formBusy,
handleCreateMilestone, handleCreateRoadmapItem,
handleMilestoneStatus, handleRoadmapItemStatus,
handleDeleteMilestone, handleUpdateRoadmapItem,
handleVerifyRoadmapItem,
handleDeleteRoadmapItem,
error, error,
} = useInitiativeOperations() } = useInitiativeOperations()
@ -19,12 +21,14 @@ export function InitiativePlanPage() {
return ( return (
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<MilestonesSection <RoadmapPlanSection
milestones={milestones} items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')} canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateMilestone} onCreate={handleCreateRoadmapItem}
onUpdateStatus={handleMilestoneStatus} onUpdateStatus={handleRoadmapItemStatus}
onDelete={handleDeleteMilestone} onUpdateItem={handleUpdateRoadmapItem}
onVerifyReached={handleVerifyRoadmapItem}
onDelete={handleDeleteRoadmapItem}
busy={formBusy} busy={formBusy}
/> />
</> </>