feat(steering): Epic Roll-up Read Model und Attention (ADP P4)
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
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) Has been cancelled

Schliesst den Backlog/Epic-Track mit kernel-basiertem Fortschritt pro Epic, Steuerungs-Attention und UI-Fortschrittsanzeige im Plan-Eingang ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 11:13:47 +02:00
parent a13913f1a0
commit ef5ee0d19b
13 changed files with 708 additions and 5 deletions

View File

@ -125,7 +125,8 @@ def get_initiative_steering_snapshot(
cur.execute(
"""
SELECT id, title, status, priority, converted_action_id, created_at, updated_at
SELECT id, title, status, priority, converted_action_id, item_kind,
parent_backlog_id, created_at, updated_at
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
@ -297,6 +298,31 @@ def get_initiative_steering_snapshot(
next_actions = evaluation.next_work
kernel_attention = evaluation.attention
from services.operating_context import get_operating_context
from steering.read_models.epic_rollup import compute_epic_rollup
operating = get_operating_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
epic_rollup: list[dict[str, Any]] = []
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
"epic_hierarchy"
):
epic_rollup = compute_epic_rollup(
backlog_items=[
{
"id": _sid(b["id"]),
"title": b["title"],
"status": b["status"],
"item_kind": b.get("item_kind"),
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
"converted_action_id": _sid(b.get("converted_action_id")),
}
for b in backlog
],
actions=actions_raw,
)
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
metadata = steering.get("lifecycle_metadata") or {}
if isinstance(metadata, str):
@ -355,6 +381,7 @@ def get_initiative_steering_snapshot(
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"active_work_cycle": active_work_cycle,
"epic_rollup": epic_rollup,
"counts": {
"actions_open": open_actions,
"actions_blocked": blocked_actions,
@ -384,6 +411,8 @@ def get_initiative_steering_snapshot(
"id": _sid(b["id"]),
"title": b["title"],
"status": b["status"],
"item_kind": b.get("item_kind"),
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
"converted_action_id": _sid(b.get("converted_action_id")),
}
for b in backlog

View File

@ -129,6 +129,27 @@ def evaluate_attention(
)
)
from services.operating_context import get_operating_context
from services import backlog as backlog_service
from steering.read_models.epic_rollup import (
compute_epic_rollup,
epic_rollup_attention_items,
)
operating = get_operating_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
"epic_hierarchy"
):
backlog_items = backlog_service.list_backlog_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
rollups = compute_epic_rollup(backlog_items=backlog_items, actions=actions)
items.extend(
epic_rollup_attention_items(initiative_id=initiative_id, rollups=rollups)
)
if (
not next_work
and binding.primary_method_key in _GATE_METHODS

View File

@ -0,0 +1 @@
"""Steering read models — derived views, no OM mutation."""

View File

@ -0,0 +1,170 @@
"""Epic roll-up read model — ADP P4 / Backlog-Hierarchie.
Fortschritt eines Epics = Aggregation committeter/erledigter Stories im Subbaum.
Kein Gate-Status; Leading bleibt Action.
"""
from __future__ import annotations
from typing import Any, Literal
EpicRollupStatus = Literal["empty", "intake_only", "in_progress", "done"]
_DONE_ACTION = frozenset({"done"})
_ACTIVE_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
_CHILD_EXCLUDE = frozenset({"rejected"})
def _action_status(actions_by_id: dict[str, dict[str, Any]], action_id: Any) -> str | None:
if not action_id:
return None
row = actions_by_id.get(str(action_id))
return row.get("status") if row else None
def compute_epic_rollup(
*,
backlog_items: list[dict[str, Any]],
actions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Roll-up pro Epic-BacklogItem (item_kind=epic, nicht rejected)."""
actions_by_id = {str(a["id"]): a for a in actions if a.get("id")}
epics = [
b
for b in backlog_items
if b.get("item_kind") == "epic" and b.get("status") not in _CHILD_EXCLUDE
]
rollups: list[dict[str, Any]] = []
for epic in epics:
epic_id = str(epic["id"])
children = [
b
for b in backlog_items
if b.get("parent_backlog_id")
and str(b["parent_backlog_id"]) == epic_id
and b.get("item_kind") != "epic"
and b.get("status") not in _CHILD_EXCLUDE
]
child_total = len(children)
committed: list[dict[str, Any]] = []
uncommitted: list[dict[str, Any]] = []
for child in children:
if child.get("status") == "converted" or child.get("converted_action_id"):
committed.append(child)
else:
uncommitted.append(child)
actions_done = actions_in_progress = actions_open = 0
for child in committed:
status = _action_status(actions_by_id, child.get("converted_action_id"))
if status in _DONE_ACTION:
actions_done += 1
elif status in _ACTIVE_ACTION:
actions_in_progress += 1
actions_open += 1
elif status:
actions_open += 1
if child_total == 0:
rollup_status: EpicRollupStatus = "empty"
elif len(committed) == 0:
rollup_status = "intake_only"
elif actions_done == child_total:
rollup_status = "done"
else:
rollup_status = "in_progress"
completion_ratio = actions_done / child_total if child_total else None
commit_ratio = len(committed) / child_total if child_total else None
rollups.append(
{
"epic_backlog_id": epic_id,
"epic_title": epic.get("title") or "Epic",
"child_total": child_total,
"child_uncommitted": len(uncommitted),
"child_committed": len(committed),
"actions_done": actions_done,
"actions_in_progress": actions_in_progress,
"actions_open": actions_open,
"completion_ratio": completion_ratio,
"commit_ratio": commit_ratio,
"status": rollup_status,
"data_source": "epic_rollup_v0.1",
}
)
rollups.sort(key=lambda r: (r["status"] != "in_progress", r["epic_title"].lower()))
return rollups
def epic_rollup_attention_items(
*,
initiative_id: str,
rollups: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Attention aus Epic-Roll-up — Steuerung, nicht nur Anzeige."""
items: list[dict[str, Any]] = []
for rollup in rollups:
epic_id = rollup["epic_backlog_id"]
title = rollup["epic_title"]
child_total = rollup["child_total"]
if child_total == 0:
continue
if rollup["child_committed"] == 0:
items.append(
{
"kind": "epic_awaiting_commit",
"severity": "info",
"title": title,
"summary": (
f"Epic hat {rollup['child_uncommitted']} Item(s) im Eingang — "
"noch keine committete Arbeit"
),
"scope_type": "backlog",
"scope_id": epic_id,
"initiative_id": initiative_id,
"reason_code": "epic_awaiting_commit",
"data_source": "epic_rollup",
}
)
continue
if rollup["child_uncommitted"] > 0 and rollup["actions_in_progress"] > 0:
items.append(
{
"kind": "epic_uncommitted_during_execution",
"severity": "warning",
"title": title,
"summary": (
f"{rollup['child_uncommitted']} Item(s) noch im Eingang, "
f"während {rollup['actions_in_progress']} Arbeitspaket(e) laufen"
),
"scope_type": "backlog",
"scope_id": epic_id,
"initiative_id": initiative_id,
"reason_code": "epic_uncommitted_during_execution",
"data_source": "epic_rollup",
}
)
if rollup["status"] == "done":
items.append(
{
"kind": "epic_complete",
"severity": "info",
"title": title,
"summary": "Alle Stories im Epic erledigt",
"scope_type": "backlog",
"scope_id": epic_id,
"initiative_id": initiative_id,
"reason_code": "epic_complete",
"data_source": "epic_rollup",
}
)
return items

View File

@ -0,0 +1,141 @@
"""AP2.2h — Epic Roll-up Read Model (ADP P4)."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def _create_product_initiative(client, token, title="Epic Rollup"):
created = _create_initiative(
client,
token,
title=title,
archetype_key="initiative.product",
)
assert created.status_code == 201
return created.json()["id"]
def test_steering_snapshot_includes_epic_rollup(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token)
epic = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Checkout Epic", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
epic_id = epic.json()["id"]
story_a = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Login Story",
"item_kind": "story",
"parent_backlog_id": epic_id,
"status": "accepted",
},
headers=_auth(token),
)
story_a_id = story_a.json()["id"]
client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Payment Story",
"item_kind": "story",
"parent_backlog_id": epic_id,
"status": "accepted",
},
headers=_auth(token),
)
converted = client.post(
f"/api/backlog/{story_a_id}/convert-to-action",
json={},
headers=_auth(token),
)
assert converted.status_code == 201
action_id = converted.json()["action"]["id"]
done = client.patch(
f"/api/actions/{action_id}",
json={"status": "done"},
headers=_auth(token),
)
assert done.status_code == 200
snapshot = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snapshot.status_code == 200
body = snapshot.json()
assert "epic_rollup" in body
assert len(body["epic_rollup"]) == 1
rollup = body["epic_rollup"][0]
assert rollup["epic_backlog_id"] == epic_id
assert rollup["child_total"] == 2
assert rollup["child_uncommitted"] == 1
assert rollup["child_committed"] == 1
assert rollup["actions_done"] == 1
assert rollup["status"] == "in_progress"
assert rollup["completion_ratio"] == 0.5
def test_steering_snapshot_epic_attention_awaiting_commit(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token, title="Epic Attention")
epic = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Epic", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
epic_id = epic.json()["id"]
client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Story",
"item_kind": "story",
"parent_backlog_id": epic_id,
"status": "accepted",
},
headers=_auth(token),
)
snapshot = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snapshot.status_code == 200
attention_codes = [
item.get("code") or item.get("reason_code")
for item in snapshot.json().get("attention_items", [])
]
assert "epic_awaiting_commit" in attention_codes
def test_linear_initiative_has_no_epic_rollup(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Linear Rollup",
archetype_key="initiative.linear_project",
)
initiative_id = created.json()["id"]
snapshot = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snapshot.status_code == 200
assert snapshot.json().get("epic_rollup") == []

