feat(A1): Minimum Usability Slice — CadenceInstance, Today-Übung, Gate-Fortschritt
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 4m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 4m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
PO No-Go A1 adressieren: Tages-Tracking mit Messnotiz, Work-UI für Übung, Next-Action-Link, Gate-Fortschritt in Kontrolle, Eingang aus A1-Nav entfernt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0baa5952bc
commit
b7d88ce24a
|
|
@ -124,7 +124,7 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
|
||||||
"planDefaultRoute": "/plan/gates",
|
"planDefaultRoute": "/plan/gates",
|
||||||
"workDefaultRoute": "/work/today",
|
"workDefaultRoute": "/work/today",
|
||||||
"controlDefaultRoute": "/control/status",
|
"controlDefaultRoute": "/control/status",
|
||||||
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
|
"planOutlineKeys": ["profile", "gates", "work"],
|
||||||
"workNavKeys": ["today", "mine", "sprint"],
|
"workNavKeys": ["today", "mine", "sprint"],
|
||||||
"dataSlices": [
|
"dataSlices": [
|
||||||
"actions",
|
"actions",
|
||||||
|
|
|
||||||
25
backend/migrations/030_cadence_instances.sql
Normal file
25
backend/migrations/030_cadence_instances.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- AP A1.1 — CadenceInstance (minimal): eine offene Instanz pro Recurring, Tages-Tracking
|
||||||
|
|
||||||
|
CREATE TABLE cadence_instances (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
recurring_element_id UUID NOT NULL REFERENCES recurring_elements(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'open'
|
||||||
|
CHECK (status IN ('open', 'completed')),
|
||||||
|
due_at TIMESTAMPTZ NOT NULL,
|
||||||
|
completed_at TIMESTAMPTZ NULL,
|
||||||
|
measurement_note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cadence_instances_tenant_recurring
|
||||||
|
ON cadence_instances(tenant_id, recurring_element_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cadence_instances_open_due
|
||||||
|
ON cadence_instances(tenant_id, due_at)
|
||||||
|
WHERE status = 'open';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_cadence_instances_one_open_per_recurring
|
||||||
|
ON cadence_instances(recurring_element_id)
|
||||||
|
WHERE status = 'open';
|
||||||
|
|
@ -24,6 +24,10 @@ class RecurringUpdateRequest(BaseModel):
|
||||||
clear_next_due_at: bool = False
|
clear_next_due_at: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringCompleteRequest(BaseModel):
|
||||||
|
measurement_note: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -73,6 +77,29 @@ def update_recurring_element(
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recurring_id}/complete")
|
||||||
|
def complete_recurring_practice(
|
||||||
|
recurring_id: str,
|
||||||
|
body: RecurringCompleteRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||||
|
):
|
||||||
|
from services.cadence_instance import complete_open_instance
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = complete_open_instance(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
recurring_element_id=recurring_id,
|
||||||
|
user_id=ctx.user_id or None,
|
||||||
|
measurement_note=body.measurement_note or "",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "Recurring-Element nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{recurring_id}", status_code=204)
|
@router.delete("/{recurring_id}", status_code=204)
|
||||||
def delete_recurring_element(
|
def delete_recurring_element(
|
||||||
recurring_id: str,
|
recurring_id: str,
|
||||||
|
|
|
||||||
220
backend/services/cadence_instance.py
Normal file
220
backend/services/cadence_instance.py
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""CadenceInstance service — AP A1.1 minimal daily practice tracking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
|
||||||
|
CadenceStatus = Literal["open", "completed"]
|
||||||
|
|
||||||
|
_CADENCE_COLUMNS = """
|
||||||
|
id, tenant_id, recurring_element_id, status, due_at,
|
||||||
|
completed_at, measurement_note, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
for key in ("id", "tenant_id", "recurring_element_id"):
|
||||||
|
if result.get(key):
|
||||||
|
result[key] = str(result[key])
|
||||||
|
for ts_key in ("due_at", "completed_at", "created_at", "updated_at"):
|
||||||
|
if result.get(ts_key):
|
||||||
|
result[ts_key] = result[ts_key].isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_instance(
|
||||||
|
*, tenant_id: str, recurring_element_id: str
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_CADENCE_COLUMNS}
|
||||||
|
FROM cadence_instances
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
AND recurring_element_id = %s
|
||||||
|
AND status = 'open'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(tenant_id, recurring_element_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _serialize_row(dict(row)) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_open_instance(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
recurring_element_id: str,
|
||||||
|
due_at: Optional[datetime] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Ensure exactly one open CadenceInstance (idempotent)."""
|
||||||
|
existing = get_open_instance(
|
||||||
|
tenant_id=tenant_id, recurring_element_id=recurring_element_id
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
due = due_at or datetime.now(timezone.utc)
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO cadence_instances (
|
||||||
|
tenant_id, recurring_element_id, status, due_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, 'open', %s)
|
||||||
|
RETURNING {_CADENCE_COLUMNS}
|
||||||
|
""",
|
||||||
|
(tenant_id, recurring_element_id, due),
|
||||||
|
)
|
||||||
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def complete_open_instance(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
recurring_element_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
measurement_note: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Close open instance, advance recurring next_due_at, open next instance.
|
||||||
|
Returns { completed, recurring, next_instance }.
|
||||||
|
"""
|
||||||
|
from services.recurring import get_recurring_element, update_recurring_element
|
||||||
|
|
||||||
|
recurring = get_recurring_element(
|
||||||
|
tenant_id=tenant_id, recurring_id=recurring_element_id
|
||||||
|
)
|
||||||
|
if not recurring:
|
||||||
|
raise ValueError("Recurring-Element nicht gefunden")
|
||||||
|
if recurring["status"] != "active":
|
||||||
|
raise ValueError("Recurring-Element ist nicht aktiv")
|
||||||
|
|
||||||
|
open_inst = get_open_instance(
|
||||||
|
tenant_id=tenant_id, recurring_element_id=recurring_element_id
|
||||||
|
)
|
||||||
|
if not open_inst:
|
||||||
|
open_inst = ensure_open_instance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
recurring_element_id=recurring_element_id,
|
||||||
|
due_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
note = (measurement_note or "").strip()
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE cadence_instances
|
||||||
|
SET status = 'completed',
|
||||||
|
completed_at = %s,
|
||||||
|
measurement_note = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s AND tenant_id = %s AND status = 'open'
|
||||||
|
RETURNING {_CADENCE_COLUMNS}
|
||||||
|
""",
|
||||||
|
(now, note, open_inst["id"], tenant_id),
|
||||||
|
)
|
||||||
|
completed_row = cur.fetchone()
|
||||||
|
if not completed_row:
|
||||||
|
raise ValueError("Keine offene Cadence-Instanz")
|
||||||
|
completed = _serialize_row(dict(completed_row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
interval = recurring.get("interval_days") or 1
|
||||||
|
next_due = now + timedelta(days=int(interval))
|
||||||
|
|
||||||
|
update_recurring_element(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
recurring_id=recurring_element_id,
|
||||||
|
user_id=user_id,
|
||||||
|
next_due_at=next_due,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_instance = ensure_open_instance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
recurring_element_id=recurring_element_id,
|
||||||
|
due_at=next_due,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"cadence_instance.completed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"cadence_instance_id": completed["id"],
|
||||||
|
"recurring_id": recurring_element_id,
|
||||||
|
"measurement_note": note[:200] if note else "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_recurring = get_recurring_element(
|
||||||
|
tenant_id=tenant_id, recurring_id=recurring_element_id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"completed_instance": completed,
|
||||||
|
"next_instance": next_instance,
|
||||||
|
"recurring": updated_recurring,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attach_open_instances_to_recurring_list(
|
||||||
|
items: list[dict[str, Any]], *, tenant_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
ids = [item["id"] for item in items]
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_CADENCE_COLUMNS}
|
||||||
|
FROM cadence_instances
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
AND recurring_element_id = ANY(%s::uuid[])
|
||||||
|
AND status = 'open'
|
||||||
|
""",
|
||||||
|
(tenant_id, ids),
|
||||||
|
)
|
||||||
|
by_recurring = {
|
||||||
|
str(row["recurring_element_id"]): _serialize_row(dict(row))
|
||||||
|
for row in cur.fetchall()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for item in items:
|
||||||
|
copy = dict(item)
|
||||||
|
open_inst = by_recurring.get(item["id"])
|
||||||
|
if not open_inst and item.get("status") == "active":
|
||||||
|
open_inst = ensure_open_instance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
recurring_element_id=item["id"],
|
||||||
|
)
|
||||||
|
copy["open_cadence_instance"] = open_inst
|
||||||
|
enriched.append(copy)
|
||||||
|
return enriched
|
||||||
|
|
@ -90,6 +90,16 @@ def create_recurring_element(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
details={"recurring_id": row["id"], "initiative_id": initiative_id},
|
details={"recurring_id": row["id"], "initiative_id": initiative_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from services.cadence_instance import ensure_open_instance
|
||||||
|
|
||||||
|
due = next_due_at or datetime.now(timezone.utc)
|
||||||
|
open_inst = ensure_open_instance(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
recurring_element_id=row["id"],
|
||||||
|
due_at=due,
|
||||||
|
)
|
||||||
|
row["open_cadence_instance"] = open_inst
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -111,10 +121,14 @@ def list_recurring_for_initiative(
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id),
|
(tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
rows = [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
from services.cadence_instance import attach_open_instances_to_recurring_list
|
||||||
|
|
||||||
|
return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
def get_recurring_element(
|
def get_recurring_element(
|
||||||
*, tenant_id: str, recurring_id: str
|
*, tenant_id: str, recurring_id: str
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,11 @@ def recurring_due_candidates(
|
||||||
re.title AS title,
|
re.title AS title,
|
||||||
'Wiederkehrendes Element fällig' AS summary,
|
'Wiederkehrendes Element fällig' AS summary,
|
||||||
re.initiative_id,
|
re.initiative_id,
|
||||||
|
re.id AS recurring_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
'recurring_due' AS reason_code,
|
'recurring_due' AS reason_code,
|
||||||
'Rhythmus-Element bearbeiten' AS recommended_action
|
'Übung heute erledigen' AS recommended_action
|
||||||
FROM recurring_elements re
|
FROM recurring_elements re
|
||||||
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||||
AND re.status = 'active'
|
AND re.status = 'active'
|
||||||
|
|
@ -40,6 +41,8 @@ def recurring_due_candidates(
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["initiative_id"] = str(item["initiative_id"])
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
if item.get("recurring_id"):
|
||||||
|
item["recurring_id"] = str(item["recurring_id"])
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -60,10 +63,11 @@ def active_recurring_candidates(
|
||||||
re.title AS title,
|
re.title AS title,
|
||||||
'Heutige Übung / Routine' AS summary,
|
'Heutige Übung / Routine' AS summary,
|
||||||
re.initiative_id,
|
re.initiative_id,
|
||||||
|
re.id AS recurring_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
'recurring_active' AS reason_code,
|
'recurring_active' AS reason_code,
|
||||||
'Routine ausführen' AS recommended_action
|
'Übung heute erledigen' AS recommended_action
|
||||||
FROM recurring_elements re
|
FROM recurring_elements re
|
||||||
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||||
AND re.status = 'active'
|
AND re.status = 'active'
|
||||||
|
|
@ -76,6 +80,8 @@ def active_recurring_candidates(
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["initiative_id"] = str(item["initiative_id"])
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
if item.get("recurring_id"):
|
||||||
|
item["recurring_id"] = str(item["recurring_id"])
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
70
backend/tests/test_a1_cadence_mvp.py
Normal file
70
backend/tests/test_a1_cadence_mvp.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""AP A1.1 — CadenceInstance complete practice flow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_daily_practice_advances_cadence(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Spagat Cadence",
|
||||||
|
archetype_key="initiative.maturity_journey",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
recurring = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/recurring",
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
assert recurring
|
||||||
|
item = recurring[0]
|
||||||
|
assert item.get("open_cadence_instance")
|
||||||
|
assert item["open_cadence_instance"]["status"] == "open"
|
||||||
|
recurring_id = item["id"]
|
||||||
|
|
||||||
|
completed = client.post(
|
||||||
|
f"/api/recurring/{recurring_id}/complete",
|
||||||
|
json={"measurement_note": "45° Spagat gehalten"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert completed.status_code == 200
|
||||||
|
body = completed.json()
|
||||||
|
assert body["completed_instance"]["status"] == "completed"
|
||||||
|
assert body["completed_instance"]["measurement_note"] == "45° Spagat gehalten"
|
||||||
|
assert body["next_instance"]["status"] == "open"
|
||||||
|
assert body["recurring"]["next_due_at"]
|
||||||
|
|
||||||
|
refreshed = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/recurring",
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
assert refreshed[0]["open_cadence_instance"]["id"] == body["next_instance"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_steering_next_action_includes_recurring_id(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Spagat Next",
|
||||||
|
archetype_key="initiative.maturity_journey",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
next_actions = snap.json().get("next_actions") or []
|
||||||
|
recurring_items = [a for a in next_actions if a.get("kind") == "recurring_due"]
|
||||||
|
assert recurring_items
|
||||||
|
assert recurring_items[0].get("recurring_id")
|
||||||
|
|
@ -82,6 +82,8 @@ def test_operating_context_maturity(client):
|
||||||
body = res.json()
|
body = res.json()
|
||||||
assert body["method_key"] == "maturity_progression"
|
assert body["method_key"] == "maturity_progression"
|
||||||
assert any(s["key"] == "journey" for s in body["ui_profile"]["processSteps"])
|
assert any(s["key"] == "journey" for s in body["ui_profile"]["processSteps"])
|
||||||
|
assert "inbox" not in (body["ui_profile"].get("planOutlineKeys") or [])
|
||||||
|
assert body["ui_profile"]["planOutlineKeys"] == ["profile", "gates", "work"]
|
||||||
|
|
||||||
|
|
||||||
def test_operating_context_program(client):
|
def test_operating_context_program(client):
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| maturity / sequential / queue / recurring Strategien | ✓ | AP2.0d; pytest + Deploy |
|
| maturity / sequential / queue / recurring Strategien | ✓ | AP2.0d; pytest + Deploy |
|
||||||
| Referenz-Ausprägungen (Kumite, Buch, Kairo) | ◐ | Code-Registry; Nutzer wählt bei Anlage |
|
| Referenz-Ausprägungen (Kumite, Buch, Kairo) | ◐ | Code-Registry; Nutzer wählt bei Anlage |
|
||||||
| Archetyp Starter-Kits | ◐ | AP2.2a |
|
| Archetyp Starter-Kits | ◐ | AP2.2a |
|
||||||
| MVP-Abnahfe Stufe A validiert | ◐ | AP2.1: Report v0.3 + Assignment — PO-Validation auf Dev ausstehend |
|
| MVP-Abnahfe Stufe A validiert | ✗ | AP2.1: A1 PO No-Go; A2/B2b offen — Register §7 |
|
||||||
| Operational Actor API | ✓ | AP1.7: `/api/operational/` + Service Tokens |
|
| Operational Actor API | ✓ | AP1.7: `/api/operational/` + Service Tokens |
|
||||||
| Actor Service Token | ✓ | AP1.7c; Tenant-scoped, capability-gebunden |
|
| Actor Service Token | ✓ | AP1.7c; Tenant-scoped, capability-gebunden |
|
||||||
| MCP produktiv | ✗ | nach AP2.1 Go |
|
| MCP produktiv | ✗ | nach AP2.1 Go |
|
||||||
|
|
@ -220,6 +220,15 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
|
||||||
| Roadmap-Phasen veraltet | Roadmap v0.2 §2 aktualisiert 2026-07-11 |
|
| Roadmap-Phasen veraltet | Roadmap v0.2 §2 aktualisiert 2026-07-11 |
|
||||||
| Usability Recovery → InitiativeDetail | IA-first, Redirects AP1.9a |
|
| Usability Recovery → InitiativeDetail | IA-first, Redirects AP1.9a |
|
||||||
|
|
||||||
|
| **F-A1-09** | Leitfrage (≤2 Min) | Prozess/Doku | — | offen | Report §3.3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Validation-Findings (AP2.1)
|
||||||
|
|
||||||
|
PO-Smoke-Ergebnisse und Architektur-Einordnung:
|
||||||
|
`docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Nächste erwartete Truth-Table-Änderungen
|
## Nächste erwartete Truth-Table-Änderungen
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ Auth: Actor Service Token (Tenant-scoped). Kein Gate-Verify, kein Lifecycle-Over
|
||||||
|
|
||||||
Go-Kriterium: MVP v0.3 §5 — pro Stufe-A-Archetyp Leitfrage ≤2 Min + Next Action begründet; Portfolio Cockpit-Attention (Kriterium 7).
|
Go-Kriterium: MVP v0.3 §5 — pro Stufe-A-Archetyp Leitfrage ≤2 Min + Next Action begründet; Portfolio Cockpit-Attention (Kriterium 7).
|
||||||
|
|
||||||
|
**Findings-Register (PO-Smoke + Architektur-Review):** `docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Dokumentenpriorität
|
## 8. Dokumentenpriorität
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,11 @@ Cadence im Detail am RecurringElement.
|
||||||
|
|
||||||
## 11. Ist-Stand (Spec vs. Code)
|
## 11. Ist-Stand (Spec vs. Code)
|
||||||
|
|
||||||
|
**PO-Validation 2026-07-28:** Detaillierte Findings + Architektur-Urteil →
|
||||||
|
`docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` (F-A1-01…10)
|
||||||
|
|
||||||
|
**PO-Produkturteil A1:** **No-Go** — Leitfrage nicht erfüllt; Tages-Tracking, Messgrößen, Gate-Fortschritt und IA fehlen für echten Einsatz. Architektur/Spec **nicht** falsch; **Minimal-Slice** (CadenceInstance, Work=Übung) vor Re-Abnahme nötig.
|
||||||
|
|
||||||
| Bereich | Spec | Code/Truth | Lücke |
|
| Bereich | Spec | Code/Truth | Lücke |
|
||||||
|---------|------|------------|-------|
|
|---------|------|------------|-------|
|
||||||
| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz |
|
| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz |
|
||||||
|
|
|
||||||
|
|
@ -5,27 +5,32 @@
|
||||||
**Stand:** 2026-07-28
|
**Stand:** 2026-07-28
|
||||||
**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`
|
||||||
|
|
||||||
| Feld | Wert |
|
| Feld | Wert |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **Kairo-Version / Deploy** | |
|
| **Kairo-Version / Deploy** | `0.20.2-ap2.2d` (schema 025) |
|
||||||
| **Dev-URL** | https://dev.kairo.jinkendo.de |
|
| **Dev-URL** | https://dev.kairo.jinkendo.de |
|
||||||
| **Branch / Commit** | `develop` @ |
|
| **Branch / Commit** | `develop` @ `0baa595` |
|
||||||
| **Tester (PO)** | |
|
| **Tester (PO)** | Lars |
|
||||||
| **Testdatum** | |
|
| **Testdatum** | 2026-07-28 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Executive Summary
|
## 1. Executive Summary
|
||||||
|
|
||||||
| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☐ No-Go |
|
| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (bis A1 nachgezogen oder Scope-PO) |
|
||||||
|--------------|-------------------------------------|
|
|--------------|-------------------------------------|
|
||||||
|
|
||||||
**Kurzfazit (3–5 Sätze):**
|
**Kurzfazit (3–5 Sätze):**
|
||||||
|
|
||||||
|
Technisch: pytest grün, A1-Happy-Path (Anlage → Verify → Stufenwechsel) durchspielbar.
|
||||||
|
Produkt (PO): **A1 No-Go** — Leitfrage nicht beantwortbar; tagesbezogenes Tracking, Messgrößen und Gate-Fortschritt fehlen; extreme Unübersichtlichkeit; **Hürden > Nutzen**. A2/B2b noch offen.
|
||||||
|
|
||||||
**Blocker (falls No-Go / Bedingt):**
|
**Blocker (falls No-Go / Bedingt):**
|
||||||
|
|
||||||
|
- **A1:** CadenceInstance/Tages-„erledigt“, Work=Übung, Gate-Fortschritt, IA — siehe Findings Register §6.1 (F-A1-02…10)
|
||||||
|
- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach A1 Minimum Slice **oder** PO-Scope-Entscheidung (A1 aus Stufe-A-Abnahfe)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -44,8 +49,22 @@
|
||||||
|
|
||||||
## 3. Szenario A1 — Spagat können (Reifegrad)
|
## 3. Szenario A1 — Spagat können (Reifegrad)
|
||||||
|
|
||||||
**Initiative-ID / Titel:**
|
**Initiative-ID / Titel:** Spagat können (PO-Smoke 2026-07-28)
|
||||||
**Archetyp:** `initiative.maturity_journey` · **Methode:** `maturity_progression`
|
**Archetyp:** `initiative.maturity_journey` · **Methode:** `maturity_progression`
|
||||||
|
**Zwischenurteil:** ☐ Go · ☐ Bedingt · ☑ **No-Go (Produkt)** — [Findings Register](Sprint1_AP2_1_Validation_Findings_Register_v0.1.md) F-A1-01…10
|
||||||
|
|
||||||
|
### 3.0 PO-Feedback (2026-07-28)
|
||||||
|
|
||||||
|
| Thema | Urteil |
|
||||||
|
|-------|--------|
|
||||||
|
| Anlage + Starter-Kit | ✓ technisch |
|
||||||
|
| Kontrolle (Stufe, Next Action) | ✗ kein Tages-/Gate-Fortschrittsbild |
|
||||||
|
| Ausführen (Anti-Todo-Wand) | ✗ Übung nicht ausführbar; AP-Verwirrung |
|
||||||
|
| Verify + Stufenwechsel | ✓ technisch; PO: kein Ersatz für laufendes Tracking |
|
||||||
|
| Journey / Rhythmen | ✗ unübersichtlich |
|
||||||
|
| Leitfrage ≤2 Min | **✗** — nicht sinnvoll beantwortbar |
|
||||||
|
|
||||||
|
**PO-Zitat (Kern):** *Für tagesbezogenes Tracking, Messmethoden und aktive Gate-Einschätzung unbrauchbar; Hürden größer als Nutzen.*
|
||||||
|
|
||||||
### 3.1 Smoke-Schritte (Dev)
|
### 3.1 Smoke-Schritte (Dev)
|
||||||
|
|
||||||
|
|
@ -74,9 +93,9 @@
|
||||||
|
|
||||||
### 3.3 Leitfrage-Antwort (PO, ≤2 Min)
|
### 3.3 Leitfrage-Antwort (PO, ≤2 Min)
|
||||||
|
|
||||||
**Nächster Schritt:**
|
**Nächster Schritt:** *Nicht sinnvoll bestimmbar* — kein klarer Tages-Handlungspfad (Übung erledigen + Fortschritt sehen).
|
||||||
**Warum:**
|
**Warum:** Ausführen zeigt AP statt Übung; kein Tracking; Gate-Fortschritt nicht steuerbar; UI unübersichtlich.
|
||||||
**Stopuhr (Min):**
|
**Stopuhr:** >2 Min / abgebrochen — **MVP §5.6 nicht erfüllt**
|
||||||
|
|
||||||
### 3.4 Anti-Patterns (§7)
|
### 3.4 Anti-Patterns (§7)
|
||||||
|
|
||||||
|
|
@ -85,8 +104,7 @@
|
||||||
| Flache Liste aller Übungs-Tasks als Hauptbild | ☐ ja ☐ nein | |
|
| Flache Liste aller Übungs-Tasks als Hauptbild | ☐ ja ☐ nein | |
|
||||||
| Recurring im Action-Backlog statt Rhythmus-Ansicht | ☐ ja ☐ nein | |
|
| Recurring im Action-Backlog statt Rhythmus-Ansicht | ☐ ja ☐ nein | |
|
||||||
|
|
||||||
**Lücken / UX-Schmerz:**
|
**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).
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
229
docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md
Normal file
229
docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# AP2.1 — Validation Findings Register
|
||||||
|
## v0.1 (PO-Smoke → Architektur-Review)
|
||||||
|
|
||||||
|
**Status:** aktiv — wird während AP2.1 ergänzt
|
||||||
|
**Stand:** 2026-07-28 (A1 Minimum Slice implementiert — PO Re-Abnahme ausstehend)
|
||||||
|
**Bezug:** `Sprint1_AP2_1_MVP_Validation_Report_v0.3.md`, `Kairo_MVP_Definition_v0.3.md` §5–8
|
||||||
|
**Dev:** https://dev.kairo.jinkendo.de · nach Deploy Migration **030** + A1-Slice
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Dieses Register hält **PO-Beobachtungen aus der MVP-Validation** fest und ordnet sie architektonisch ein:
|
||||||
|
|
||||||
|
| Kategorie | Bedeutung |
|
||||||
|
|-----------|-----------|
|
||||||
|
| **Bug** | Abweichung vom **aktuellen** Soll (Spec + gelieferte Architektur) — Fix ohne ADP |
|
||||||
|
| **Config-Drift** | Inkonsistenz innerhalb der Plugin-/Profile-Schicht (kein Modellbruch) |
|
||||||
|
| **Implementierungsschuld** | Architektur und Spec stimmen; Code liefert Teilmenge |
|
||||||
|
| **Aufgeschoben (Spec)** | Explizit in Archetyp-/Methoden-Spec oder ADP als „später“ / Stufe B+ |
|
||||||
|
| **Architektur-Lücke** | Zielmodell dokumentiert, tragfähige Basis fehlt oder widerspricht sich **über** Spec hinaus |
|
||||||
|
|
||||||
|
**Leitfrage des Reviews:** Brauchen wir ein **neues Architektur-Design**, oder ist die Plugin-/Kernel-Schicht korrekt und es fehlen **Slices**?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur-Urteil (Stand nach A1-Smoke)
|
||||||
|
|
||||||
|
### 2.1 Kurzfazit (technisch)
|
||||||
|
|
||||||
|
**Kein generelles Architektur-Design-Problem.** Die vier Schichten (Methode → Archetyp → Profil → UI-Composition, AP2.3/AP2.4) tragen den A1-Happy-Path **technisch**.
|
||||||
|
|
||||||
|
### 2.1b PO-Produkturteil A1 (2026-07-28)
|
||||||
|
|
||||||
|
**No-Go für echten Einsatz** — unabhängig vom grünen E2E-Test.
|
||||||
|
|
||||||
|
> Leitfrage lässt sich nicht sinnvoll beantworten. Für tagesbezogenes Tracking (durchgeführt; optional Messwerte wie Anzahl, 45°-Spagat), aktive Einschätzung des Gate-Fortschritts und übersichtliche Steuerung ist der Vorhabentyp **unbrauchbar**. Hürden > Nutzen.
|
||||||
|
|
||||||
|
| Dimension | Technische Validation | PO-Produktnutzen |
|
||||||
|
|-----------|----------------------|------------------|
|
||||||
|
| Happy Path (Anlage → Verify) | ✓ | ◐ — nur Demo, kein Alltag |
|
||||||
|
| Leitfrage ≤2 Min (MVP §5.6) | — | **✗** |
|
||||||
|
| Tages-Tracking / Übung erledigen | ✗ | **Blocker** |
|
||||||
|
| Messgrößen / Metriken | ✗ | **Blocker** (PO) |
|
||||||
|
| Gate-Fortschritt sichtbar steuerbar | ◐ | **✗** |
|
||||||
|
| Übersichtlichkeit | ◐ | **✗** |
|
||||||
|
|
||||||
|
**Architektur-Einordnung:** PO-Anforderungen stehen **bereits in SPEC A1 / Spec-D** (CadenceInstance, Tages-Übung, Kriterien, Metriken). Es fehlt nicht ein neues Design — es fehlt der **Minimal-Slice für nutzbaren A1**, der fälschlich als „post-MVP“ behandelt wurde.
|
||||||
|
|
||||||
|
**Konsequenz AP2.1 / MVP Stufe A:** A1 erfüllt **nicht** die MVP-Nutzbarkeits-Bar §5 für PO-Abnahme. Gesamt-Go Stufe A **blockiert**, solange A1 Pflicht-Szenario bleibt (`Kairo_MVP_Definition_v0.3.md` §4).
|
||||||
|
|
||||||
|
Die Validation zeigt drei **Lückentypen**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(1) Config-Drift planOutlineKeys ≠ dataSlices → schneller Fix
|
||||||
|
(2) Spec-Deferred CadenceInstance, Alternanz, metric → geplant, Spec §11
|
||||||
|
(3) Polymorphie-Gap Kernel Q2 Work-Items: BE ja, FE nein → Implementierungsschuld
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Referenz-Architektur (Prüfmaßstab)
|
||||||
|
|
||||||
|
| Dokument | Rolle |
|
||||||
|
|----------|--------|
|
||||||
|
| `ADP_Archetype_Method_Plugin_Architecture_v0.1.md` | Route Gating, Slices, kein Page-If |
|
||||||
|
| `SPEC_A1_maturity_journey_v0.1.md` §4, §8, §11 | A1-Fachmodell + Ist-Stand-Tabelle |
|
||||||
|
| `methods/SPEC_D_maturity_progression_v0.1.md` D4–D6 | Leading = CadenceInstance; Ranking |
|
||||||
|
| `ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md` | Kriterien, Evidence, `metric` später |
|
||||||
|
| `Kairo_Steering_Method_Kernel_v0.1.md` Q2 | Work-Item-Polymorphie |
|
||||||
|
| `Kairo_Steering_Method_Normalization_Program_v0.1.md` | CadenceInstance als Normalform |
|
||||||
|
|
||||||
|
### 2.3 Was **nicht** neu designt werden muss
|
||||||
|
|
||||||
|
- Archetyp ↔ Methode ↔ UI-Profil (Operating Context)
|
||||||
|
- Gate-Verify + Kriterien-Checkliste (AP1.4b)
|
||||||
|
- Stufenwechsel + Recurring-Rotation (AP2.0e)
|
||||||
|
- Composition-Slots (`maturity_stage`, `recurring_rhythm`, `work.today`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Findings — Szenario A1 (Spagat / Reifegrad)
|
||||||
|
|
||||||
|
**PO-Validation:** 2026-07-28 · Vorhaben „Spagat können“ · `initiative.maturity_journey`
|
||||||
|
**Urteil A1:** **No-Go (Produkt)** · technischer Kernpfad ✓ · MVP §5 für PO **✗**
|
||||||
|
|
||||||
|
| ID | PO-Beobachtung | Kategorie | Architektur-Urteil | MVP Stufe A | Follow-up |
|
||||||
|
|----|----------------|-----------|-------------------|-------------|-----------|
|
||||||
|
| **F-A1-01** | Klick **Plan → Eingang** leitet auf **Zielzustände** um | **Config-Drift** | `planOutlineKeys` enthält `inbox`, `dataSlices` ohne `backlog` → Route Gating korrekt, Nav falsch. **Kein Modellproblem.** Spec A1 §8: Eingang „nicht dominant“. | Mitverantwortlich Unübersicht | **Fix:** `inbox` aus `planOutlineKeys` bei `initiative.maturity_journey` |
|
||||||
|
| **F-A1-02** | Keine Konfiguration **Wochentage / Rhythmus**; kein **Tages-Tracking** („heute erledigt“) | **Implementierungsschuld** (Spec-Pflicht) | Spec A1 §4.3–4.5 + Spec-D D4: `CadenceInstance`. Code: nur `RecurringElement` + Fälligkeit. **Kein Designbruch — fehlender Kern-Slice.** | **PO-Blocker** | **AP A1-Cadence-MVP:** Instanz + „heute erledigt“ |
|
||||||
|
| **F-A1-03** | **Ausführen:** AP statt Übung; Rhythmus-Link tot; AP nicht öffenbar | **Implementierungsschuld** | Kernel Q2: Leading = CadenceInstance; FE verlinkt nur Actions. | **PO-Blocker** | **AP A1-Work-UI** |
|
||||||
|
| **F-A1-04** | Kriterien auto-erfüllt via Evidence; keine laufende Gate-Einschätzung | **By Design** + **Lücke UX** | AP1.4b korrekt für Verify-Moment; PO braucht **Fortschrittsbild** während Stufe (Spec A1 §8 Kontrolle). | **PO-Blocker** (Einschätzung) | Fortschritts-Panel + manuelle/metric Kriterien |
|
||||||
|
| **F-A1-05** | Keine Messung (4×/Woche, 80 Punkte, 45° Spagat, …) | **Implementierungsschuld** (Spec vorbereitet) | `criterion_kind: metric` + Cadence-Erfüllung geplant; keine Engine. Schema reicht — **Feature fehlt, kein ADP neu.** | **PO-Blocker** | Messwert am Tagesabschluss + optional metric-Kriterium |
|
||||||
|
| **F-A1-06** | Keine **kontinuierliche** Fortschrittskontrolle in der Stufe | **Implementierungsschuld** | Plan/Ist-Trennung korrekt; Ist-Tracking für Übungen fehlt (CadenceInstance). | **PO-Blocker** | Teil A1-Cadence-MVP |
|
||||||
|
| **F-A1-07** | Nach Verify keine AP-Aktivierung; nur Recurring rotiert | **Implementierungsschuld** | Spec A1 §4.4 Activity Set — Scope AP2.0e minimal. | Sekundär vs. F-A1-02–06 | AP A1-Activity-Set |
|
||||||
|
| **F-A1-08** | Journey unklar; pausierte Übung verwirrend | **IA** | Technisch korrekt (AP2.0e); PO wahrgenommene Unübersicht. | Mitverantwortlich | IA A1 Today-first |
|
||||||
|
| **F-A1-09** | **Leitfrage** nicht beantwortbar — Hürden > Nutzen | **PO-Produkturteil** | MVP §5.6 nicht erfüllt. Symptom von F-A1-02…05, nicht Doku-Problem. | **No-Go** | siehe §8 Minimum-Slice |
|
||||||
|
| **F-A1-10** | **Extreme Unübersichtlichkeit** (Eingang-Redirect, AP vs. Übung, Journey vs. Rhythmen) | **IA + Implementierungsschuld** | Plugin-Architektur OK; **A1-Profil und Work-Default** spiegeln Nutzerbild Spec §8 nicht. Kein neues OM nötig — **falsche/fehlende UI-Hülle**. | **PO-Blocker** | A1-IA-Slice (Eingang weg, Today=Übung, klare Stufenleiste) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Detail-Notizen (Architektur-Check)
|
||||||
|
|
||||||
|
### F-A1-01 — Eingang vs. Slices
|
||||||
|
|
||||||
|
```text
|
||||||
|
ui_profiles.py initiative.maturity_journey:
|
||||||
|
planOutlineKeys: [profile, gates, inbox, work] ← Nav zeigt Eingang
|
||||||
|
dataSlices: [actions, roadmap, recurring, …] ← kein "backlog"
|
||||||
|
|
||||||
|
viewRegistry.js /plan/inbox requiredSlices: ['backlog']
|
||||||
|
→ isRouteAllowedForMode = false → Redirect planDefaultRoute (/plan/gates)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Urteil:** Plugin-Architektur funktioniert wie designed; **Profil-Seed ist inkonsistent**.
|
||||||
|
|
||||||
|
### F-A1-02 / F-A1-06 — CadenceInstance-Schicht
|
||||||
|
|
||||||
|
| Schicht | Spec | Code heute |
|
||||||
|
|---------|------|------------|
|
||||||
|
| RecurringElement | Container + Bindung Stufe | ✓ |
|
||||||
|
| Cadence / Wochentage / Alternanz | Spec A1 §4.3–4.5 | ✗ |
|
||||||
|
| CadenceInstance (eine offene Instanz) | Spec-D D4, Kernel Q2 | ✗ (nur `next_due_at` am Element) |
|
||||||
|
| Erfüllung → nächste Instanz | Spec A1 Happy Path §9.3 | ◐ (manuell / overdue-Logik minimal) |
|
||||||
|
|
||||||
|
**Urteil:** **Kein falsches Design** — Normalization-Programm beschreibt Ziel; MVP lieferte **Minimal-Rhythmus**. Spec A1 §11 listet das explizit als Lücke.
|
||||||
|
|
||||||
|
### F-A1-03 — Work-Item-Polymorphie (Kernel Q2)
|
||||||
|
|
||||||
|
| Work Item | Next Action BE | Next Action FE Link |
|
||||||
|
|-----------|----------------|---------------------|
|
||||||
|
| Action | ✓ | ✓ |
|
||||||
|
| Backlog | ✓ | ✓ |
|
||||||
|
| Recurring / Cadence | ✓ (`recurring_due`) | ✗ (nur Text `recommended_action`) |
|
||||||
|
|
||||||
|
**Urteil:** **Architektur-Lücke nur in der Präsentationsschicht** — Steering-Kernel und Strategies sind erweiterbar; FE muss polymorphe Ziele unterstützen (Link-Typ-Registry analog Backend `kind`).
|
||||||
|
|
||||||
|
### F-A1-04 / F-A1-05 — Kriterien & Metriken
|
||||||
|
|
||||||
|
- **Evidence → satisfied:** beabsichtigt (`roadmap_criteria.prepare_criteria_for_verify`).
|
||||||
|
- **Manuelle Kriterien:** bleiben offen bis manuell / waived — korrekt.
|
||||||
|
- **Metriken:** Schema vorbereitet, **keine** Regel-Engine — ADP „später“.
|
||||||
|
|
||||||
|
**Urteil:** Gate-Modell ist **generisch genug**; fehlende Metrik-Auswertung ist **Feature-Deferral**, kein Redesign.
|
||||||
|
|
||||||
|
### F-A1-07 — Activity Set bei Stufenwechsel
|
||||||
|
|
||||||
|
`maturity_stage_transition.on_maturity_stage_reached`:
|
||||||
|
|
||||||
|
- pausiert aktive Recurring ✓
|
||||||
|
- aktiviert nächste Stufe ✓
|
||||||
|
- legt neue Recurring-Übung an ✓
|
||||||
|
- **keine** Stage-Actions, **keine** Graph-Aktivierung weiterer Plan-APs
|
||||||
|
|
||||||
|
Spec A1 §4.4 verlangt volles Set — **Implementierungsschuld**, Scope AP2.0e war Minimal-Slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Abgleich mit SPEC_A1 §11 (Ist-Stand-Tabelle)
|
||||||
|
|
||||||
|
| Spec §11 Zeile | Validation bestätigt |
|
||||||
|
|----------------|-------------------|
|
||||||
|
| Multi-Übung / Alternanz fehlt | F-A1-02 ✓ |
|
||||||
|
| Stage Activity Set Teilmenge | F-A1-07 ✓ |
|
||||||
|
| UI Stufe-Detail lückenhaft | F-A1-02, F-A1-03 ✓ |
|
||||||
|
| steering_elements FE/BE Parität | F-A1-03 (recurring in Kontrolle ✓, Work ◐) |
|
||||||
|
|
||||||
|
**Keine neue Spec-Zeile nötig** — Register präzisiert PO-Sicht für AP2.1.
|
||||||
|
|
||||||
|
**Urteil:** **Kein falsches Design** — PO-Anforderung = Spec §4 / Spec-D. MVP lieferte **Infrastruktur ohne Ist-Schicht**. Für PO-Abnahme ist das **kein „später“**, sondern **fehlender A1-Kern**.
|
||||||
|
|
||||||
|
### F-A1-09 / F-A1-10 — PO-Leitfrage & Unübersichtlichkeit
|
||||||
|
|
||||||
|
**PO (2026-07-28, wörtlich zusammengefasst):**
|
||||||
|
|
||||||
|
- Leitfrage nicht sinnvoll beantwortbar
|
||||||
|
- Tages-Tracking + optional Messwerte (Anzahl, Grad, …) + aktive Gate-Einschätzung **erforderlich** für echten Einsatz
|
||||||
|
- Extreme Unübersichtlichkeit → **Hürden größer als Nutzen**
|
||||||
|
|
||||||
|
**Architektur:** Symptome von fehlendem CadenceInstance-Modell, Work-UI auf AP statt Übung, IA-Rauschen (Eingang, Journey). **Kein Widerspruch** zwischen Vision und Spec — **Widerspruch zwischen geliefertem MVP-Slice und PO-Mindestnutzen**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Priorisierte Follow-ups (PO-gesteuert)
|
||||||
|
|
||||||
|
### 6.1 A1 Minimum Usability Slice (vor erneuter A1-Abnahme)
|
||||||
|
|
||||||
|
**Implementiert (2026-07-28, uncommitted):** Migration 030, `cadence_instance` + `POST /api/recurring/{id}/complete`, `WorkTodayPracticePanel`, Next-Action-Link `#today-practice`, Gate-Fortschritt in `MaturityStagePanel`, Eingang aus A1-Nav. **Offen:** PO Re-Abnahme; Metric-Kriterium manuell (Slice #5, optional vor Re-Test).
|
||||||
|
|
||||||
|
| # | Lieferung | Decke PO-Schmerz | Status |
|
||||||
|
|---|-----------|------------------|--------|
|
||||||
|
| 1 | **CadenceInstance + „Heute erledigt“** | Tages-Tracking | ✓ implementiert |
|
||||||
|
| 2 | **Ausführen/Today = Übung** | Nutzen Alltag | ✓ implementiert |
|
||||||
|
| 3 | **Kontrolle: Gate-Fortschritt** | Gate-Einschätzung | ✓ implementiert (Kriterien offen/erfüllt) |
|
||||||
|
| 4 | **IA A1** (Eingang weg) | Unübersichtlichkeit | ✓ implementiert |
|
||||||
|
| 5 | **Metric-Kriterium manuell** | Messgrößen | ◐ Messnotiz am Complete; Auto-Engine weiter offen |
|
||||||
|
|
||||||
|
Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4.
|
||||||
|
|
||||||
|
### 6.2 Technische Quick-Wins (P0)
|
||||||
|
|
||||||
|
| Prio | ID | Slice |
|
||||||
|
|------|-----|-------|
|
||||||
|
| P0 | F-A1-01 | Eingang aus A1-Nav |
|
||||||
|
| P1 | F-A1-03 | Next Action → Recurring/Übung-Aktion |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Konsequenz für AP2.1 / MVP Stufe A
|
||||||
|
|
||||||
|
| Option | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| **A — A1 nachziehen** | Validation pausieren; **A1 Minimum Slice** (§6.1) liefern; A1 erneut smoke-testen |
|
||||||
|
| **B — MVP-Definition anpassen** | A1 vorübergehend aus Stufe-A-Abnahfe (PO-Entscheidung + Doku) — **nur wenn A2+B2b als MVP reichen** |
|
||||||
|
| **C — Gesamt No-Go** | AP2.1 stoppt bei Stufe A bis A1 oder Scope-Entscheidung |
|
||||||
|
|
||||||
|
**Empfehlung:** **Option A** — Architektur trägt; Spec war richtig; Implementierung war zu dünn. A2/B2b-Smoke **parallel** möglich, Gesamt-Go aber nicht ohne A1-Lösung (Option A oder explizite B).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Nächste Register-Einträge
|
||||||
|
|
||||||
|
| Szenario | Status |
|
||||||
|
|----------|--------|
|
||||||
|
| A2 — Neue Küche (Linear) | ausstehend |
|
||||||
|
| B2b + B3 — Product + Sprint | ausstehend |
|
||||||
|
| Portfolio / Cockpit (Kriterium 7) | ausstehend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Aktualisieren bei jedem PO-Smoke-Schritt. Bei Kategorie „Architektur-Lücke“ → ADP prüfen, nicht sofort implementieren.*
|
||||||
61
docs/sprints/Sprint1_AP_A1_Minimum_Usability_Slice_v0.1.md
Normal file
61
docs/sprints/Sprint1_AP_A1_Minimum_Usability_Slice_v0.1.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# AP A1 — Minimum Usability Slice
|
||||||
|
## Auftrag v0.1
|
||||||
|
|
||||||
|
**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **Implementierung A1.1–A1.3 abgeschlossen** (Re-Abnahme ausstehend)
|
||||||
|
**Auslöser:** AP2.1 PO No-Go A1 — [`Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`](Sprint1_AP2_1_Validation_Findings_Register_v0.1.md) §6.1
|
||||||
|
**Ziel:** A1 **real nutzbar** für Tages-Tracking + Gate-Einschätzung + klare Today-Ansicht — **ohne** Architektur-Neudesign.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Nicht-Ziele (dieser Slice)
|
||||||
|
|
||||||
|
- Alternanz-Gruppen, Wochentags-Editor vollständig
|
||||||
|
- Automatische Metric-Engine (4×/Woche auto-count)
|
||||||
|
- Volles Activity Set bei Stufenwechsel (Stage-APs auto-aktivieren)
|
||||||
|
- A3/Care CadenceInstance-Parität
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lieferumfang (Pflicht für Re-Abnahme A1)
|
||||||
|
|
||||||
|
| # | Lieferung | Findings | Akzeptanz |
|
||||||
|
|---|-----------|----------|-----------|
|
||||||
|
| 1 | **`cadence_instances`** + API `complete` | F-A1-02, F-A1-06 | Eine offene Instanz pro aktivem Recurring; „Heute erledigt“ → nächste Fälligkeit |
|
||||||
|
| 2 | **`WorkTodayPracticePanel`** | F-A1-03 | Ausführen/Today zeigt Übung zuerst; optional Messnotiz |
|
||||||
|
| 3 | **Next Action → Übung** | F-A1-03 | Klickbarer Pfad zu Today-Übung |
|
||||||
|
| 4 | **Gate-Fortschritt** am aktiven Stufe-Panel | F-A1-04 | Kriterien offen/erfüllt sichtbar + Link Gate-Detail |
|
||||||
|
| 5 | **IA A1** | F-A1-01, F-A1-10 | Eingang aus Nav; Plan-Outline nur profile + gates + work |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architektur-Bezug (kein ADP nötig)
|
||||||
|
|
||||||
|
| Baustein | Dokument |
|
||||||
|
|----------|----------|
|
||||||
|
| CadenceInstance | `Kairo_Steering_Method_Normalization_Program_v0.1.md`, Spec-D `maturity_progression` D4 |
|
||||||
|
| Work-Item-Polymorphie FE | Kernel Q2 — Link-Typ `recurring_id` |
|
||||||
|
| Gate-Kriterien | AP1.4b — bestehend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Pakete
|
||||||
|
|
||||||
|
```text
|
||||||
|
A1.1 Migration 030 + cadence_instance service + complete API + pytest
|
||||||
|
A1.2 WorkTodayPracticePanel + Composition + NextAction-Link
|
||||||
|
A1.3 MaturityStagePanel Gate-Fortschritt + ui_profile IA
|
||||||
|
A1.4 Re-Validation A1 (PO) → Report §3 Go/Bedingt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Re-Abnahme-Kriterien (PO)
|
||||||
|
|
||||||
|
1. PO kann **heute** Übung als erledigt markieren (+ optional Messnotiz).
|
||||||
|
2. **Leitfrage ≤2 Min:** „Welche Übung heute?“ — ohne AP-Detour.
|
||||||
|
3. **Kontrolle:** aktive Stufe + Kriterien-Fortschritt erkennbar.
|
||||||
|
4. Kein Eingang-Redirect / Nav-Rauschen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Nach Abschluss: Findings Register F-A1-* auf erledigt setzen; AP2.1 A1 erneut smoke-testen.*
|
||||||
|
|
@ -21,3 +21,10 @@ export function updateRecurring(recurringId, body) {
|
||||||
export function deleteRecurring(recurringId) {
|
export function deleteRecurring(recurringId) {
|
||||||
return apiFetch(`/api/recurring/${recurringId}`, { method: 'DELETE' })
|
return apiFetch(`/api/recurring/${recurringId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function completeRecurringPractice(recurringId, body = {}) {
|
||||||
|
return apiFetch(`/api/recurring/${recurringId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,66 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
import { listInitiativeRoadmapCriteriaProgress } from '../api/roadmap.js'
|
||||||
|
import { gatePath, scopedPath } from '../utils/routes.js'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
|
||||||
|
|
||||||
export function MaturityStagePanel({
|
export function MaturityStagePanel({
|
||||||
initiativeId,
|
initiativeId,
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
embedded = true,
|
embedded = true,
|
||||||
}) {
|
}) {
|
||||||
|
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||||
|
|
||||||
const stages = [...roadmapItems]
|
const stages = [...roadmapItems]
|
||||||
.filter((item) => item.item_type === 'maturity_stage')
|
.filter((item) => item.item_type === 'maturity_stage')
|
||||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||||
|
|
||||||
const activeStage = stages.find((s) => s.status === 'active')
|
const activeStage = stages.find((s) => s.status === 'active')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId) return undefined
|
||||||
|
let cancelled = false
|
||||||
|
listInitiativeRoadmapCriteriaProgress(initiativeId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setCriteriaProgress(data || {})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCriteriaProgress({})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initiativeId, roadmapItems.length])
|
||||||
|
|
||||||
|
const activeProgress = activeStage ? criteriaProgress[activeStage.id] : null
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
stages.length === 0 ? (
|
stages.length === 0 ? (
|
||||||
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
|
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeStage && (
|
{activeStage && (
|
||||||
<p className="maturity-stage-active muted">
|
<>
|
||||||
Aktive Stufe: <strong>{activeStage.title}</strong>
|
<p className="maturity-stage-active muted">
|
||||||
</p>
|
Aktive Stufe:{' '}
|
||||||
|
<Link to={gatePath(activeStage.id)} className="link-inline">
|
||||||
|
<strong>{activeStage.title}</strong>
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{activeProgress && (
|
||||||
|
<p className="maturity-stage-progress muted">
|
||||||
|
Gate-Fortschritt:{' '}
|
||||||
|
<strong>
|
||||||
|
{activeProgress.closed ?? 0} / {activeProgress.total ?? 0}
|
||||||
|
</strong>{' '}
|
||||||
|
Kriterien erfüllt
|
||||||
|
{(activeProgress.open ?? 0) > 0 && (
|
||||||
|
<span> · {activeProgress.open} offen</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<ol className="item-list maturity-stage-list">
|
<ol className="item-list maturity-stage-list">
|
||||||
{stages.map((stage, index) => (
|
{stages.map((stage, index) => (
|
||||||
|
|
@ -36,7 +74,9 @@ export function MaturityStagePanel({
|
||||||
>
|
>
|
||||||
<span className="maturity-stage-item__index muted">{index + 1}</span>
|
<span className="maturity-stage-item__index muted">{index + 1}</span>
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<strong>{stage.title}</strong>
|
<Link to={gatePath(stage.id)} className="list-item-link">
|
||||||
|
<strong>{stage.title}</strong>
|
||||||
|
</Link>
|
||||||
<span className="muted list-item-sub">
|
<span className="muted list-item-sub">
|
||||||
{MILESTONE_STATUS_LABELS[stage.status] || stage.status}
|
{MILESTONE_STATUS_LABELS[stage.status] || stage.status}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -45,9 +85,13 @@ export function MaturityStagePanel({
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<p className="muted maturity-stage-hint">
|
<p className="muted maturity-stage-hint">
|
||||||
Stufen und Übungen unter{' '}
|
Kriterien und Verify am{' '}
|
||||||
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
|
<Link to={scopedPath('/plan/gates', { initiativeId })} className="link-inline">
|
||||||
Kontrolle → Rhythmen & Journey
|
Plan → Stufen
|
||||||
|
</Link>
|
||||||
|
; Übung unter{' '}
|
||||||
|
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
||||||
|
Ausführen → Heute
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -62,7 +106,7 @@ export function MaturityStagePanel({
|
||||||
<div>
|
<div>
|
||||||
<h2>Reifegrad-Stufen</h2>
|
<h2>Reifegrad-Stufen</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Aktive Stufe und Fortschritt — Steuerungshorizont für Reifegrad-Vorhaben (A1).
|
Aktive Stufe, Gate-Fortschritt und Orientierung — Steuerungshorizont (A1).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
99
frontend/src/components/WorkTodayPracticePanel.jsx
Normal file
99
frontend/src/components/WorkTodayPracticePanel.jsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { completeRecurringPractice } from '../api/recurring.js'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
|
function formatDue(iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A1 — Heutige Übung (CadenceInstance): Tages-Tracking mit optionaler Messnotiz.
|
||||||
|
*/
|
||||||
|
export function WorkTodayPracticePanel({
|
||||||
|
initiativeId,
|
||||||
|
recurringItems = [],
|
||||||
|
canManage = false,
|
||||||
|
onCompleted,
|
||||||
|
busy = false,
|
||||||
|
}) {
|
||||||
|
const [measurement, setMeasurement] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const activePractice = useMemo(() => {
|
||||||
|
return (recurringItems || []).find((item) => item.status === 'active')
|
||||||
|
}, [recurringItems])
|
||||||
|
|
||||||
|
const openInstance = activePractice?.open_cadence_instance
|
||||||
|
|
||||||
|
if (!activePractice) {
|
||||||
|
return (
|
||||||
|
<section className="card work-today-practice" id="today-practice">
|
||||||
|
<EmptyState message="Keine aktive Übung — Starter-Kit oder Stufenwechsel prüfen." />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canManage || submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await completeRecurringPractice(activePractice.id, {
|
||||||
|
measurement_note: measurement.trim() || undefined,
|
||||||
|
})
|
||||||
|
setMeasurement('')
|
||||||
|
await onCompleted?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card work-today-practice" id="today-practice">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Heutige Übung</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Tages-Tracking — erledigt markieren, optional Messwert notieren (z. B. Grad, Wiederholungen).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="work-today-practice__focus">
|
||||||
|
<strong>{activePractice.title}</strong>
|
||||||
|
{openInstance?.due_at && (
|
||||||
|
<p className="muted list-item-sub">Fällig: {formatDue(openInstance.due_at)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<form className="inline-form-block work-today-practice__form" onSubmit={handleComplete}>
|
||||||
|
<label>
|
||||||
|
Messwert / Notiz (optional)
|
||||||
|
<input
|
||||||
|
value={measurement}
|
||||||
|
onChange={(e) => setMeasurement(e.target.value)}
|
||||||
|
placeholder="z. B. 45° Spagat, 3×30 Sek."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting || busy}>
|
||||||
|
{submitting ? 'Speichern …' : 'Heute erledigt'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="muted">Keine Berechtigung zum Markieren der Übung.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ export function InitiativeCompositionSurface({
|
||||||
uiFeatures: ops.uiFeatures,
|
uiFeatures: ops.uiFeatures,
|
||||||
dataSlices: ops.dataSlices,
|
dataSlices: ops.dataSlices,
|
||||||
onAcceptBacklogProposal,
|
onAcceptBacklogProposal,
|
||||||
|
refreshOm: ops.refreshOm,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
ops.initiativeId,
|
ops.initiativeId,
|
||||||
|
|
@ -53,6 +54,7 @@ export function InitiativeCompositionSurface({
|
||||||
ops.uiFeatures,
|
ops.uiFeatures,
|
||||||
ops.dataSlices,
|
ops.dataSlices,
|
||||||
onAcceptBacklogProposal,
|
onAcceptBacklogProposal,
|
||||||
|
ops.refreshOm,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,16 @@ export const COMPOSITION_PROVIDERS = [
|
||||||
scopeTypes: ['initiative'],
|
scopeTypes: ['initiative'],
|
||||||
order: 16,
|
order: 16,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'work.practice.today',
|
||||||
|
kind: 'steering_element',
|
||||||
|
steeringElement: 'recurring_rhythm',
|
||||||
|
slotKeys: ['work.today.primary'],
|
||||||
|
componentKey: 'WorkTodayPracticePanel',
|
||||||
|
requiresCapability: 'kairo.recurring.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'steering.next_action',
|
key: 'steering.next_action',
|
||||||
kind: 'steering_element',
|
kind: 'steering_element',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
|
||||||
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
||||||
import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
|
import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
|
||||||
import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
|
import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
|
||||||
|
import { WorkTodayPracticePanel } from '../components/WorkTodayPracticePanel.jsx'
|
||||||
import { WorkActionsPanel } from './WorkActionsPanel.jsx'
|
import { WorkActionsPanel } from './WorkActionsPanel.jsx'
|
||||||
|
|
||||||
/** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */
|
/** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */
|
||||||
|
|
@ -45,6 +46,7 @@ export const PROVIDER_COMPONENTS = {
|
||||||
RoadblockersStrip,
|
RoadblockersStrip,
|
||||||
WidgetHost,
|
WidgetHost,
|
||||||
WorkActionsPanel,
|
WorkActionsPanel,
|
||||||
|
WorkTodayPracticePanel,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,18 @@ export function buildProviderProps(provider, input) {
|
||||||
initiativeId: ops.initiativeId,
|
initiativeId: ops.initiativeId,
|
||||||
recurringItems: ops.recurringItems || [],
|
recurringItems: ops.recurringItems || [],
|
||||||
}
|
}
|
||||||
|
case 'WorkTodayPracticePanel':
|
||||||
|
return {
|
||||||
|
initiativeId: ops.initiativeId,
|
||||||
|
recurringItems: ops.recurringItems || [],
|
||||||
|
canManage: capabilities.has('kairo.recurring.manage'),
|
||||||
|
busy: ops.formBusy,
|
||||||
|
onCompleted: async () => {
|
||||||
|
if (typeof ops.refreshOm === 'function') {
|
||||||
|
await ops.refreshOm(['recurring', 'steering_snapshot'])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
case 'NextActionWidget': {
|
case 'NextActionWidget': {
|
||||||
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
|
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { COMPOSITION_PROVIDERS } from './compositionProviders.js'
|
import { COMPOSITION_PROVIDERS } from './compositionProviders.js'
|
||||||
import {
|
import {
|
||||||
resolveSteeringComposition,
|
resolveSteeringComposition,
|
||||||
|
|
@ -265,19 +265,39 @@ describe('resolveSteeringComposition', () => {
|
||||||
const result = resolveSteeringComposition({
|
const result = resolveSteeringComposition({
|
||||||
surfaceKey: 'work.today',
|
surfaceKey: 'work.today',
|
||||||
scope: 'initiative',
|
scope: 'initiative',
|
||||||
capabilities: CAPS,
|
capabilities: new Set([...CAPS, 'kairo.recurring.read']),
|
||||||
steeringElements: ['next_action_primary', 'critical_path'],
|
steeringElements: ['next_action_primary', 'recurring_rhythm', 'critical_path'],
|
||||||
steeringSnapshot: { counts: {}, next_actions: [{ kind: 'action', title: 'X' }] },
|
steeringSnapshot: { counts: {}, next_actions: [{ kind: 'action', title: 'X' }] },
|
||||||
opsContext: { initiativeId: 'init-1' },
|
opsContext: { initiativeId: 'init-1' },
|
||||||
})
|
})
|
||||||
expect(result.slots['work.today.primary']?.some((p) => p.key === 'steering.next_action')).toBe(
|
expect(result.slots['work.today.primary']?.some((p) => p.key === 'steering.next_action')).toBe(
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
expect(result.slots['work.today.primary']?.some((p) => p.key === 'work.practice.today')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
expect(result.slots['work.today.actions']?.some((p) => p.key === 'work.actions.today')).toBe(
|
expect(result.slots['work.today.actions']?.some((p) => p.key === 'work.actions.today')).toBe(
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('buildProviderProps wires WorkTodayPracticePanel complete refresh', async () => {
|
||||||
|
const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'work.practice.today')
|
||||||
|
const refreshOm = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const props = buildProviderProps(provider, {
|
||||||
|
opsContext: {
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
recurringItems: [{ id: 'rec-1', status: 'active', title: 'Spagat' }],
|
||||||
|
formBusy: false,
|
||||||
|
refreshOm,
|
||||||
|
},
|
||||||
|
capabilities: new Set([...CAPS, 'kairo.recurring.read', 'kairo.recurring.manage']),
|
||||||
|
})
|
||||||
|
expect(props.canManage).toBe(true)
|
||||||
|
await props.onCompleted()
|
||||||
|
expect(refreshOm).toHaveBeenCalledWith(['recurring', 'steering_snapshot'])
|
||||||
|
})
|
||||||
|
|
||||||
it('activates sprint backlog provider when active work cycle', () => {
|
it('activates sprint backlog provider when active work cycle', () => {
|
||||||
const result = resolveSteeringComposition({
|
const result = resolveSteeringComposition({
|
||||||
surfaceKey: 'work.sprint',
|
surfaceKey: 'work.sprint',
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ const ARCHETYPE_UI = {
|
||||||
planDefaultRoute: '/plan/gates',
|
planDefaultRoute: '/plan/gates',
|
||||||
workDefaultRoute: '/work/today',
|
workDefaultRoute: '/work/today',
|
||||||
controlDefaultRoute: '/control/status',
|
controlDefaultRoute: '/control/status',
|
||||||
planOutlineKeys: ['profile', 'gates', 'inbox', 'work'],
|
planOutlineKeys: ['profile', 'gates', 'work'],
|
||||||
workNavKeys: ['today', 'mine', 'sprint'],
|
workNavKeys: ['today', 'mine', 'sprint'],
|
||||||
},
|
},
|
||||||
'initiative.program': {
|
'initiative.program': {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,17 @@ describe('methodUiDefaults', () => {
|
||||||
expect(resolvePlanOutlineNodes({}).length).toBe(6)
|
expect(resolvePlanOutlineNodes({}).length).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maturity archetype omits inbox from plan outline', () => {
|
||||||
|
const ui = resolveMethodUiDefaults({ archetypeKey: 'initiative.maturity_journey' })
|
||||||
|
expect(ui.planDefaultRoute).toBe('/plan/gates')
|
||||||
|
expect(ui.planOutlineKeys).toEqual(['profile', 'gates', 'work'])
|
||||||
|
expect(resolvePlanOutlineNodes({ archetypeKey: 'initiative.maturity_journey' }).map((n) => n.key)).toEqual([
|
||||||
|
'profile',
|
||||||
|
'gates',
|
||||||
|
'work',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves active process step from pathname', () => {
|
it('resolves active process step from pathname', () => {
|
||||||
const steps = resolveProcessSteps({ archetypeKey: 'initiative.product' })
|
const steps = resolveProcessSteps({ archetypeKey: 'initiative.product' })
|
||||||
expect(resolveActiveProcessStepKey('/plan/inbox', steps)).toBe('inbox')
|
expect(resolveActiveProcessStepKey('/plan/inbox', steps)).toBe('inbox')
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
import { actionPath, scopedPath } from '../utils/routes.js'
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
import { formatPortfolioRank } from '../utils/portfolioRank.js'
|
import { formatPortfolioRank } from '../utils/portfolioRank.js'
|
||||||
|
|
||||||
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
|
||||||
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
|
const { hrefWithScope } = useProgramScope()
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
|
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
|
||||||
|
|
@ -17,6 +21,12 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionLinkForItem(item) {
|
function actionLinkForItem(item) {
|
||||||
|
if (item.recurring_id && item.initiative_id) {
|
||||||
|
return {
|
||||||
|
to: `${hrefWithScope('/work/today', { initiativeId: item.initiative_id })}#today-practice`,
|
||||||
|
label: 'Übung ausführen',
|
||||||
|
}
|
||||||
|
}
|
||||||
if (item.action_id) {
|
if (item.action_id) {
|
||||||
return { to: actionPath(item.action_id), label: 'Arbeitspaket' }
|
return { to: actionPath(item.action_id), label: 'Arbeitspaket' }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user