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",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
|
||||
"planOutlineKeys": ["profile", "gates", "work"],
|
||||
"workNavKeys": ["today", "mine", "sprint"],
|
||||
"dataSlices": [
|
||||
"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
|
||||
|
||||
|
||||
class RecurringCompleteRequest(BaseModel):
|
||||
measurement_note: Optional[str] = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
|
|
@ -73,6 +77,29 @@ def update_recurring_element(
|
|||
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)
|
||||
def delete_recurring_element(
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -111,10 +121,14 @@ def list_recurring_for_initiative(
|
|||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
rows = [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
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(
|
||||
*, tenant_id: str, recurring_id: str
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ def recurring_due_candidates(
|
|||
re.title AS title,
|
||||
'Wiederkehrendes Element fällig' AS summary,
|
||||
re.initiative_id,
|
||||
re.id AS recurring_id,
|
||||
NULL::uuid AS action_id,
|
||||
NULL::uuid AS backlog_item_id,
|
||||
'recurring_due' AS reason_code,
|
||||
'Rhythmus-Element bearbeiten' AS recommended_action
|
||||
'Übung heute erledigen' AS recommended_action
|
||||
FROM recurring_elements re
|
||||
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||
AND re.status = 'active'
|
||||
|
|
@ -40,6 +41,8 @@ def recurring_due_candidates(
|
|||
for row in cur.fetchall():
|
||||
item = dict(row)
|
||||
item["initiative_id"] = str(item["initiative_id"])
|
||||
if item.get("recurring_id"):
|
||||
item["recurring_id"] = str(item["recurring_id"])
|
||||
items.append(item)
|
||||
return items
|
||||
finally:
|
||||
|
|
@ -60,10 +63,11 @@ def active_recurring_candidates(
|
|||
re.title AS title,
|
||||
'Heutige Übung / Routine' AS summary,
|
||||
re.initiative_id,
|
||||
re.id AS recurring_id,
|
||||
NULL::uuid AS action_id,
|
||||
NULL::uuid AS backlog_item_id,
|
||||
'recurring_active' AS reason_code,
|
||||
'Routine ausführen' AS recommended_action
|
||||
'Übung heute erledigen' AS recommended_action
|
||||
FROM recurring_elements re
|
||||
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||
AND re.status = 'active'
|
||||
|
|
@ -76,6 +80,8 @@ def active_recurring_candidates(
|
|||
for row in cur.fetchall():
|
||||
item = dict(row)
|
||||
item["initiative_id"] = str(item["initiative_id"])
|
||||
if item.get("recurring_id"):
|
||||
item["recurring_id"] = str(item["recurring_id"])
|
||||
items.append(item)
|
||||
return items
|
||||
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()
|
||||
assert body["method_key"] == "maturity_progression"
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| maturity / sequential / queue / recurring Strategien | ✓ | AP2.0d; pytest + Deploy |
|
||||
| Referenz-Ausprägungen (Kumite, Buch, Kairo) | ◐ | Code-Registry; Nutzer wählt bei Anlage |
|
||||
| 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 |
|
||||
| Actor Service Token | ✓ | AP1.7c; Tenant-scoped, capability-gebunden |
|
||||
| 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 |
|
||||
| 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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
**Findings-Register (PO-Smoke + Architektur-Review):** `docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
|
||||
|
||||
---
|
||||
|
||||
## 8. Dokumentenpriorität
|
||||
|
|
|
|||
|
|
@ -228,6 +228,11 @@ Cadence im Detail am RecurringElement.
|
|||
|
||||
## 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 |
|
||||
|---------|------|------------|-------|
|
||||
| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz |
|
||||
|
|
|
|||
|
|
@ -4,28 +4,33 @@
|
|||
**Status:** Entwurf — vom Product Owner auszufüllen
|
||||
**Stand:** 2026-07-28
|
||||
**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 |
|
||||
|------|------|
|
||||
| **Kairo-Version / Deploy** | |
|
||||
| **Kairo-Version / Deploy** | `0.20.2-ap2.2d` (schema 025) |
|
||||
| **Dev-URL** | https://dev.kairo.jinkendo.de |
|
||||
| **Branch / Commit** | `develop` @ |
|
||||
| **Tester (PO)** | |
|
||||
| **Testdatum** | |
|
||||
| **Branch / Commit** | `develop` @ `0baa595` |
|
||||
| **Tester (PO)** | Lars |
|
||||
| **Testdatum** | 2026-07-28 |
|
||||
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
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):**
|
||||
|
||||
- **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)
|
||||
|
||||
**Initiative-ID / Titel:**
|
||||
**Archetyp:** `initiative.maturity_journey` · **Methode:** `maturity_progression`
|
||||
**Initiative-ID / Titel:** Spagat können (PO-Smoke 2026-07-28)
|
||||
**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)
|
||||
|
||||
|
|
@ -74,9 +93,9 @@
|
|||
|
||||
### 3.3 Leitfrage-Antwort (PO, ≤2 Min)
|
||||
|
||||
**Nächster Schritt:**
|
||||
**Warum:**
|
||||
**Stopuhr (Min):**
|
||||
**Nächster Schritt:** *Nicht sinnvoll bestimmbar* — kein klarer Tages-Handlungspfad (Übung erledigen + Fortschritt sehen).
|
||||
**Warum:** Ausführen zeigt AP statt Übung; kein Tracking; Gate-Fortschritt nicht steuerbar; UI unübersichtlich.
|
||||
**Stopuhr:** >2 Min / abgebrochen — **MVP §5.6 nicht erfüllt**
|
||||
|
||||
### 3.4 Anti-Patterns (§7)
|
||||
|
||||
|
|
@ -85,8 +104,7 @@
|
|||
| Flache Liste aller Übungs-Tasks als Hauptbild | ☐ 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) {
|
||||
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 { 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 { scopedPath } from '../utils/routes.js'
|
||||
|
||||
export function MaturityStagePanel({
|
||||
initiativeId,
|
||||
roadmapItems = [],
|
||||
embedded = true,
|
||||
}) {
|
||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||
|
||||
const stages = [...roadmapItems]
|
||||
.filter((item) => item.item_type === 'maturity_stage')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
|
||||
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 =
|
||||
stages.length === 0 ? (
|
||||
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
|
||||
) : (
|
||||
<>
|
||||
{activeStage && (
|
||||
<p className="maturity-stage-active muted">
|
||||
Aktive Stufe: <strong>{activeStage.title}</strong>
|
||||
</p>
|
||||
<>
|
||||
<p className="maturity-stage-active muted">
|
||||
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">
|
||||
{stages.map((stage, index) => (
|
||||
|
|
@ -36,7 +74,9 @@ export function MaturityStagePanel({
|
|||
>
|
||||
<span className="maturity-stage-item__index muted">{index + 1}</span>
|
||||
<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">
|
||||
{MILESTONE_STATUS_LABELS[stage.status] || stage.status}
|
||||
</span>
|
||||
|
|
@ -45,9 +85,13 @@ export function MaturityStagePanel({
|
|||
))}
|
||||
</ol>
|
||||
<p className="muted maturity-stage-hint">
|
||||
Stufen und Übungen unter{' '}
|
||||
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
|
||||
Kontrolle → Rhythmen & Journey
|
||||
Kriterien und Verify am{' '}
|
||||
<Link to={scopedPath('/plan/gates', { initiativeId })} className="link-inline">
|
||||
Plan → Stufen
|
||||
</Link>
|
||||
; Übung unter{' '}
|
||||
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
||||
Ausführen → Heute
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
@ -62,7 +106,7 @@ export function MaturityStagePanel({
|
|||
<div>
|
||||
<h2>Reifegrad-Stufen</h2>
|
||||
<p className="section-lead muted">
|
||||
Aktive Stufe und Fortschritt — Steuerungshorizont für Reifegrad-Vorhaben (A1).
|
||||
Aktive Stufe, Gate-Fortschritt und Orientierung — Steuerungshorizont (A1).
|
||||
</p>
|
||||
</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,
|
||||
dataSlices: ops.dataSlices,
|
||||
onAcceptBacklogProposal,
|
||||
refreshOm: ops.refreshOm,
|
||||
}),
|
||||
[
|
||||
ops.initiativeId,
|
||||
|
|
@ -53,6 +54,7 @@ export function InitiativeCompositionSurface({
|
|||
ops.uiFeatures,
|
||||
ops.dataSlices,
|
||||
onAcceptBacklogProposal,
|
||||
ops.refreshOm,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,16 @@ export const COMPOSITION_PROVIDERS = [
|
|||
scopeTypes: ['initiative'],
|
||||
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',
|
||||
kind: 'steering_element',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
|
|||
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
||||
import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
|
||||
import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
|
||||
import { WorkTodayPracticePanel } from '../components/WorkTodayPracticePanel.jsx'
|
||||
import { WorkActionsPanel } from './WorkActionsPanel.jsx'
|
||||
|
||||
/** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */
|
||||
|
|
@ -45,6 +46,7 @@ export const PROVIDER_COMPONENTS = {
|
|||
RoadblockersStrip,
|
||||
WidgetHost,
|
||||
WorkActionsPanel,
|
||||
WorkTodayPracticePanel,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -201,6 +201,18 @@ export function buildProviderProps(provider, input) {
|
|||
initiativeId: ops.initiativeId,
|
||||
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': {
|
||||
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
|
||||
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 {
|
||||
resolveSteeringComposition,
|
||||
|
|
@ -265,19 +265,39 @@ describe('resolveSteeringComposition', () => {
|
|||
const result = resolveSteeringComposition({
|
||||
surfaceKey: 'work.today',
|
||||
scope: 'initiative',
|
||||
capabilities: CAPS,
|
||||
steeringElements: ['next_action_primary', 'critical_path'],
|
||||
capabilities: new Set([...CAPS, 'kairo.recurring.read']),
|
||||
steeringElements: ['next_action_primary', 'recurring_rhythm', 'critical_path'],
|
||||
steeringSnapshot: { counts: {}, next_actions: [{ kind: 'action', title: 'X' }] },
|
||||
opsContext: { initiativeId: 'init-1' },
|
||||
})
|
||||
expect(result.slots['work.today.primary']?.some((p) => p.key === 'steering.next_action')).toBe(
|
||||
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(
|
||||
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', () => {
|
||||
const result = resolveSteeringComposition({
|
||||
surfaceKey: 'work.sprint',
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ const ARCHETYPE_UI = {
|
|||
planDefaultRoute: '/plan/gates',
|
||||
workDefaultRoute: '/work/today',
|
||||
controlDefaultRoute: '/control/status',
|
||||
planOutlineKeys: ['profile', 'gates', 'inbox', 'work'],
|
||||
planOutlineKeys: ['profile', 'gates', 'work'],
|
||||
workNavKeys: ['today', 'mine', 'sprint'],
|
||||
},
|
||||
'initiative.program': {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ describe('methodUiDefaults', () => {
|
|||
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', () => {
|
||||
const steps = resolveProcessSteps({ archetypeKey: 'initiative.product' })
|
||||
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 { formatPortfolioRank } from '../utils/portfolioRank.js'
|
||||
|
||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||
|
||||
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||
const { hrefWithScope } = useProgramScope()
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<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) {
|
||||
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) {
|
||||
return { to: actionPath(item.action_id), label: 'Arbeitspaket' }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user