View File

@ -0,0 +1,125 @@
"""Unit tests — Epic roll-up read model (P4)."""
from __future__ import annotations
from steering.read_models.epic_rollup import (
compute_epic_rollup,
epic_rollup_attention_items,
)
def _item(
*,
id: str,
title: str,
item_kind: str = "story",
status: str = "accepted",
parent_backlog_id: str | None = None,
converted_action_id: str | None = None,
) -> dict:
return {
"id": id,
"title": title,
"item_kind": item_kind,
"status": status,
"parent_backlog_id": parent_backlog_id,
"converted_action_id": converted_action_id,
}
def test_epic_rollup_empty_epic():
rollups = compute_epic_rollup(
backlog_items=[_item(id="e1", title="Epic", item_kind="epic")],
actions=[],
)
assert len(rollups) == 1
assert rollups[0]["status"] == "empty"
assert rollups[0]["child_total"] == 0
def test_epic_rollup_intake_only():
rollups = compute_epic_rollup(
backlog_items=[
_item(id="e1", title="Checkout", item_kind="epic"),
_item(id="s1", title="Login", parent_backlog_id="e1"),
_item(id="s2", title="Pay", parent_backlog_id="e1"),
],
actions=[],
)
assert rollups[0]["status"] == "intake_only"
assert rollups[0]["child_total"] == 2
assert rollups[0]["child_uncommitted"] == 2
assert rollups[0]["completion_ratio"] == 0.0
def test_epic_rollup_partial_and_done():
rollups = compute_epic_rollup(
backlog_items=[
_item(id="e1", title="Epic", item_kind="epic"),
_item(
id="s1",
title="Done story",
parent_backlog_id="e1",
status="converted",
converted_action_id="a1",
),
_item(id="s2", title="Open story", parent_backlog_id="e1"),
],
actions=[
{"id": "a1", "status": "done"},
{"id": "a2", "status": "in_progress"},
],
)
assert rollups[0]["status"] == "in_progress"
assert rollups[0]["actions_done"] == 1
assert rollups[0]["child_uncommitted"] == 1
assert rollups[0]["completion_ratio"] == 0.5
def test_epic_rollup_all_done():
rollups = compute_epic_rollup(
backlog_items=[
_item(id="e1", title="Epic", item_kind="epic"),
_item(
id="s1",
title="A",
parent_backlog_id="e1",
status="converted",
converted_action_id="a1",
),
],
actions=[{"id": "a1", "status": "done"}],
)
assert rollups[0]["status"] == "done"
assert rollups[0]["completion_ratio"] == 1.0
def test_epic_rollup_attention_awaiting_commit():
rollups = compute_epic_rollup(
backlog_items=[
_item(id="e1", title="Epic", item_kind="epic"),
_item(id="s1", title="Story", parent_backlog_id="e1"),
],
actions=[],
)
items = epic_rollup_attention_items(initiative_id="init-1", rollups=rollups)
assert any(i["reason_code"] == "epic_awaiting_commit" for i in items)
def test_epic_rollup_attention_split_focus():
rollups = compute_epic_rollup(
backlog_items=[
_item(id="e1", title="Epic", item_kind="epic"),
_item(
id="s1",
title="Running",
parent_backlog_id="e1",
status="converted",
converted_action_id="a1",
),
_item(id="s2", title="Waiting", parent_backlog_id="e1"),
],
actions=[{"id": "a1", "status": "in_progress"}],
)
items = epic_rollup_attention_items(initiative_id="init-1", rollups=rollups)
assert any(i["reason_code"] == "epic_uncommitted_during_execution" for i in items)

