refactor(A1): Progression-Lanes aus Graph statt Strang=Project
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 4m52s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 31s
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 4m52s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 31s
Ersetzt strand_project_id/Stream-Projects in der A1-Steuerung durch graph-abgeleitete Lanes (requires-Ketten + Join). Recurring bindet nur noch am Gate; Re-Abnahme-Docs und ADP dokumentieren die Korrektur. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
88a03a65f2
commit
17add13d9d
|
|
@ -316,9 +316,10 @@ def _retire_legacy_linear_stages_for_progression(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
keep_gate_ids: frozenset[str],
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Starter-Kit-Stufen ohne Strang zurückstellen, wenn Join-Graph angelegt wird."""
|
"""Starter-Kit-Stufen zurückstellen, wenn Join-Graph angelegt wird."""
|
||||||
from services.maturity_practice import list_practices_for_stage, update_recurring_element
|
from services.maturity_practice import list_practices_for_stage, update_recurring_element
|
||||||
from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item
|
from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item
|
||||||
|
|
||||||
|
|
@ -327,7 +328,7 @@ def _retire_legacy_linear_stages_for_progression(
|
||||||
):
|
):
|
||||||
if item.get("item_type") != "maturity_stage":
|
if item.get("item_type") != "maturity_stage":
|
||||||
continue
|
continue
|
||||||
if item.get("strand_project_id"):
|
if str(item["id"]) in keep_gate_ids:
|
||||||
continue
|
continue
|
||||||
if item.get("status") not in ("active", "at_risk"):
|
if item.get("status") not in ("active", "at_risk"):
|
||||||
continue
|
continue
|
||||||
|
|
@ -357,9 +358,8 @@ def apply_a1_progression_demo(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""PO-Kernbild: 2 Stränge → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
|
"""PO-Kernbild: 2 parallele Gates → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
|
||||||
from services.maturity_practice import seed_practices_for_stage
|
from services.maturity_practice import seed_practices_for_stage
|
||||||
from services.projects import create_project, list_projects_for_initiative
|
|
||||||
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
|
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
|
||||||
|
|
||||||
existing = list_roadmap_items_for_initiative(
|
existing = list_roadmap_items_for_initiative(
|
||||||
|
|
@ -368,30 +368,6 @@ def apply_a1_progression_demo(
|
||||||
if any(item.get("item_type") == "join_gate" for item in existing):
|
if any(item.get("item_type") == "join_gate" for item in existing):
|
||||||
return {"applied": False, "reason": "progression_exists"}
|
return {"applied": False, "reason": "progression_exists"}
|
||||||
|
|
||||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
|
||||||
dehnung = _find_by_title(projects, "Dehnung")
|
|
||||||
if not dehnung:
|
|
||||||
dehnung = create_project(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
initiative_id=initiative_id,
|
|
||||||
title="Dehnung",
|
|
||||||
description="Strang Dehnung",
|
|
||||||
container_kind="stream",
|
|
||||||
sort_order=0,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
huft = _find_by_title(projects, "Hüftrotation")
|
|
||||||
if not huft:
|
|
||||||
huft = create_project(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
initiative_id=initiative_id,
|
|
||||||
title="Hüftrotation",
|
|
||||||
description="Strang Hüftrotation",
|
|
||||||
container_kind="stream",
|
|
||||||
sort_order=1,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
d1 = create_roadmap_item(
|
d1 = create_roadmap_item(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
|
@ -399,7 +375,6 @@ def apply_a1_progression_demo(
|
||||||
item_type="maturity_stage",
|
item_type="maturity_stage",
|
||||||
status="active",
|
status="active",
|
||||||
sort_order=10,
|
sort_order=10,
|
||||||
strand_project_id=dehnung["id"],
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
h1 = create_roadmap_item(
|
h1 = create_roadmap_item(
|
||||||
|
|
@ -409,7 +384,6 @@ def apply_a1_progression_demo(
|
||||||
item_type="maturity_stage",
|
item_type="maturity_stage",
|
||||||
status="active",
|
status="active",
|
||||||
sort_order=20,
|
sort_order=20,
|
||||||
strand_project_id=huft["id"],
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
join = create_roadmap_item(
|
join = create_roadmap_item(
|
||||||
|
|
@ -428,7 +402,6 @@ def apply_a1_progression_demo(
|
||||||
item_type="maturity_stage",
|
item_type="maturity_stage",
|
||||||
status="planned",
|
status="planned",
|
||||||
sort_order=40,
|
sort_order=40,
|
||||||
strand_project_id=dehnung["id"],
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
h2 = create_roadmap_item(
|
h2 = create_roadmap_item(
|
||||||
|
|
@ -438,7 +411,6 @@ def apply_a1_progression_demo(
|
||||||
item_type="maturity_stage",
|
item_type="maturity_stage",
|
||||||
status="planned",
|
status="planned",
|
||||||
sort_order=50,
|
sort_order=50,
|
||||||
strand_project_id=huft["id"],
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -470,12 +442,14 @@ def apply_a1_progression_demo(
|
||||||
_retire_legacy_linear_stages_for_progression(
|
_retire_legacy_linear_stages_for_progression(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
keep_gate_ids=frozenset(
|
||||||
|
str(gate["id"]) for gate in (d1, h1, d2, h2)
|
||||||
|
),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"applied": True,
|
"applied": True,
|
||||||
"projects": ["Dehnung", "Hüftrotation"],
|
|
||||||
"join": join["title"],
|
"join": join["title"],
|
||||||
"active_gates": [d1["title"], h1["title"]],
|
"active_gates": [d1["title"], h1["title"]],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,14 +102,27 @@ def pause_all_practices(
|
||||||
return paused
|
return paused
|
||||||
|
|
||||||
|
|
||||||
def pause_practices_except_stage(
|
def pause_practices_on_same_lane_except_stage(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
keep_stage_id: str,
|
keep_stage_id: str,
|
||||||
strand_project_id: Optional[str] = None,
|
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
from services.progression_graph import progression_lanes_for_initiative
|
||||||
|
|
||||||
|
lanes = progression_lanes_for_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
keep_lane = lanes.get(str(keep_stage_id))
|
||||||
|
if not keep_lane:
|
||||||
|
return pause_all_practices(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
lane_gate_ids = {
|
||||||
|
gate_id for gate_id, lane in lanes.items() if lane == keep_lane
|
||||||
|
}
|
||||||
paused: list[str] = []
|
paused: list[str] = []
|
||||||
for item in list_recurring_for_initiative(
|
for item in list_recurring_for_initiative(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
|
@ -118,7 +131,7 @@ def pause_practices_except_stage(
|
||||||
continue
|
continue
|
||||||
if item.get("roadmap_item_id") == keep_stage_id:
|
if item.get("roadmap_item_id") == keep_stage_id:
|
||||||
continue
|
continue
|
||||||
if strand_project_id is not None and item.get("project_id") != strand_project_id:
|
if str(item.get("roadmap_item_id") or "") not in lane_gate_ids:
|
||||||
continue
|
continue
|
||||||
update_recurring_element(
|
update_recurring_element(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -130,6 +143,23 @@ def pause_practices_except_stage(
|
||||||
return paused
|
return paused
|
||||||
|
|
||||||
|
|
||||||
|
def pause_practices_except_stage(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
keep_stage_id: str,
|
||||||
|
strand_project_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
del strand_project_id
|
||||||
|
return pause_practices_on_same_lane_except_stage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
keep_stage_id=keep_stage_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def seed_practices_for_stage(
|
def seed_practices_for_stage(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -140,7 +170,6 @@ def seed_practices_for_stage(
|
||||||
"""Idempotent: create missing exercises for a stage; activate existing."""
|
"""Idempotent: create missing exercises for a stage; activate existing."""
|
||||||
stage_id = stage["id"]
|
stage_id = stage["id"]
|
||||||
stage_title = stage.get("title") or ""
|
stage_title = stage.get("title") or ""
|
||||||
strand_project_id = stage.get("strand_project_id")
|
|
||||||
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
||||||
if not defs:
|
if not defs:
|
||||||
defs = [
|
defs = [
|
||||||
|
|
@ -185,7 +214,6 @@ def seed_practices_for_stage(
|
||||||
interval_days=1,
|
interval_days=1,
|
||||||
next_due_at=now,
|
next_due_at=now,
|
||||||
roadmap_item_id=stage_id,
|
roadmap_item_id=stage_id,
|
||||||
project_id=strand_project_id,
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
created.append(row)
|
created.append(row)
|
||||||
|
|
@ -200,19 +228,12 @@ def activate_stage_practice_set(
|
||||||
stage: dict[str, Any],
|
stage: dict[str, Any],
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
strand_project_id = stage.get("strand_project_id")
|
paused = pause_practices_on_same_lane_except_stage(
|
||||||
if strand_project_id:
|
tenant_id=tenant_id,
|
||||||
paused = pause_practices_except_stage(
|
initiative_id=initiative_id,
|
||||||
tenant_id=tenant_id,
|
keep_stage_id=stage["id"],
|
||||||
initiative_id=initiative_id,
|
user_id=user_id,
|
||||||
keep_stage_id=stage["id"],
|
)
|
||||||
strand_project_id=strand_project_id,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
paused = pause_all_practices(
|
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
|
||||||
)
|
|
||||||
exercises = seed_practices_for_stage(
|
exercises = seed_practices_for_stage(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,101 @@ def successor_work_gate_ids(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _work_gate_predecessor_ids(
|
||||||
|
gate_id: str,
|
||||||
|
*,
|
||||||
|
work_gate_ids: frozenset[str],
|
||||||
|
join_gate_ids: frozenset[str],
|
||||||
|
dependencies: list[dict[str, Any]],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Direct requires predecessors that are work gates or join gates."""
|
||||||
|
preds: list[str] = []
|
||||||
|
for dep in _requires_deps(dependencies):
|
||||||
|
if str(dep["from_item_id"]) != str(gate_id):
|
||||||
|
continue
|
||||||
|
pred_id = str(dep["to_item_id"])
|
||||||
|
if pred_id in work_gate_ids or pred_id in join_gate_ids:
|
||||||
|
preds.append(pred_id)
|
||||||
|
return preds
|
||||||
|
|
||||||
|
|
||||||
|
def compute_progression_lanes(
|
||||||
|
*,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
dependencies: list[dict[str, Any]],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Graph-derived progression lane per work gate (not persisted).
|
||||||
|
|
||||||
|
Parallel active gates live on different lanes. Sequential gates on the same
|
||||||
|
requires-chain share a lane. First gate after a join starts a new lane.
|
||||||
|
"""
|
||||||
|
item_by_id = {str(item["id"]): item for item in items}
|
||||||
|
work_gate_ids = frozenset(
|
||||||
|
str(item["id"]) for item in items if is_work_gate(item)
|
||||||
|
)
|
||||||
|
join_gate_ids = frozenset(
|
||||||
|
str(item["id"]) for item in items if is_join_gate(item)
|
||||||
|
)
|
||||||
|
work_pred: dict[str, Optional[str]] = {}
|
||||||
|
for gate_id in work_gate_ids:
|
||||||
|
work_preds = [
|
||||||
|
pred_id
|
||||||
|
for pred_id in _work_gate_predecessor_ids(
|
||||||
|
gate_id,
|
||||||
|
work_gate_ids=work_gate_ids,
|
||||||
|
join_gate_ids=join_gate_ids,
|
||||||
|
dependencies=dependencies,
|
||||||
|
)
|
||||||
|
if pred_id in work_gate_ids
|
||||||
|
]
|
||||||
|
work_pred[gate_id] = work_preds[0] if len(work_preds) == 1 else None
|
||||||
|
|
||||||
|
lanes: dict[str, str] = {}
|
||||||
|
|
||||||
|
def resolve_lane(gate_id: str) -> str:
|
||||||
|
cached = lanes.get(gate_id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
pred = work_pred.get(gate_id)
|
||||||
|
if pred:
|
||||||
|
lanes[gate_id] = resolve_lane(pred)
|
||||||
|
return lanes[gate_id]
|
||||||
|
join_preds = [
|
||||||
|
pred_id
|
||||||
|
for pred_id in _work_gate_predecessor_ids(
|
||||||
|
gate_id,
|
||||||
|
work_gate_ids=work_gate_ids,
|
||||||
|
join_gate_ids=join_gate_ids,
|
||||||
|
dependencies=dependencies,
|
||||||
|
)
|
||||||
|
if pred_id in join_gate_ids
|
||||||
|
]
|
||||||
|
if join_preds:
|
||||||
|
lanes[gate_id] = f"post-join:{gate_id}"
|
||||||
|
return lanes[gate_id]
|
||||||
|
lanes[gate_id] = f"source:{gate_id}"
|
||||||
|
return lanes[gate_id]
|
||||||
|
|
||||||
|
for gate_id in work_gate_ids:
|
||||||
|
resolve_lane(gate_id)
|
||||||
|
return lanes
|
||||||
|
|
||||||
|
|
||||||
|
def progression_lanes_for_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
||||||
|
|
||||||
|
items = list_roadmap_items_for_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
dependencies = list_dependencies_for_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
return compute_progression_lanes(items=items, dependencies=dependencies)
|
||||||
|
|
||||||
|
|
||||||
def list_active_work_gates(
|
def list_active_work_gates(
|
||||||
*, tenant_id: str, initiative_id: str
|
*, tenant_id: str, initiative_id: str
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
|
@ -71,12 +166,10 @@ def list_active_work_gates(
|
||||||
items = list_roadmap_items_for_initiative(
|
items = list_roadmap_items_for_initiative(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
)
|
)
|
||||||
has_join_graph = any(is_join_gate(item) for item in items)
|
|
||||||
active = [
|
active = [
|
||||||
item
|
item
|
||||||
for item in items
|
for item in items
|
||||||
if is_work_gate(item) and item.get("status") in ACTIVE_WORK_STATUSES
|
if is_work_gate(item) and item.get("status") in ACTIVE_WORK_STATUSES
|
||||||
and (not has_join_graph or item.get("strand_project_id") is not None)
|
|
||||||
]
|
]
|
||||||
active.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", "")))
|
active.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", "")))
|
||||||
return active
|
return active
|
||||||
|
|
@ -87,7 +180,6 @@ def compute_join_states(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
from services.projects import list_projects_for_initiative
|
|
||||||
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
||||||
|
|
||||||
items = list_roadmap_items_for_initiative(
|
items = list_roadmap_items_for_initiative(
|
||||||
|
|
@ -97,10 +189,6 @@ def compute_join_states(
|
||||||
dependencies = list_dependencies_for_initiative(
|
dependencies = list_dependencies_for_initiative(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
)
|
)
|
||||||
projects = list_projects_for_initiative(
|
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
|
||||||
)
|
|
||||||
project_title = {str(p["id"]): p.get("title") or "" for p in projects}
|
|
||||||
|
|
||||||
joins = [item for item in items if is_join_gate(item)]
|
joins = [item for item in items if is_join_gate(item)]
|
||||||
states: list[dict[str, Any]] = []
|
states: list[dict[str, Any]] = []
|
||||||
|
|
@ -115,13 +203,10 @@ def compute_join_states(
|
||||||
if not gate:
|
if not gate:
|
||||||
continue
|
continue
|
||||||
if gate.get("status") != "reached":
|
if gate.get("status") != "reached":
|
||||||
strand_id = gate.get("strand_project_id")
|
|
||||||
pending.append(
|
pending.append(
|
||||||
{
|
{
|
||||||
"gate_id": input_id,
|
"gate_id": input_id,
|
||||||
"title": gate.get("title") or input_id,
|
"title": gate.get("title") or input_id,
|
||||||
"strand_project_id": strand_id,
|
|
||||||
"strand_title": project_title.get(str(strand_id or ""), ""),
|
|
||||||
"status": gate.get("status"),
|
"status": gate.get("status"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -137,9 +222,6 @@ def compute_join_states(
|
||||||
{
|
{
|
||||||
"gate_id": output_id,
|
"gate_id": output_id,
|
||||||
"title": (item_by_id.get(output_id) or {}).get("title") or output_id,
|
"title": (item_by_id.get(output_id) or {}).get("title") or output_id,
|
||||||
"strand_project_id": (item_by_id.get(output_id) or {}).get(
|
|
||||||
"strand_project_id"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
for output_id in output_ids
|
for output_id in output_ids
|
||||||
if output_id in item_by_id
|
if output_id in item_by_id
|
||||||
|
|
@ -160,7 +242,6 @@ def compute_progression_state(
|
||||||
{
|
{
|
||||||
"id": gate["id"],
|
"id": gate["id"],
|
||||||
"title": gate.get("title"),
|
"title": gate.get("title"),
|
||||||
"strand_project_id": gate.get("strand_project_id"),
|
|
||||||
"status": gate.get("status"),
|
"status": gate.get("status"),
|
||||||
}
|
}
|
||||||
for gate in active_gates
|
for gate in active_gates
|
||||||
|
|
@ -171,30 +252,33 @@ def compute_progression_state(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def enforce_single_active_work_gate_per_strand(
|
def enforce_single_active_work_gate_per_lane(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
keep_active_id: str,
|
keep_active_id: str,
|
||||||
strand_project_id: Optional[str],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if strand_project_id:
|
"""Revoke other active work gates on the same graph-derived progression lane."""
|
||||||
cur.execute(
|
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
||||||
"""
|
|
||||||
UPDATE roadmap_items ri
|
items = list_roadmap_items_for_initiative(
|
||||||
SET status = 'planned', updated_at = NOW()
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
FROM roadmaps r
|
)
|
||||||
WHERE ri.roadmap_id = r.id
|
dependencies = list_dependencies_for_initiative(
|
||||||
AND ri.tenant_id = %s
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
AND r.initiative_id = %s
|
)
|
||||||
AND ri.item_type = 'maturity_stage'
|
lanes = compute_progression_lanes(items=items, dependencies=dependencies)
|
||||||
AND ri.status IN ('active', 'at_risk')
|
keep_lane = lanes.get(str(keep_active_id))
|
||||||
AND ri.strand_project_id = %s
|
if not keep_lane:
|
||||||
AND ri.id != %s
|
return
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id, strand_project_id, keep_active_id),
|
revoke_ids = [
|
||||||
)
|
gate_id
|
||||||
|
for gate_id, lane in lanes.items()
|
||||||
|
if lane == keep_lane and gate_id != str(keep_active_id)
|
||||||
|
]
|
||||||
|
if not revoke_ids:
|
||||||
return
|
return
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -207,10 +291,27 @@ def enforce_single_active_work_gate_per_strand(
|
||||||
AND r.initiative_id = %s
|
AND r.initiative_id = %s
|
||||||
AND ri.item_type = 'maturity_stage'
|
AND ri.item_type = 'maturity_stage'
|
||||||
AND ri.status IN ('active', 'at_risk')
|
AND ri.status IN ('active', 'at_risk')
|
||||||
AND ri.strand_project_id IS NULL
|
AND ri.id = ANY(%s::uuid[])
|
||||||
AND ri.id != %s
|
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id, keep_active_id),
|
(tenant_id, initiative_id, revoke_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_single_active_work_gate_per_strand(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
keep_active_id: str,
|
||||||
|
strand_project_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Deprecated — graph lanes replace strand/project grouping (ADP A1 lanes v0.1)."""
|
||||||
|
del strand_project_id
|
||||||
|
enforce_single_active_work_gate_per_lane(
|
||||||
|
cur,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
keep_active_id=keep_active_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,17 +400,14 @@ def update_roadmap_item(
|
||||||
and existing.get("item_type") == "maturity_stage"
|
and existing.get("item_type") == "maturity_stage"
|
||||||
):
|
):
|
||||||
from services.progression_graph import (
|
from services.progression_graph import (
|
||||||
enforce_single_active_work_gate_per_strand,
|
enforce_single_active_work_gate_per_lane,
|
||||||
)
|
)
|
||||||
|
|
||||||
enforce_single_active_work_gate_per_strand(
|
enforce_single_active_work_gate_per_lane(
|
||||||
cur,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=str(existing["initiative_id"]),
|
initiative_id=str(existing["initiative_id"]),
|
||||||
keep_active_id=item_id,
|
keep_active_id=item_id,
|
||||||
strand_project_id=strand_project_id
|
|
||||||
if strand_project_id is not None
|
|
||||||
else existing.get("strand_project_id"),
|
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -803,15 +800,14 @@ def reopen_roadmap_item(
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
if item.get("item_type") == "maturity_stage":
|
if item.get("item_type") == "maturity_stage":
|
||||||
from services.progression_graph import (
|
from services.progression_graph import (
|
||||||
enforce_single_active_work_gate_per_strand,
|
enforce_single_active_work_gate_per_lane,
|
||||||
)
|
)
|
||||||
|
|
||||||
enforce_single_active_work_gate_per_strand(
|
enforce_single_active_work_gate_per_lane(
|
||||||
cur,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
keep_active_id=item_id,
|
keep_active_id=item_id,
|
||||||
strand_project_id=item.get("strand_project_id"),
|
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -74,14 +74,13 @@ def _reopen_item_if_reached(
|
||||||
if item["status"] != "reached":
|
if item["status"] != "reached":
|
||||||
return
|
return
|
||||||
if item.get("item_type") == "maturity_stage":
|
if item.get("item_type") == "maturity_stage":
|
||||||
from services.progression_graph import enforce_single_active_work_gate_per_strand
|
from services.progression_graph import enforce_single_active_work_gate_per_lane
|
||||||
|
|
||||||
enforce_single_active_work_gate_per_strand(
|
enforce_single_active_work_gate_per_lane(
|
||||||
cur,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
keep_active_id=item["id"],
|
keep_active_id=item["id"],
|
||||||
strand_project_id=item.get("strand_project_id"),
|
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,63 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.progression_graph import compute_progression_lanes
|
||||||
from tests.factories import provision_user_in_tenant
|
from tests.factories import provision_user_in_tenant
|
||||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
|
||||||
|
def _item(item_id: str, item_type: str = "maturity_stage", **kwargs):
|
||||||
|
return {
|
||||||
|
"id": item_id,
|
||||||
|
"item_type": item_type,
|
||||||
|
"status": kwargs.get("status", "planned"),
|
||||||
|
"title": kwargs.get("title", item_id),
|
||||||
|
"sort_order": kwargs.get("sort_order", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _requires(from_id: str, to_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"from_item_id": from_id,
|
||||||
|
"to_item_id": to_id,
|
||||||
|
"dependency_type": "requires",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progression_lanes_parallel_sources_and_post_join():
|
||||||
|
items = [
|
||||||
|
_item("d1", status="active", title="Dehnung — Einstieg"),
|
||||||
|
_item("h1", status="active", title="Hüftrotation — Einstieg"),
|
||||||
|
_item("join", item_type="join_gate", title="Join"),
|
||||||
|
_item("d2", title="Dehnung — nach Join"),
|
||||||
|
_item("h2", title="Hüftrotation — nach Join"),
|
||||||
|
]
|
||||||
|
deps = [
|
||||||
|
_requires("join", "d1"),
|
||||||
|
_requires("join", "h1"),
|
||||||
|
_requires("d2", "join"),
|
||||||
|
_requires("h2", "join"),
|
||||||
|
]
|
||||||
|
lanes = compute_progression_lanes(items=items, dependencies=deps)
|
||||||
|
assert lanes["d1"].startswith("source:")
|
||||||
|
assert lanes["h1"].startswith("source:")
|
||||||
|
assert lanes["h1"] != lanes["d1"]
|
||||||
|
assert lanes["d2"] == "post-join:d2"
|
||||||
|
assert lanes["h2"] == "post-join:h2"
|
||||||
|
assert lanes["d2"] != lanes["h2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_progression_lanes_sequential_chain_shares_lane():
|
||||||
|
items = [
|
||||||
|
_item("g1", status="active"),
|
||||||
|
_item("g2"),
|
||||||
|
_item("g3"),
|
||||||
|
]
|
||||||
|
deps = [_requires("g2", "g1"), _requires("g3", "g2")]
|
||||||
|
lanes = compute_progression_lanes(items=items, dependencies=deps)
|
||||||
|
assert lanes["g1"] == lanes["g2"] == lanes["g3"]
|
||||||
|
|
||||||
|
|
||||||
def test_progression_join_switches_and_activates_outputs(client):
|
def test_progression_join_switches_and_activates_outputs(client):
|
||||||
user = provision_user_in_tenant(tenant_role="admin")
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
token = _login(client, user)
|
token = _login(client, user)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# ADP — A1 Progression: Graph-abgeleitete Lanes statt Strang=Project
|
||||||
|
|
||||||
|
**Status:** PO/Architektur-Korrektur (2026-07-28)
|
||||||
|
**Stand:** 2026-07-28
|
||||||
|
**Auslöser:** AP2.1 — Strang=Project und `strand_project_id` vermischen Dimension A (Zielzustands-Graph) mit Dimension B (Project-Struktur)
|
||||||
|
**Bezug:** `ADP_Execution_Plan_and_Work_Package_Dependencies_v0.1.md`, `SPEC_A1_Progression_Model_PO_Lock_v0.1.md`, `SPEC_D_maturity_progression_v0.1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
**A1 Multi-Gate-Progression braucht kein Project pro parallelem Pfad.**
|
||||||
|
|
||||||
|
| Thema | Lock |
|
||||||
|
|-------|------|
|
||||||
|
| **Ziel-Horizont (A)** | Gates + `requires`-Graph + Join — modelliert **Zielzustände**, nicht Pfade |
|
||||||
|
| **Ist / Übungen** | `RecurringElement.roadmap_item_id` → aktives Gate — **eine** Bindung |
|
||||||
|
| **Parallele aktive Gates** | Erlaubt; Horizon-Regel „max. 1 aktiv pro Lane“ aus **Graph-Topologie** ableiten |
|
||||||
|
| **Lane** | Interner, nicht persistierter Schlüssel (`compute_progression_lanes`) — kein Nutzerobjekt |
|
||||||
|
| **Project** | **Nicht** Träger paralleler Progression in A1; optional nur für echte Struktur-Hierarchie (ADP Dimension B) |
|
||||||
|
| **`strand_project_id`** | **Deprecated** für Steuerlogik; Spalte bleibt nullable (Migration 032), wird nicht mehr gelesen |
|
||||||
|
| **`recurring.project_id` für A1** | **Nicht** setzen / nicht auswerten für Ready-Menge |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
1. **ADP-Leitentscheidung:** Gate-Designer = Zielzustände; Pfad-/Container-Semantik gehört nicht in dieselbe Pflichtmodellierung.
|
||||||
|
2. **Nutzerbild:** Mehrere aktive Gates, Routinen am Gate, Join wertet Kanten aus — kein Pfad-Navigator nötig.
|
||||||
|
3. **Archetyp-Isolation:** Project=Strang hätte andere Archetypen (A2, B1) semantisch verunreinigt.
|
||||||
|
4. **Kein Doppelmodell:** Kanten im Designer genügen; kein Strang-Dropdown, keine Stream-Projects für A1-Kernbild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lane-Algorithmus (MVP)
|
||||||
|
|
||||||
|
Aus `requires`-Kanten zwischen Work-Gates und Joins:
|
||||||
|
|
||||||
|
- Sequenzielle Work-Gates teilen eine Lane (Vorgänger-Kette).
|
||||||
|
- Parallele Quellen (kein Work-Gate-Vorgänger) → getrennte Lanes.
|
||||||
|
- Erstes Gate nach Join (`requires` Join) → neue Lane (`post-join:{gate_id}`).
|
||||||
|
|
||||||
|
Horizon: Beim Aktivieren eines Gates werden andere `active`/`at_risk` Gates **derselben Lane** auf `planned` gesetzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierung
|
||||||
|
|
||||||
|
| Modul | Änderung |
|
||||||
|
|-------|----------|
|
||||||
|
| `backend/services/progression_graph.py` | `compute_progression_lanes`, `enforce_single_active_work_gate_per_lane` |
|
||||||
|
| `backend/services/maturity_practice.py` | Pause/Seed nur über `roadmap_item_id` + Lane |
|
||||||
|
| `backend/services/archetype_starter_kit.py` | Demo ohne Stream-Projects / `strand_project_id` |
|
||||||
|
| `frontend/.../WorkTodayPracticePanel.jsx` | Gruppierung nach Gate-Titel |
|
||||||
|
|
||||||
|
Steuerung bleibt in **`maturity_progression`** — nicht im generischen `roadmap_engine`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec-Nachzug
|
||||||
|
|
||||||
|
`SPEC_A1_Progression_Model_PO_Lock_v0.1.md` §1/§4: „Strang = Project“ → **ersetzt durch** „parallele Lanes = graph-abgeleitete Requires-Ketten“. Project-Spiegel `project.maturity_journey` optional für Struktur-IA, nicht für Horizon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in Scope
|
||||||
|
|
||||||
|
- Spalten-Drop in Migration ( später, wenn keine Legacy-Daten )
|
||||||
|
- Join If/Else (Stufe B)
|
||||||
|
- Blueprint / Starter-Kit als Produktweg
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# AP2.1 — MVP Validation Report
|
# AP2.1 — MVP Validation Report
|
||||||
## v0.3 (Stufe A)
|
## v0.3 (Stufe A)
|
||||||
|
|
||||||
**Status:** Entwurf — vom Product Owner auszufüllen
|
**Status:** Entwurf — Re-Abnahme A1 Progression ausstehend (2026-08-02)
|
||||||
**Stand:** 2026-07-28
|
**Stand:** 2026-08-02 (§3.5 Progressions-Re-Abnahme ergänzt)
|
||||||
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §5–8
|
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §5–8
|
||||||
**Assignment:** `Sprint1_AP2_1_MVP_Validation_Assignment_v0.1.md`
|
**Assignment:** `Sprint1_AP2_1_MVP_Validation_Assignment_v0.1.md`
|
||||||
**Findings (Architektur-Review):** `Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
|
**Findings (Architektur-Review):** `Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
|
||||||
|
|
@ -106,6 +106,79 @@ Produkt (PO): **A1 No-Go** — Cadence-Plumbing reicht nicht; Nutzerbild = **par
|
||||||
|
|
||||||
**Lücken / UX-Schmerz:** PO No-Go — Register F-A1-02…10; Minimum Slice §6.1 vor Re-Test. Kein Architektur-Neudesign — fehlender A1-Kern (CadenceInstance, Today=Übung, Gate-Fortschritt, IA).
|
**Lücken / UX-Schmerz:** PO No-Go — Register F-A1-02…10; Minimum Slice §6.1 vor Re-Test. Kein Architektur-Neudesign — fehlender A1-Kern (CadenceInstance, Today=Übung, Gate-Fortschritt, IA).
|
||||||
|
|
||||||
|
### 3.5 Re-Abnahme A1 — Progressionsmodell (PO-Lock, ohne Starter-Kit)
|
||||||
|
|
||||||
|
**Zweck:** PO-Kernbild prüfen gemäß [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §7 — **≥2 parallele Gates + 1 Join + Activity Sets + Today**, manuell modelliert (kein Spagat-Starter-Kit). Horizon/Lanes: [`ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md`](../architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md).
|
||||||
|
|
||||||
|
**Vorbedingungen**
|
||||||
|
|
||||||
|
| # | Check | erledigt |
|
||||||
|
|---|--------|----------|
|
||||||
|
| V1 | Deploy `develop` grün (pytest inkl. `test_a1_progression_join.py`) | ☐ |
|
||||||
|
| V2 | Health: `GET https://dev.kairo.jinkendo.de/api/health` | ☐ |
|
||||||
|
| V3 | PO-Entscheid: **kein Starter-Kit** — Anlage mit `"apply_starter_kit": false` (API) oder Spagat-Stufen nach Anlage löschen | ☐ |
|
||||||
|
|
||||||
|
**Referenz-Graph (MVP, 2 parallele Pfade — reicht für Go):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Dehnung — Einstieg] ──requires──▶ [Join: Mawashi …] ◀──requires── [Hüftrotation — Einstieg]
|
||||||
|
│
|
||||||
|
┌───────────────────┴───────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
[Dehnung — nach Join] [Hüftrotation — nach Join]
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Kanten: **Voraussetzung** (`requires`). Kein Parallelgruppe/Blockiert nötig. **Keine** Stream-Projects / `strand_project_id` nötig — parallele Pfade ergeben sich aus dem Graph.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**9 Smoke-Schritte**
|
||||||
|
|
||||||
|
| # | Schritt | Route / Aktion | Erwartung | ✓/✗ | Notiz |
|
||||||
|
|---|---------|----------------|-----------|-----|-------|
|
||||||
|
| **1** | Vorhaben anlegen | API `POST /api/initiatives` mit `archetype_key: initiative.maturity_journey`, **`apply_starter_kit: false`** | Archetyp A1, Methode `maturity_progression`, **keine** Spagat-Stufen | | |
|
||||||
|
| **2** | Operating Context | `GET …/initiatives/{id}/operating-context` oder Kontrolle | `maturity_stage`, `recurring_rhythm`; Default-Route Kontrolle | | |
|
||||||
|
| **3** | Gates + Join im Designer | Plan → Zielzustände → **Designer** | 5 Knoten: 4× `Reifegrad-Stufe` + 1× **Join/Schaltknoten**; D1+H1 **active**, Rest **planned** | | |
|
||||||
|
| **4** | Kanten ziehen | Designer, Kantentyp **Voraussetzung** | Join→D1, Join→H1, D2→Join, H2→Join (4 Kanten) | | |
|
||||||
|
| **5** | Activity Sets | Gate-Detail je Work-Gate: Recurring/Übung anlegen oder Kriterium + Übung | D1 und H1 je ≥1 Übung; Join **ohne** Checkliste/Verify-Button | | |
|
||||||
|
| **6** | Today (Start) | `/work/today` (Initiative-Scope) | Übungen von **beiden** aktiven Gates; **keine** flache AP-Wand; Join **nicht** in Today | | |
|
||||||
|
| **7** | Verify Pfad 1 | Gate D1: Evidence + **Verify** | D1 `reached`; Join wartet auf H1; **kein** auto-next auf D2; Today nur noch H1-Übungen | | |
|
||||||
|
| **8** | Join schaltet | Gate H1: Evidence + **Verify** | Join `reached` (automatisch); D2 + H2 **active**; neue Übungen in Today | | |
|
||||||
|
| **9** | Leitfrage + Kontrolle | Kontrolle `/control/status` + Stopuhr | Join-Warteliste zeigte offene Eingänge (Schritt 7); Leitfrage in **≤2 Min** beantwortbar: *Welche Übung heute, welcher Join wartet?* | | |
|
||||||
|
|
||||||
|
**Technische Referenz (Automatisierung):** `backend/tests/test_a1_progression_join.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Go-Kriterien A1 Progression (alle nötig für Go)**
|
||||||
|
|
||||||
|
| # | Kriterium | ja/nein | Notiz |
|
||||||
|
|---|-----------|---------|-------|
|
||||||
|
| G1 | Multi-Gate: 2 aktive Work-Gates parallel (Schritt 6) | | |
|
||||||
|
| G2 | Join: Warteliste/Kontrolle zeigt pending inputs (Schritt 7) | | |
|
||||||
|
| G3 | Join schaltet Ausgänge gemeinsam (Schritt 8) | | |
|
||||||
|
| G4 | Today = Übungen am Gate, nicht AP-Liste (Schritt 6, 9) | | |
|
||||||
|
| G5 | Leitfrage ≤2 Min (Schritt 9) | | |
|
||||||
|
| G6 | Kein Anti-Pattern: flache Rhythmus-Liste als Hauptbild | | |
|
||||||
|
|
||||||
|
**Zwischenurteil Re-Abnahme:** ☐ Go · ☐ Bedingt · ☐ No-Go
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|------|------|
|
||||||
|
| **Initiative-ID / Titel** | |
|
||||||
|
| **Deploy / Commit** | `develop` @ |
|
||||||
|
| **Testdatum Re-Abnahme** | |
|
||||||
|
|
||||||
|
**Leitfrage-Antwort (PO, ≤2 Min):**
|
||||||
|
|
||||||
|
**Nächster Schritt:**
|
||||||
|
**Warum:**
|
||||||
|
**Stopuhr (Min):**
|
||||||
|
|
||||||
|
**Bei Go:** §3 Zwischenurteil auf Go setzen; Findings Register F-A1-02…10 einzeln abhaken; Truth Table MVP-Abnahfe A1 aktualisieren.
|
||||||
|
|
||||||
|
**Bei No-Go:** neue Finding-IDs im Register; keine Stufe-A-Sign-off.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Szenario A2 — Neue Küche (Linear)
|
## 4. Szenario A2 — Neue Küche (Linear)
|
||||||
|
|
|
||||||
|
|
@ -216,12 +216,12 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4
|
||||||
|
|
||||||
Kernentscheide:
|
Kernentscheide:
|
||||||
|
|
||||||
- **Multi-Strang** (Projects) + **Gate-Graph** + **Join-Gates**
|
- **Parallele Gates** + **Gate-Graph** + **Join-Gates** (Lanes graph-abgeleitet — ADP A1 Lanes v0.1)
|
||||||
- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit)
|
- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit)
|
||||||
- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld
|
- **Übung** = `RecurringElement` am Gate (`roadmap_item_id`); Rhythmus = Feld
|
||||||
- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
|
- **A1 Go MVP:** ≥2 parallele Gates + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
|
||||||
|
|
||||||
**Validation A1:** pausiert bis `AP-A1-PM-4` (Plan Struktur + Gate-Detail). A2/B2b-Smoke **parallel** möglich.
|
**Validation A1:** Re-Abnahme gemäß [`Sprint1_AP2_1_MVP_Validation_Report_v0.3.md`](Sprint1_AP2_1_MVP_Validation_Report_v0.3.md) **§3.5** (Progressionsmodell, ohne Starter-Kit). A2/B2b-Smoke **parallel** möglich.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function JoinWaitlistPanel({ progressionState = null }) {
|
||||||
<div>
|
<div>
|
||||||
<h2>Join-Fortschritt</h2>
|
<h2>Join-Fortschritt</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten?
|
Konsolidierte Meilensteine — welche Eingangs-Gates fehlen noch zum Schalten?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,6 @@ function exercisesForGate(recurringItems = [], gateId) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function strandLabel(gate, projectsById) {
|
|
||||||
const projectId = gate.strand_project_id
|
|
||||||
if (!projectId) return 'Training'
|
|
||||||
return projectsById.get(projectId)?.title || 'Strang'
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
||||||
const [measurement, setMeasurement] = useState('')
|
const [measurement, setMeasurement] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
@ -99,31 +93,24 @@ function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A1 — Heutige Übungen aller aktiven Work-Gates, gruppiert nach Strang.
|
* A1 — Heutige Übungen aller aktiven Work-Gates, gruppiert nach Gate.
|
||||||
*/
|
*/
|
||||||
export function WorkTodayPracticePanel({
|
export function WorkTodayPracticePanel({
|
||||||
initiativeId,
|
initiativeId,
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
recurringItems = [],
|
recurringItems = [],
|
||||||
projects = [],
|
|
||||||
canManage = false,
|
canManage = false,
|
||||||
onCompleted,
|
onCompleted,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}) {
|
||||||
const projectsById = useMemo(
|
|
||||||
() => new Map((projects || []).map((project) => [project.id, project])),
|
|
||||||
[projects],
|
|
||||||
)
|
|
||||||
|
|
||||||
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
|
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
return gates.map((gate) => ({
|
return gates.map((gate) => ({
|
||||||
gate,
|
gate,
|
||||||
strand: strandLabel(gate, projectsById),
|
|
||||||
exercises: exercisesForGate(recurringItems, gate.id),
|
exercises: exercisesForGate(recurringItems, gate.id),
|
||||||
}))
|
}))
|
||||||
}, [gates, recurringItems, projectsById])
|
}, [gates, recurringItems])
|
||||||
|
|
||||||
const todayExercises = grouped.flatMap((group) => group.exercises)
|
const todayExercises = grouped.flatMap((group) => group.exercises)
|
||||||
const pending = todayExercises.filter((item) => !item.today_completed)
|
const pending = todayExercises.filter((item) => !item.today_completed)
|
||||||
|
|
@ -137,7 +124,7 @@ export function WorkTodayPracticePanel({
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
{gates.length > 0
|
{gates.length > 0
|
||||||
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
|
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
|
||||||
: 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'}
|
: 'Tages-Tracking für aktive Work-Gates.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -151,12 +138,10 @@ export function WorkTodayPracticePanel({
|
||||||
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{grouped.map(({ gate, strand, exercises }) =>
|
{grouped.map(({ gate, exercises }) =>
|
||||||
exercises.length === 0 ? null : (
|
exercises.length === 0 ? null : (
|
||||||
<div key={gate.id} className="work-today-practice__strand">
|
<div key={gate.id} className="work-today-practice__strand">
|
||||||
<h3 className="work-today-practice__strand-title">
|
<h3 className="work-today-practice__strand-title">{gate.title}</h3>
|
||||||
{strand} — {gate.title}
|
|
||||||
</h3>
|
|
||||||
<ul className="item-list work-today-practice__list">
|
<ul className="item-list work-today-practice__list">
|
||||||
{exercises.map((exercise) => (
|
{exercises.map((exercise) => (
|
||||||
<ExerciseRow
|
<ExerciseRow
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,6 @@ export function buildProviderProps(provider, input) {
|
||||||
initiativeId: ops.initiativeId,
|
initiativeId: ops.initiativeId,
|
||||||
roadmapItems: ops.roadmapItems || [],
|
roadmapItems: ops.roadmapItems || [],
|
||||||
recurringItems: ops.recurringItems || [],
|
recurringItems: ops.recurringItems || [],
|
||||||
projects: ops.projects || [],
|
|
||||||
canManage: capabilities.has('kairo.recurring.manage'),
|
canManage: capabilities.has('kairo.recurring.manage'),
|
||||||
busy: ops.formBusy,
|
busy: ops.formBusy,
|
||||||
onCompleted: async () => {
|
onCompleted: async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user