AP1.5c: Rekursive Projects fuer Mega-Programme ohne Stream-Typ.
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m42s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 21s
Test Suite / playwright-smoke (push) Successful in 14s

parent_project_id und container_kind ermoeglichen verschachtelte Projektstruktur; Arbeitspakete bleiben an Blatt-Projekten gebunden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-06 08:38:58 +02:00
parent 6eeddfdc76
commit 4ca0a2c460
15 changed files with 947 additions and 91 deletions

View File

@ -0,0 +1,12 @@
-- AP1.5c: Rekursive Projects (parent_project_id, container_kind)
ALTER TABLE projects
ADD COLUMN parent_project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL,
ADD COLUMN container_kind VARCHAR(32) NULL
CHECK (
container_kind IS NULL
OR container_kind IN ('project', 'stream', 'phase', 'release')
);
CREATE INDEX idx_projects_parent ON projects(tenant_id, parent_project_id)
WHERE parent_project_id IS NOT NULL;

View File

@ -19,6 +19,8 @@ class ProjectCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "completed", "archived"] = "active"
parent_project_id: Optional[str] = None
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
roadmap_item_id: Optional[str] = None
sort_order: int = 0
target_date: Optional[date] = None
@ -28,6 +30,10 @@ class ProjectUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
parent_project_id: Optional[str] = None
clear_parent_project: bool = False
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
clear_container_kind: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
@ -62,6 +68,8 @@ def create_initiative_project(
title=body.title,
description=body.description,
status=body.status,
parent_project_id=body.parent_project_id,
container_kind=body.container_kind,
roadmap_item_id=body.roadmap_item_id,
sort_order=body.sort_order,
target_date=body.target_date,
@ -95,6 +103,10 @@ def update_project(
title=body.title,
description=body.description,
status=body.status,
parent_project_id=body.parent_project_id,
clear_parent_project=body.clear_parent_project,
container_kind=body.container_kind,
clear_container_kind=body.clear_container_kind,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
@ -113,7 +125,11 @@ def delete_project(
project_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
if not project_service.delete_project(
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
):
try:
deleted = project_service.delete_project(
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")

View File

@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative
from services.projects import project_is_leaf
ActionStatus = Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
@ -116,6 +117,10 @@ def _validate_project_in_initiative(
)
if not cur.fetchone():
raise ValueError("Projekt gehört nicht zum Vorhaben")
if not project_is_leaf(tenant_id=tenant_id, project_id=project_id):
raise ValueError(
"Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)"
)
finally:
conn.close()

View File

@ -1,4 +1,4 @@
"""Project service — operative Struktur unter Initiative (AP1.5)."""
"""Project service — operative Struktur unter Initiative (AP1.5, AP1.5c)."""
from __future__ import annotations
@ -14,15 +14,21 @@ from services.initiatives import get_initiative
ProjectStatus = Literal["active", "paused", "completed", "archived"]
PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"})
ContainerKind = Literal["project", "stream", "phase", "release"]
CONTAINER_KINDS = frozenset({"project", "stream", "phase", "release"})
MAX_PROJECT_DEPTH = 5
_PROJECT_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
id, tenant_id, initiative_id, parent_project_id, container_kind,
title, description, status,
roadmap_item_id, sort_order, target_date, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"):
for key in ("id", "tenant_id", "initiative_id", "parent_project_id", "roadmap_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
@ -42,6 +48,11 @@ def _validate_status(status: str) -> None:
raise ValueError(f"Ungültiger Project-Status: {status}")
def _validate_container_kind(container_kind: Optional[str]) -> None:
if container_kind is not None and container_kind not in CONTAINER_KINDS:
raise ValueError(f"Ungültiger container_kind: {container_kind}")
def _validate_roadmap_item(
cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str]
) -> None:
@ -59,6 +70,151 @@ def _validate_roadmap_item(
raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben")
def _fetch_project_row(
cur, *, tenant_id: str, project_id: str, initiative_id: Optional[str] = None
) -> Optional[dict[str, Any]]:
query = f"""
SELECT {_PROJECT_COLUMNS}
FROM projects
WHERE id = %s AND tenant_id = %s
"""
params: list[Any] = [project_id, tenant_id]
if initiative_id is not None:
query += " AND initiative_id = %s"
params.append(initiative_id)
cur.execute(query, params)
row = cur.fetchone()
return dict(row) if row else None
def _project_depth_from_map(
project_id: str, by_id: dict[str, dict[str, Any]], memo: dict[str, int]
) -> int:
if project_id in memo:
return memo[project_id]
project = by_id.get(project_id)
if not project or not project.get("parent_project_id"):
memo[project_id] = 1
return 1
parent_id = str(project["parent_project_id"])
if parent_id == project_id:
raise ValueError("Zyklus in Project-Hierarchie")
depth = _project_depth_from_map(parent_id, by_id, memo) + 1
memo[project_id] = depth
return depth
def _collect_descendant_ids(
project_id: str, by_parent: dict[str, list[str]]
) -> set[str]:
result: set[str] = set()
stack = list(by_parent.get(project_id, []))
while stack:
child_id = stack.pop()
if child_id in result:
continue
result.add(child_id)
stack.extend(by_parent.get(child_id, []))
return result
def _subtree_height(project_id: str, by_parent: dict[str, list[str]]) -> int:
children = by_parent.get(project_id, [])
if not children:
return 1
return 1 + max(_subtree_height(child_id, by_parent) for child_id in children)
def _load_initiative_project_maps(
cur, *, tenant_id: str, initiative_id: str
) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]:
cur.execute(
f"""
SELECT {_PROJECT_COLUMNS}
FROM projects
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
rows = [dict(r) for r in cur.fetchall()]
by_id = {str(r["id"]): r for r in rows}
by_parent: dict[str, list[str]] = {}
for row in rows:
parent_id = row.get("parent_project_id")
if parent_id:
by_parent.setdefault(str(parent_id), []).append(str(row["id"]))
return by_id, by_parent
def _validate_parent_project(
cur,
*,
tenant_id: str,
initiative_id: str,
project_id: Optional[str],
parent_project_id: Optional[str],
) -> None:
if not parent_project_id:
return
if project_id and parent_project_id == project_id:
raise ValueError("Projekt kann nicht sein eigener Parent sein")
parent = _fetch_project_row(
cur, tenant_id=tenant_id, project_id=parent_project_id, initiative_id=initiative_id
)
if not parent:
raise ValueError("Parent-Projekt gehört nicht zu diesem Vorhaben")
by_id, by_parent = _load_initiative_project_maps(
cur, tenant_id=tenant_id, initiative_id=initiative_id
)
if project_id:
descendants = _collect_descendant_ids(project_id, by_parent)
if parent_project_id in descendants:
raise ValueError("Zyklus: Parent darf kein Nachfahr sein")
memo: dict[str, int] = {}
parent_depth = _project_depth_from_map(parent_project_id, by_id, memo)
subtree_height = (
_subtree_height(project_id, by_parent) if project_id else 1
)
if parent_depth + subtree_height > MAX_PROJECT_DEPTH:
raise ValueError(
f"Maximale Project-Tiefe ({MAX_PROJECT_DEPTH}) würde überschritten"
)
def _attach_depths(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not projects:
return projects
by_id = {p["id"]: p for p in projects}
memo: dict[str, int] = {}
for project in projects:
project["depth"] = _project_depth_from_map(project["id"], by_id, memo)
project["has_children"] = any(
other.get("parent_project_id") == project["id"] for other in projects
)
project["is_leaf"] = not project["has_children"]
return projects
def project_is_leaf(*, tenant_id: str, project_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM projects
WHERE parent_project_id = %s AND tenant_id = %s
LIMIT 1
""",
(project_id, tenant_id),
)
return cur.fetchone() is None
finally:
conn.close()
def create_project(
*,
tenant_id: str,
@ -66,6 +222,8 @@ def create_project(
title: str,
description: str = "",
status: ProjectStatus = "active",
parent_project_id: Optional[str] = None,
container_kind: Optional[ContainerKind] = None,
roadmap_item_id: Optional[str] = None,
sort_order: int = 0,
target_date: Optional[date] = None,
@ -75,6 +233,7 @@ def create_project(
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
_validate_container_kind(container_kind)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
@ -87,18 +246,28 @@ def create_project(
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
_validate_parent_project(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
project_id=None,
parent_project_id=parent_project_id,
)
cur.execute(
f"""
INSERT INTO projects (
tenant_id, initiative_id, title, description, status,
tenant_id, initiative_id, parent_project_id, container_kind,
title, description, status,
roadmap_item_id, sort_order, target_date
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_PROJECT_COLUMNS}
""",
(
tenant_id,
initiative_id,
parent_project_id,
container_kind,
title,
description,
status,
@ -116,7 +285,12 @@ def create_project(
"project.created",
user_id=user_id,
tenant_id=tenant_id,
details={"project_id": row["id"], "initiative_id": initiative_id, "title": title},
details={
"project_id": row["id"],
"initiative_id": initiative_id,
"title": title,
"parent_project_id": parent_project_id,
},
)
return row
@ -139,7 +313,8 @@ def list_projects_for_initiative(
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
rows = [_serialize_row(dict(r)) for r in cur.fetchall()]
return _attach_depths(rows)
finally:
conn.close()
@ -150,16 +325,25 @@ def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_PROJECT_COLUMNS}
SELECT initiative_id
FROM projects WHERE id = %s AND tenant_id = %s
""",
(project_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
if not row:
return None
initiative_id = str(row["initiative_id"])
finally:
conn.close()
for project in list_projects_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
):
if project["id"] == project_id:
return project
return None
def update_project(
*,
@ -169,6 +353,10 @@ def update_project(
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[ProjectStatus] = None,
parent_project_id: Optional[str] = None,
clear_parent_project: bool = False,
container_kind: Optional[ContainerKind] = None,
clear_container_kind: bool = False,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
sort_order: Optional[int] = None,
@ -195,6 +383,29 @@ def update_project(
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_parent_project:
updates.append("parent_project_id = NULL")
elif parent_project_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
_validate_parent_project(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
project_id=project_id,
parent_project_id=parent_project_id,
)
finally:
conn.close()
updates.append("parent_project_id = %s")
params.append(parent_project_id)
if clear_container_kind:
updates.append("container_kind = NULL")
elif container_kind is not None:
_validate_container_kind(container_kind)
updates.append("container_kind = %s")
params.append(container_kind)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
@ -251,7 +462,7 @@ def update_project(
tenant_id=tenant_id,
details={"project_id": project_id},
)
return result
return get_project(tenant_id=tenant_id, project_id=project_id)
def delete_project(
@ -260,6 +471,18 @@ def delete_project(
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM projects
WHERE parent_project_id = %s AND tenant_id = %s
LIMIT 1
""",
(project_id, tenant_id),
)
if cur.fetchone():
raise ValueError(
"Projekt hat Unterprojekte — zuerst löschen oder verschieben"
)
cur.execute(
"UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s",
(project_id, tenant_id),

View File

@ -0,0 +1,111 @@
"""AP1.5c — Rekursive Projects (parent_project_id)."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_initiative,
_login,
)
def test_nested_projects_and_depth(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
root = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "Programm", "container_kind": "project"},
headers=_auth(token),
)
assert root.status_code == 201
root_id = root.json()["id"]
stream = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={
"title": "Backend Stream",
"parent_project_id": root_id,
"container_kind": "stream",
},
headers=_auth(token),
)
assert stream.status_code == 201
stream_id = stream.json()["id"]
leaf = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "API", "parent_project_id": stream_id},
headers=_auth(token),
)
assert leaf.status_code == 201
leaf_id = leaf.json()["id"]
listed = client.get(
f"/api/initiatives/{initiative_id}/projects",
headers=_auth(token),
)
assert listed.status_code == 200
items = {p["id"]: p for p in listed.json()}
assert items[stream_id]["parent_project_id"] == root_id
assert items[leaf_id]["depth"] == 3
assert items[leaf_id]["is_leaf"] is True
assert items[root_id]["has_children"] is True
def test_action_only_on_leaf_project(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
parent = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "Container"},
headers=_auth(token),
).json()
child = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "Blatt", "parent_project_id": parent["id"]},
headers=_auth(token),
).json()
blocked = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Am Container", "project_id": parent["id"]},
headers=_auth(token),
)
assert blocked.status_code == 400
assert "Blatt" in blocked.json()["detail"]
ok = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Am Blatt", "project_id": child["id"]},
headers=_auth(token),
)
assert ok.status_code == 201
def test_cannot_delete_project_with_children(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
parent = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "Parent"},
headers=_auth(token),
).json()
child = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "Child", "parent_project_id": parent["id"]},
headers=_auth(token),
).json()
resp = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token))
assert resp.status_code == 400
client.delete(f"/api/projects/{child['id']}", headers=_auth(token))
ok = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token))
assert ok.status_code == 204

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.14.0-ap1.5"
DB_SCHEMA_VERSION = "013"
APP_VERSION = "0.14.1-ap1.5c"
DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo"

View File

@ -82,10 +82,12 @@ Minimaler Fix erlaubt: Tab-Beschreibung / Breadcrumb „Gates“ statt implizit
```text
✓ AP1.4b Gate-Checkliste Backend + Minimal-Detail-Route
→ AP1.5 Project, Task, Ausführung als Ist-Kern (NÄCHSTES CODE-PAKET)
→ AP1.5 Project, Task, Ausführung als Ist-Kern ✓
→ AP1.5c Rekursive Projects (parent_project_id)
→ AP1.5b IA/Begriffe: Gates vs. Ausführung, optionale Gate-Sicht
→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog → Gate)
⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.5
→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog/Project → Gate)
→ AP1.5d Rekursive Tasks (parent_task_id), Roll-up
⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.6
→ AP1.4d Graph Engine (Gate-Kanten only)
→ AP1.4e Methodenprofile (generic_operating ohne Gate-Zwang)
```
@ -101,9 +103,9 @@ Initiative (Vorhaben / Programm)
│ ▲
│ │ zahlt ein (Evidence, Zuordnung)
│ │
├── [optional] Project ← AP1.5
├── [optional] Project* ← AP1.5c parent_project_id
│ └── Action (Arbeitspaket) ← Ist — „Plan zwischen Gates“
│ └── Task ← AP1.5 minimal
│ └── Task* ← AP1.5d parent_task_id
├── Backlog → Commit → Action
└── steering_context (Lifecycle, method_key) ← getrennt von Gate-Graph
@ -155,4 +157,4 @@ Initiative (Vorhaben / Programm)
---
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`*
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`*

View File

@ -0,0 +1,198 @@
# ADP — Rekursive Container & Ausführungs-Granularität v0.1
**Status:** PO-Freigabe (2026-07-06)
**Stand:** 2026-07-06
**Auslöser:** PO-Review — Mega-Programme vs. normale Vorhaben; Granularität für Mensch vs. Agent
**Bezug:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, Canonical OM v0.2, Target State §1011
---
## Problem
Zwei verwandte Fragen ohne feste Extra-Typen (`Stream`, `Subtask`, …):
1. **Organisation:** Sehr große Programme brauchen optional **Project in Project**; normale Vorhaben nicht.
2. **Ausführung:** Arbeitspakete und Aufgaben brauchen **methoden- und akteursabhängige Tiefe** — Menschen ~23 Untergliederungen, Agenten feiner.
Fest codierte Ebenen (`Stream`, `WorkPackage`, `Subtask` als eigene Typen) wären für kleine Vorhaben überdimensioniert und duplizieren semantisch **Project** bzw. **Task**.
---
## Betroffene Regeln
| Dokument | Regel |
|----------|--------|
| Canonical OM v0.2 §4 | Project optional; Action = Arbeitspaket; Task methodenabhängig |
| Vision v0.2 §4 | Action ≠ atomare Aufgabe; Task-Hierarchie / WBS |
| ADP Product Layering | Ist = Project → Action → Task; Gates orthogonal |
| Target State §1011 | Steuerbare Objekte inkl. `project`; `SteeringContext` mit `scope_type` |
| Tenant Invariants | Kein paralleles Steering; Actor-first Assignments |
---
## Optionen (Struktur)
| Option | Kurz | Pro | Contra |
|--------|------|-----|--------|
| A | Feste Typen `Stream`, `Subtask` | Klare UI-Labels | Schema-Ballast; Doppelung zu Project/Task |
| B | **Rekursion gleicher Typen** (`parent_*_id`) | Ein Modell für klein + groß | Baum-UI; Roll-up-Regeln nötig |
| C | Unbegrenzte Rekursion + Steuerung pro Ebene | Maximale Flexibilität | Steuerungs-Explosion; MVP-Verzögerung |
---
## Entscheidung (PO-Freigabe 2026-07-06)
### 1. Organisations-Ebene: rekursives **Project**
- **`parent_project_id`** nullable auf `projects` (Adjacency List).
- Gleiche Initiative; **Zyklen verboten**; **max. Tiefe 5** (hard block).
- Optional **`container_kind`** (Registry/Label): `project`, `stream`, `phase`, `release`**nur Anzeige/Methoden-Hint**, keine Sonderlogik pro Kind.
- **Kein** eigener Typ `Stream`.
**Semantik nach Tiefe (UI, nicht Schema):**
| Tiefe | Typisches Label | Beispiel |
|-------|-----------------|----------|
| 0 | Projekt | „Kairo MVP“ |
| 1 | Stream / Phase | „Backend“, „Kumite“ |
| 2+ | Teilprojekt | „API-Schicht“ |
**Guardrails:**
- Actions bevorzugt an **Blatt-Projects** oder direkt an Initiative (Warnung bei Container mit Kindern).
- Gates (`roadmap_item_id`) an jeder Project-Tiefe oder Initiative — Plan/Ist in AP1.6.
**Implementierung:** **AP1.5c** — Migration `014` o.ä.
---
### 2. Ausführungs-Ebene: rekursives **Task** (Haupthebel Granularität)
- **`parent_task_id`** nullable auf `tasks` — gleiche Tabelle, Zeiger auf weitere Instanz.
- **Max. Tiefe Schema:** 10 (Agent/API).
- **Max. Tiefe UI (Menschen):** 3 Ebenen (Task → Subtask → Sub-Subtask) — **Policy**, kein Schema-Limit.
- Roll-up: Action `done` wenn Task-Baum erfüllt (Regeln in AP1.5d); Task `done` wenn alle Kinder `done` oder explizit ohne Kinder.
**Guardrails:**
- **Steuerung bleibt auf Action** (Assignment, Blocker, Evidence, Next Action für Menschen).
- Kein `steering_context` pro Task-Ebene.
- Agenten: Blatt-Tasks als Ausführungsschritte; tiefe Bäume in UI **aggregiert** anzeigen.
**Implementierung:** **AP1.5d****nach AP1.6** (Plan/Ist-Verknüpfung + Gate-Beitrag zuerst).
---
### 3. Action-Verschachtelung: **eingeschränkt**, nicht Default
- **`parent_action_id`** nur für **`wbs_driven`** (später), max. Tiefe **2**.
- Nur **Root-Action** aus Backlog-Commit; Kind-Actions = strukturelle Teilpakete desselben Commits.
- Default-Vorhaben: **eine Action + Task-Baum** — kein Action-Baum.
**Implementierung:** dokumentiert; Code **nicht** in AP1.5c/d — erst mit Methodenprofil / Structure Builder.
---
### 4. Steuerung: Vererbung, nicht Explosion
**Stufe 1 (MVP bis AP1.6+):**
```text
Initiative = einziger Steering-Root (steering_context, heute implementiert)
Project* = Struktur; erbt Initiative-Steuerung
Action = operative Steuerungs-Anker für Menschen (Assignment, Blocker, Evidence)
Task* = Ausführungsschritte; Roll-up, kein eigener Context
```
**Stufe 2 (später, Mega-Programme):**
- Polymorpher `SteeringContext` (`scope_type`, `scope_id`) wie Target State.
- Nur Projects mit `steering_root = true` oder explizitem Override — **nicht** jede Ebene.
---
## Schichtenmodell (Ziel)
```text
Initiative ── steering_context (Pflicht)
├── [optional] Project* parent_project_id
│ └── Project* …
│ └── Action Commit-Grenze (Root aus Backlog)
│ └── Task* parent_task_id
│ └── Task* …
├── [optional] Gates (RoadmapItem) orthogonal
└── Backlog → Commit → Action (Root)
```
**Zwei orthogonale Dimensionen:**
| Dimension | Rekursion | Mensch-Tiefe | Agent-Tiefe |
|-----------|-----------|--------------|-------------|
| Project (Organisation) | ja | 02 typisch | — |
| Action (Arbeitspaket) | selten (WBS) | 1 | 12 |
| Task (Ausführung) | ja | 23 | bis 10 |
---
## Repriorisierung (verbindlich)
```text
✓ AP1.5 Project, Task flach, Ausführung (0.14.0-ap1.5)
→ AP1.5c parent_project_id, Baum-UI, container_kind optional
→ AP1.6 Plan/Ist-Verknüpfung, Journey
→ AP1.5d parent_task_id, Roll-up, UI-Tiefen-Policy
⏸ AP1.4c Gate-UI — weiter eingefroren
→ AP1.4d Gate-Graph
→ AP1.7 Operational Actor Interface (unverändert)
```
AP1.5b (IA-Begriffe) kann parallel zu AP1.5c.
---
## Konsequenzen
| Bereich | Konsequenz |
|---------|------------|
| Schema | Zwei Migrationen: Project-Baum, Task-Baum — getrennte APs |
| UI | Baum/Outline statt nur flache Listen; Default collapsed ab Tiefe 2 |
| API | Cycle-Detection; depth validation |
| Tests | Klein (flach), Mega (Project-Tiefe 3), Agent (Task-Tiefe >3) |
| Steering | Keine Änderung in AP1.5c/d außer Roll-up-Fortschritt |
| Agent AP1.7 | Nutzt Task-Blätter; tiefe Zerlegung über AP1.5d |
---
## Risiko
| Risiko | Mitigation |
|--------|------------|
| UI-Overload durch Tiefe | UI soft cap 3 Tasks; Mega-Projects collapsed |
| Redundante Pfade (Project + Action + Task alle tief) | Guardrails + PO-Dokumentation |
| Commit-Modell bei nested Actions | Nested Actions erst WBS-Profil; Default flach |
| Steuerungs-Explosion | Stufe 1: nur Initiative-Context |
---
## Rückbaubarkeit
- `parent_*_id` nullable — flache Nutzung bleibt Default.
- `container_kind` optional — entfernbar ohne Schema-Bruch.
- Polymorpher SteeringContext ist **Add-on**, kein Breaking Change für AP1.5c/d.
---
## Abnahme PO
- [x] Rekursives Project statt Stream-Typ
- [x] Rekursives Task für Granularität (Mensch 23, Agent tiefer)
- [x] Steering-Root = Initiative (Stufe 1)
- [x] AP1.5c → AP1.6 → AP1.5d Reihenfolge
- [x] Implementierung AP1.5c
- [ ] Implementierung AP1.5d
---
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (Update 2026-07-06), `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`*

View File

@ -6,7 +6,8 @@
**Ersetzt:** `Kairo_Corrected_MVP_Roadmap_v0.1.md`
**Vision:** `Kairo_Vision_and_Product_Direction_v0.2.md`
**Ist-Stand:** `Kairo_Implementation_Truth_Table_v0.1.md`
**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`
**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`
**Container-ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
---
@ -28,8 +29,8 @@
| Entscheidung | |
|--------------|--|
| AP1.4b Backend | ✓ behalten (Kriterien, Verify, Reopen) |
| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.5 |
| Nächstes Code-Paket | **AP1.5** Hierarchie + Ausführung als Ist-Kern |
| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.6 |
| Nächstes Code-Paket | **AP1.5c** rekursive Projects |
| Tab „Plan“ | fachlich **Gates / Zielzustände** — Umbenennung AP1.5b |
| Graph Engine AP1.4d | Gate-Kanten only — **kein** Lifecycle-Workflow |
@ -37,6 +38,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`.
---
## 1.2 PO-Entscheidung 2026-07-06 — Rekursive Container & Task-Tiefe
**Auslöser:** Mega-Programme vs. normale Vorhaben; Granularität Mensch vs. Agent.
| Entscheidung | |
|--------------|--|
| Stream als eigener Typ | **Nein**`Project` mit `parent_project_id` + optionalem `container_kind` |
| Project in Project | **Ja** — AP1.5c |
| Task → Subtask → … | **Ja**`parent_task_id`; UI max. ~3 Ebenen; Schema tiefer für Agenten — AP1.5d |
| Nested Actions | **Optional** später (`wbs_driven` only); nicht Default |
| Steuerung pro Ebene | **Nein (Stufe 1)** — Initiative-Root; Action = Anker für Menschen |
Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`.
**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d
---
## 2. Phasenübersicht
```text
@ -47,11 +66,12 @@ Phase C Dokumentation Welle 12 ✓
Phase D IA-Skeleton AP1.2 + AP1.2c ✓
Phase E Steering AP1.2, AP1.3 ◐
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
Phase G **Ist-Hierarchie AP1.5** ← **NÄCHSTES CODE**
Phase H Plan/Ist-Verknüpfung AP1.6 geplant
Phase I Gate-Graph AP1.4d4e nach AP1.5
Phase G Ist-Hierarchie AP1.5 ✓
Phase G2 Rekursive Container AP1.5c5d ✓ 5c / → 5d nach AP1.6
Phase H Plan/Ist-Verknüpfung AP1.6 ← **NÄCHSTES CODE**
Phase I Gate-Graph AP1.4d4e nach AP1.6
Phase J Portfolio & Kontext AP1.8 geplant
Phase K Agent Interface AP1.7 geplant
Phase K Agent Interface AP1.7 geplant (unverändert)
Phase L Validation AP0.10d (laufend PO) parallel
```
@ -71,13 +91,15 @@ Phase L Validation AP0.10d (laufend PO) parallel
| AP1.2c | 0.12.0-ap1.2c | IA-Skeleton |
| AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
| AP1.5 | 0.14.0-ap1.5 | Project, Task flach, Ausführung-Tab |
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
---
## 4. Nächste Pakete (verbindliche Reihenfolge — PO 2026-07-06)
### AP1.5 — Hierarchie & Ist-Ebene (**NÄCHSTES CODE-PAKET**)
### AP1.5 — Hierarchie & Ist-Ebene
**Ziel:** Operativer Plan zwischen Gates — Project, Task, Ausführung-Tab; Gates optional nutzbar.
@ -94,6 +116,22 @@ Phase L Validation AP0.10d (laufend PO) parallel
---
### AP1.5c — Rekursive Projects ✓
**Ziel:** Project-Baum für Mega-Programme; normale Vorhaben unverändert flach.
| Scope | |
|-------|--|
| `parent_project_id`, Zyklen-Check, max. Tiefe 5 |
| Optional `container_kind` (Label) |
| Baum-UI in Ausführung; Actions nur an Blatt-Projects |
| **Kein** eigener Steering-Context pro Project |
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
**Version:** `0.14.1-ap1.5c`
---
### AP1.5b — IA-Begriffe Gates vs. Ausführung
| Scope | |
@ -106,17 +144,35 @@ Phase L Validation AP0.10d (laufend PO) parallel
---
### AP1.6 — Plan/Ist-Verknüpfung & Journey
### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**)
Backlog/Action → RoadmapItem; Decision-Spur; Journey-Timeline.
Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
**Nach AP1.5** — Gates bekommen Bedeutung durch Ist-Bezug.
**Nach AP1.5c** — Gates bekommen Bedeutung durch Ist-Bezug (auch auf Project-Ebenen).
**Version:** `0.15.0-ap1.6`
---
### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.5)
### AP1.5d — Rekursive Tasks & Roll-up
**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer.
| Scope | |
|-------|--|
| Migration `parent_task_id`, Cycle/Depth-Validation |
| Roll-up Status Action ↔ Task-Baum |
| Gate-Beitrag auf Blatt-Tasks (Fortsetzung AP1.6) |
| UI: Outline, collapsed default ab Tiefe 2 |
| **Kein** nested Action (Default) |
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
**Nach AP1.6**
**Version:** `0.15.1-ap1.5d`
---
### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.6)
Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
@ -134,7 +190,7 @@ Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
| Deprecate `sequencing_mode` am Item |
| Read Models: blocked, ready, fulfillment_ratio |
**Version:** `0.14.1-ap1.4d` (nach AP1.5)
**Version:** `0.15.2-ap1.4d` (nach AP1.6)
---
@ -204,7 +260,7 @@ Criteria, Waive/Defer, Reopen, Detail-Route. Version `0.13.1-ap1.4b`
| Thema | Bis wann |
|-------|----------|
| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.5** (PO 2026-07-06) |
| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.6** (PO 2026-07-06) |
| Prompt/KI/MCP produktiv | Ist-Hierarchie + Plan/Ist |
| Method Designer UI | AP1.3+ |
| Gantt / Kalender | nach Plan/Ist |
@ -230,12 +286,14 @@ MVP-nah wenn:
0.12.0-ap1.2c ✓ IA-Skeleton
0.13.0-ap1.4 ✓ RoadmapItem / Gates Backend
0.13.1-ap1.4b ✓ Gate-Checkliste Backend
0.14.0-ap1.5 → Hierarchie (Project, Task) — NÄCHSTES
0.15.0-ap1.6 → Journey / Plan-Ist
0.14.1-ap1.4d → Gate-Graph (nach AP1.5)
0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach)
0.14.1-ap1.5c ✓ Rekursive Projects
0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES
0.15.1-ap1.5d → Rekursive Tasks / Roll-up
0.15.2-ap1.4d → Gate-Graph
0.16.0-ap1.7 → Agent Interface
0.17.0-ap1.8 → Portfolio + Kontext
```
---
*Siehe auch: `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`*
*Siehe auch: `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`*

View File

@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren
**Stand:** 2026-07-06 (nach AP1.5)
**Stand:** 2026-07-06 (nach AP1.5c)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
| Auth / Session | ✓ | |
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
| Migrationen nummeriert | ✓ | Schema 013 |
| Migrationen nummeriert | ✓ | Schema 014 |
---
@ -39,7 +39,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Element | Stand | Anmerkung |
|---------|-------|-----------|
| Initiative | ✓ | |
| Project | ◐ | AP1.5 GUI + API |
| Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions |
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
| ActionAssignment | ✓ | |
| BacklogItem | ✓ | |

View File

@ -1,6 +1,7 @@
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { ActorSelect } from './ActorSelect.jsx'
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
function isoToLocalInput(iso) {
if (!iso) return ''
@ -38,6 +39,8 @@ export function ActionForm({
}
const defaultActors = initial.assigned_actor_ids || []
const leafProjects = getLeafProjects(projects)
const projectOptions = flattenLeafProjectOptions(projects)
return (
<form className="form workspace-form" onSubmit={handleSubmit}>
@ -77,14 +80,14 @@ export function ActionForm({
defaultValue={isoToLocalInput(initial.due_at)}
/>
</label>
{projects.length > 0 && (
{leafProjects.length > 0 && (
<label>
Projekt (optional)
Projekt (optional, Blatt-Ebene)
<select name="project_id" defaultValue={initial.project_id || ''}>
<option value=""> keins </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.title}
{projectOptions.map(({ project, label }) => (
<option key={project.id} value={project.id}>
{label}
</option>
))}
</select>

View File

@ -1,6 +1,80 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx'
import {
buildProjectsByParent,
containerKindLabel,
flattenProjectOptions,
} from '../utils/projectTree.js'
function ProjectTreeNodes({
projects,
parentId,
depth,
selectedProjectId,
onSelectProject,
canManage,
onDelete,
busy,
}) {
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
const nodes = byParent.get(parentId || '') || []
if (nodes.length === 0) return null
return (
<ul className="project-tree-children">
{nodes.map((project) => (
<li key={project.id} className="project-tree-branch">
<div className="list-item card-list-item project-tree-item">
<div className="list-item-main">
<button
type="button"
className="link-button"
onClick={() =>
onSelectProject(selectedProjectId === project.id ? '' : project.id)
}
>
<strong>{project.title}</strong>
</button>
{project.container_kind && (
<span className="project-kind-badge muted">
{containerKindLabel(project.container_kind)}
</span>
)}
{project.description && (
<p className="list-item-desc muted">{project.description}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="initiative" status={project.status} />
{canManage && (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => onDelete(project.id)}
>
Löschen
</button>
)}
</div>
</div>
<ProjectTreeNodes
projects={projects}
parentId={project.id}
depth={depth + 1}
selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject}
canManage={canManage}
onDelete={onDelete}
busy={busy}
/>
</li>
))}
</ul>
)
}
export function ProjectsSection({
projects,
@ -13,37 +87,65 @@ export function ProjectsSection({
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [parentProjectId, setParentProjectId] = useState('')
const [containerKind, setContainerKind] = useState('')
const [showForm, setShowForm] = useState(false)
const parentOptions = useMemo(
() => flattenProjectOptions(projects),
[projects]
)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({
title: title.trim(),
description: description.trim(),
parent_project_id: parentProjectId || undefined,
container_kind: containerKind || undefined,
})
setTitle('')
setDescription('')
setParentProjectId('')
setContainerKind('')
setShowForm(false)
}
function openFormUnderSelected() {
setParentProjectId(selectedProjectId || '')
setShowForm(true)
}
return (
<section className="card projects-section">
<div className="section-header">
<div>
<h2>Projekte</h2>
<p className="section-lead muted">
Operative Struktur Arbeitspakete gruppieren. Zielzustände (Gates) sind optional.
Operative Struktur verschachtelbar für große Vorhaben. Arbeitspakete nur an
Blatt-Projekten. Gates optional.
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Projekt'}
</button>
<div className="section-header-actions">
{selectedProjectId && (
<button
type="button"
className="btn btn-secondary btn-block-mobile"
onClick={openFormUnderSelected}
>
Unterprojekt
</button>
)}
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Projekt'}
</button>
</div>
)}
</div>
@ -67,6 +169,32 @@ export function ProjectsSection({
rows={2}
/>
</label>
{parentOptions.length > 0 && (
<label>
Unter (optional)
<select
value={parentProjectId}
onChange={(e) => setParentProjectId(e.target.value)}
>
<option value=""> oberste Ebene </option>
{parentOptions.map(({ project, label }) => (
<option key={project.id} value={project.id}>
{label}
</option>
))}
</select>
</label>
)}
<label>
Art (optional)
<select value={containerKind} onChange={(e) => setContainerKind(e.target.value)}>
<option value=""> Standard </option>
<option value="project">Projekt</option>
<option value="stream">Stream</option>
<option value="phase">Phase</option>
<option value="release">Release</option>
</select>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
@ -77,7 +205,7 @@ export function ProjectsSection({
<EmptyState message="Noch keine Projekte — optional. Du kannst auch direkt Arbeitspakete anlegen." />
)}
<ul className="item-list project-filter-list">
<ul className="item-list project-filter-list project-tree-list">
<li className="list-item card-list-item">
<button
type="button"
@ -87,38 +215,17 @@ export function ProjectsSection({
Alle Arbeitspakete
</button>
</li>
{projects.map((project) => (
<li key={project.id} className="list-item card-list-item">
<div className="list-item-main">
<button
type="button"
className="link-button"
onClick={() =>
onSelectProject(selectedProjectId === project.id ? '' : project.id)
}
>
<strong>{project.title}</strong>
</button>
{project.description && (
<p className="list-item-desc muted">{project.description}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="initiative" status={project.status} />
{canManage && (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => onDelete(project.id)}
>
Löschen
</button>
)}
</div>
</li>
))}
</ul>
<ProjectTreeNodes
projects={projects}
parentId=""
depth={1}
selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject}
canManage={canManage}
onDelete={onDelete}
busy={busy}
/>
</section>
)
}

View File

@ -67,6 +67,7 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from './SessionContext.jsx'
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
const InitiativeOperationsContext = createContext(null)
@ -154,17 +155,25 @@ export function InitiativeOperationsProvider({ children }) {
load()
}, [load])
const selectedLeafProjectId = useMemo(() => {
if (!selectedProjectId) return ''
const match = projects.find((p) => p.id === selectedProjectId)
if (match?.is_leaf) return selectedProjectId
return ''
}, [selectedProjectId, projects])
const visibleActions = useMemo(
() => {
let list = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
if (selectedProjectId) {
list = list.filter((a) => a.project_id === selectedProjectId)
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
}
return list
},
[actions, hideDone, selectedProjectId]
[actions, hideDone, selectedProjectId, projects]
)
const actionContextById = useMemo(
@ -200,7 +209,7 @@ export function InitiativeOperationsProvider({ children }) {
: []
await createInitiativeAction(id, {
...payload,
project_id: payload.project_id || selectedProjectId || undefined,
project_id: payload.project_id || selectedLeafProjectId || undefined,
assigned_actor_ids: assigned,
})
setShowActionForm(false)

View File

@ -426,6 +426,38 @@
margin-bottom: 1rem;
}
.section-header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.project-tree-list {
margin-bottom: 0;
}
.project-tree-children {
list-style: none;
margin: 0;
padding: 0 0 0 0.75rem;
border-left: 2px solid var(--jk-border, #e2e8f0);
}
.project-tree-branch {
list-style: none;
margin: 0.35rem 0 0;
}
.project-tree-item {
margin-left: 0;
}
.project-kind-badge {
font-size: 0.75rem;
margin-left: 0.35rem;
}
.section-header h2 {
margin: 0;
font-size: 1rem;

View File

@ -0,0 +1,80 @@
/** AP1.5c — Project-Baum Hilfsfunktionen */
const CONTAINER_KIND_LABELS = {
project: 'Projekt',
stream: 'Stream',
phase: 'Phase',
release: 'Release',
}
export function containerKindLabel(kind) {
if (!kind) return null
return CONTAINER_KIND_LABELS[kind] || kind
}
export function buildProjectsByParent(projects) {
const byParent = new Map()
for (const project of projects) {
const key = project.parent_project_id || ''
if (!byParent.has(key)) byParent.set(key, [])
byParent.get(key).push(project)
}
for (const list of byParent.values()) {
list.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.title.localeCompare(b.title))
}
return byParent
}
export function collectProjectSubtreeIds(projects, rootId) {
const byParent = buildProjectsByParent(projects)
const ids = new Set([rootId])
const stack = [rootId]
while (stack.length) {
const current = stack.pop()
for (const child of byParent.get(current) || []) {
if (!ids.has(child.id)) {
ids.add(child.id)
stack.push(child.id)
}
}
}
return ids
}
export function getLeafProjects(projects) {
const parentIds = new Set(
projects.map((p) => p.parent_project_id).filter(Boolean)
)
return projects.filter((p) => !parentIds.has(p.id))
}
export function projectOptionLabel(project, depth = 1) {
const prefix = depth > 1 ? `${'— '.repeat(depth - 1)}` : ''
const kind = containerKindLabel(project.container_kind)
const kindSuffix = kind ? ` (${kind})` : ''
return `${prefix}${project.title}${kindSuffix}`
}
export function flattenLeafProjectOptions(projects, parentId = '', depth = 1) {
const byParent = buildProjectsByParent(projects)
const result = []
for (const project of byParent.get(parentId) || []) {
const children = byParent.get(project.id) || []
if (children.length === 0) {
result.push({ project, depth, label: projectOptionLabel(project, depth) })
} else {
result.push(...flattenLeafProjectOptions(projects, project.id, depth + 1))
}
}
return result
}
export function flattenProjectOptions(projects, parentId = '', depth = 1) {
const byParent = buildProjectsByParent(projects)
const result = []
for (const project of byParent.get(parentId) || []) {
result.push({ project, depth, label: projectOptionLabel(project, depth) })
result.push(...flattenProjectOptions(projects, project.id, depth + 1))
}
return result
}