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

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:
Lars 2026-07-28 12:32:23 +02:00
parent 0baa5952bc
commit b7d88ce24a
25 changed files with 936 additions and 31 deletions

View File

@ -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",

View 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';

View File

@ -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,

View 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

View File

@ -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

View File

@ -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:

View 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")

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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 |

View File

@ -5,27 +5,32 @@
**Stand:** 2026-07-28 **Stand:** 2026-07-28
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §58 **Bezug:** `Kairo_MVP_Definition_v0.3.md` §58
**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 (35 Sätze):** **Kurzfazit (35 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).
--- ---

View 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` §58
**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` D4D6 | 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.34.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-0206 | 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.34.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 14.
### 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.*

View File

@ -0,0 +1,61 @@
# AP A1 — Minimum Usability Slice
## Auftrag v0.1
**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **Implementierung A1.1A1.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.*

View File

@ -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),
})
}

View File

@ -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"> <p className="maturity-stage-active muted">
Aktive Stufe: <strong>{activeStage.title}</strong> Aktive Stufe:{' '}
<Link to={gatePath(activeStage.id)} className="link-inline">
<strong>{activeStage.title}</strong>
</Link>
</p> </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">
<Link to={gatePath(stage.id)} className="list-item-link">
<strong>{stage.title}</strong> <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>

View 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>
)
}

View File

@ -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,
], ],
) )

View File

@ -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',

View File

@ -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,
} }
/** /**

View File

@ -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 {

View File

@ -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',

View File

@ -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': {

View File

@ -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')

View File

@ -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' }
} }