View File

@ -0,0 +1,98 @@
# ADP — Agile Steering Program (jenseits P4) v0.1
**Status:** PO-Freigabe (Planung — nicht implementiert)
**Stand:** 2026-07-27
**Bezug:** ADP Backlog/Epic P1P4, SPEC-D `agile_iteration`, Steering Kernel Spine, Vision v0.2 (Program Director)
---
## 1. Ausgangslage
Mit **P4 (Epic Roll-up)** ist der **Backlog/Epic-Track** des Agile-Intake abgeschlossen:
- Dual Commit Path (Direct AP + Intake)
- Epic-Baum, Profil-Vokabular, Roll-up + Attention
Das reicht für **Dokumentation und Nachverfolgung**, aber noch nicht für **vorausschauendes Dirigieren** im Sinne der Product Vision: Kairo soll priorisieren, planen und steuern — nicht nur CRUD und Status anzeigen.
---
## 2. Lücke — „Dirigieren“ vs. „Dokumentieren“
| Fähigkeit | Ist (nach P4) | Ziel (Program Director) |
|-----------|---------------|-------------------------|
| Epic-Fortschritt | Roll-up aus committeten Actions | ✓ |
| Sprint-Commit | Manuell aus Eingang | Vorschlag nächster Sprint-Inhalt |
| Priorisierung | Manuell (Priority-Feld) | Kernel-Vorschlag (Stories, Bugs, Debt) |
| Architekturschuld | Nicht modelliert | Führen, abbauen, Attention |
| Guardrail-/Architektur-Reviews | Review-Entität vorhanden | Regelmäßig, automatisiert, KI-unterstützt |
| Agent-Steuerung | Leading = Action | Task-Baum + Operating Context (P5) |
**Fazit:** Agile **Intake/Hierarchie** ist fertig; Agile **Steuerungsprogramm** (Planung, Debt, Reviews) ist ein **eigenes Programm** — nicht in P4 mischen.
---
## 3. Entscheidung — Phasen P5P8 (Agile Steering Program)
| Phase | Inhalt | Schicht | Abhängigkeit |
|-------|--------|---------|--------------|
| **P5** | Agent-Task-Baum unter Action | Ist / Recursive Tasks | ADP Recursive (AP1.5d) |
| **P6** | **Sprint-Vorschlag** — Read Model: committbare Items + Prioritätsranking | Kernel + Snapshot | P4, work_cycles |
| **P7** | **Tech-/Architekturschuld** — Backlog-Typ oder Tag + Attention-Regeln + Abbau-Ziele | Plan + Kernel | Evidence, Review |
| **P8** | **Automatisierte Reviews** — Guardrails, Architektur, Zielerreichung (Actor + optional KI) | Recurring + Review + Agent-Slot | Principle Gate, MCP-Freigabe |
Kein Phase-Sprung. **KI/Prompt/MCP** für P8 erst nach Principle Gate und stabilem Read-Model-Kern (vgl. Architecture Rules — eingefroren bis OM trägt).
---
## 4. P6 — Sprint-Vorschlag (Skizze)
**Read Model** `sprint_planning_proposal` (kein Pflicht-Commit):
- Input: offene Backlog-Items (Epic-Subbaum optional), aktiver/geplanter Sprint, Action-Ist, Blocker
- Output: ranked Liste `{ backlog_item_id, reason_code, score_hint }`
- Attention wenn: Sprint startet in N Tagen und Commit leer; Bugs ohne Owner; Epic intake_only trotz aktivem Sprint
**UI:** Plan → Sprint — „Vorgeschlagene Items“ (Accept/Adjust), nicht Auto-Commit.
**Steuerung:** `evaluate_next_work` / eigene Strategy-Hook — **keine** Page-Ifs.
---
## 5. P7 — Architekturschuld (Skizze)
| Option | Beschreibung | Empfehlung |
|--------|--------------|------------|
| A | `item_kind=tech_debt` im Backlog | Profilgebunden, Commit wie Story |
| B | `action_kind=tech_debt` Direct AP | Continuous-Pfad |
| C | Review + Evidence verknüpft mit Gate | Für Guardrail-Reviews |
**Empfehlung:** A + B (Dual Path analog Features/Bugs) + Attention `architecture_debt_stale` wenn Debt älter als Schwellwert ohne Commit.
**Abbau:** nicht als Gate — als committete Actions mit Review-Evidence (Guardrail-Checkliste).
---
## 6. P8 — Automatisierte Reviews (Skizze)
- **RecurringElement** pro Initiative: „Architektur-Guardrail-Review“, „Sprint-Retro“, „Ziel-Check“
- **Review**-Entität mit Checkliste (config, nicht hardcoded in UI)
- **Actor-Slot:** Agent führt Review aus → Evidence + Decision-Vorschlag
- **KI:** nur über auditierten Agent-Actor; Kontext aus Snapshot/Operating Context — nicht Prompt-Drift
---
## 7. Abgrenzung zu P4
P4 liefert **Ist-Fortschritt auf Epic-Ebene**. P6P8 liefern **Vorausschau und Korrektur** — eigenes ADP-Implementierungsprogramm, gleicher Kernel-Einstieg (`evaluate_steering`).
---
## 8. Referenzen
| Dokument | Pfad |
|----------|------|
| ADP Backlog/Epic | `docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md` |
| SPEC agile_iteration | `docs/architecture/methods/SPEC_D_agile_iteration_v0.1.md` |
| Recursive Tasks | AP1.5d / ADP Recursive Containers |
| Implementation Truth Table | `docs/product/Kairo_Implementation_Truth_Table_v0.1.md` |

View File

@ -164,7 +164,7 @@ Product `planOutlineKeys` (Ziel): `profile`, `inbox`, `work`, `sprint`, `gates`
| **P1 — Direct AP** | Plan → Arbeit im Product-Profil; Product-Labels | UI only |
| **P2 — Backlog-Baum** | `parent_backlog_id`, `item_kind=epic` | Migration |
| **P3 — Profil-Vokabular** | Operating Context liefert erlaubte `backlog_item_kinds` | AP2.4 Erweiterung |
| **P4 — Epic Roll-up** | Read Model Fortschritt / Attention | Steering Kernel |
| **P4 — Epic Roll-up** | Read Model Fortschritt / Attention | Steering Kernel | ✓ AP2.2h |
| **P5 — Agent-Tasks** | Task-Baum unter AP (ADP Recursive) | AP1.5d |
Kein Phase-Sprung ohne ADP/Spec-Referenz.

View File

@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| `actions.sort_order` | ◐ | Migration 023 |
| BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` |
| BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden |
| Backlog Epic-Hierarchie | ◐ | AP2.2f: `epic` + `parent_backlog_id`, Baum-UI, Convert-Guard — Roll-up (P4) offen |
| Backlog Epic-Hierarchie | ✓ | AP2.2f/h: Epic-Baum + P4 Roll-up Read Model (`epic_rollup`, Attention) |
| Dual Commit (Direct AP) | ◐ | ADP P1: Plan→Arbeit im Product-Outline (2026-07-26) |
| Profil-Vokabular Backlog | ◐ | AP2.2g: `backlog_vocabulary` + `backlog_epic_hierarchy` via effektive `steering_elements` |
| Blocker | ✓ | `action_id` optional |
@ -228,7 +228,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
|----|-----------|
| AP2.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) |
| AP2.2a | Starter-Kits ◐→✓ |
| ADP Backlog/Epic | P1 Direct AP · P2 Epic-Baum · P3 Profil-Vokabular · P4 Roll-up (2026-07-26 Doku) |
| ADP Backlog/Epic | P1P4 ✓ (Roll-up AP2.2h) · P5 Agent-Tasks offen |
| AP2.2be | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität |

View File

@ -14,6 +14,11 @@ import { useInitiativeOperations } from '../context/InitiativeOperationsContext.
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx'
import { actionTitleById } from '../utils/actionReferences.js'
import {
epicRollupById,
epicRollupProgressPercent,
formatEpicRollupSummary,
} from '../utils/epicRollup.js'
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
@ -38,7 +43,7 @@ export function BacklogSection({
sectionTitle = 'Product Backlog',
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
}) {
const { operatingContext, steeringElements } = useInitiativeOperations()
const { operatingContext, steeringElements, steeringSnapshot } = useInitiativeOperations()
const [modalMode, setModalMode] = useState(null)
const [dragItemId, setDragItemId] = useState('')
const [dropTargetId, setDropTargetId] = useState('')
@ -107,6 +112,12 @@ export function BacklogSection({
return map
}, [epicItems])
const rollupByEpicId = useMemo(
() => epicRollupById(steeringSnapshot?.epic_rollup),
[steeringSnapshot?.epic_rollup],
)
const showEpicRollup = Boolean(vocabulary.capabilities?.epic_hierarchy)
const defaultKind = vocabulary.default_kind || 'story'
const hasConvertedItems = items.some((item) => item.status === 'converted')
const directWorkEnabled = Boolean(
@ -326,6 +337,7 @@ export function BacklogSection({
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
const reorderable = canReorder && item.status !== 'converted'
const isEpic = item.item_kind === 'epic'
const epicRollup = isEpic && showEpicRollup ? rollupByEpicId[item.id] : null
const flatIndex = sortedItems.findIndex((row) => row.id === item.id)
return (
@ -376,6 +388,24 @@ export function BacklogSection({
{item.description && (
<p className="list-item-desc">{item.description}</p>
)}
{epicRollup && (
<div className="epic-rollup-summary">
<div
className="epic-rollup-summary__bar"
role="progressbar"
aria-valuenow={epicRollupProgressPercent(epicRollup)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Epic-Fortschritt ${epicRollupProgressPercent(epicRollup)} Prozent`}
>
<span
className="epic-rollup-summary__fill"
style={{ width: `${epicRollupProgressPercent(epicRollup)}%` }}
/>
</div>
<p className="list-item-sub muted">{formatEpicRollupSummary(epicRollup)}</p>
</div>
)}
{item.parent_backlog_id && (
<p className="list-item-sub muted">
Epic: {epicTitleById.get(item.parent_backlog_id) || '…'}

View File

@ -1678,6 +1678,27 @@
opacity: 0.75;
}
.epic-rollup-summary {
margin-top: 0.35rem;
max-width: 28rem;
}
.epic-rollup-summary__bar {
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--jk-primary) 10%, var(--jk-surface));
overflow: hidden;
margin-bottom: 0.25rem;
}
.epic-rollup-summary__fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--jk-primary);
transition: width 0.2s ease;
}
.archetype-badge {
background: var(--jk-surface-muted, #eef2f6);
color: var(--jk-text-secondary, #445);

View File

@ -0,0 +1,35 @@
/** Epic roll-up labels for Backlog UI (P4). */
export function epicRollupById(epicRollup = []) {
return Object.fromEntries(
(epicRollup || []).map((row) => [row.epic_backlog_id, row]),
)
}
export function formatEpicRollupSummary(rollup) {
if (!rollup || rollup.child_total === 0) {
return 'Noch keine Stories unter diesem Epic'
}
const done = rollup.actions_done ?? 0
const total = rollup.child_total
const uncommitted = rollup.child_uncommitted ?? 0
if (rollup.status === 'done') {
return `${done}/${total} erledigt — Epic abgeschlossen`
}
if (rollup.status === 'intake_only') {
return `${total} Item(s) im Eingang — noch nicht committet`
}
let text = `${done}/${total} erledigt`
if (uncommitted > 0) {
text += `, ${uncommitted} noch im Eingang`
}
if (rollup.actions_in_progress > 0) {
text += `, ${rollup.actions_in_progress} in Arbeit`
}
return text
}
export function epicRollupProgressPercent(rollup) {
if (!rollup?.child_total) return 0
return Math.round((rollup.completion_ratio ?? 0) * 100)
}

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
epicRollupById,
epicRollupProgressPercent,
formatEpicRollupSummary,
} from './epicRollup.js'
describe('epicRollup', () => {
const rollup = {
epic_backlog_id: 'e1',
child_total: 4,
child_uncommitted: 1,
actions_done: 2,
actions_in_progress: 1,
completion_ratio: 0.5,
status: 'in_progress',
}
it('maps rollup by epic id', () => {
const map = epicRollupById([rollup])
expect(map.e1).toEqual(rollup)
})
it('formats in-progress summary', () => {
expect(formatEpicRollupSummary(rollup)).toContain('2/4 erledigt')
expect(formatEpicRollupSummary(rollup)).toContain('1 noch im Eingang')
})
it('computes progress percent', () => {
expect(epicRollupProgressPercent(rollup)).toBe(50)
})
})