AP0.10b: Steering UI Minimum — Next Actions, Heute, Snapshot v2
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m13s
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 15s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m13s
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 15s
Workspace beantwortet die Leitfrage mit prominenten Next-Action- und Heute-Widgets sowie erweitertem Steuerungszustand pro Vorhaben. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6ea663b4c4
commit
451d62ed46
12
README.md
12
README.md
|
|
@ -242,6 +242,18 @@ Operating Model Integration (AP0.10, Schema weiter `008`):
|
||||||
|
|
||||||
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||||
|
|
||||||
|
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
|
||||||
|
|
||||||
|
| Endpoint | Methode | Capability | Beschreibung |
|
||||||
|
|----------|---------|------------|--------------|
|
||||||
|
| `/api/workspace/actions/today` | GET | `kairo.workspace.read` | Meine Maßnahmen heute (überfällig zuerst) |
|
||||||
|
|
||||||
|
**Workspace-Widgets:** Nächste sinnvolle Schritte, Heute, Aufmerksamkeit (erweitert)
|
||||||
|
|
||||||
|
**Steuerungszustand v2:** Meilenstein-Horizont, Next Actions pro Vorhaben, Phase-Klartext
|
||||||
|
|
||||||
|
Version nach AP0.10b: **`0.10.1-ap0.10b`**
|
||||||
|
|
||||||
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,37 @@ def _list_assigned_actions(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
statuses: list[str],
|
statuses: list[str],
|
||||||
|
order_today: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
order_clause = """
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN a.due_at IS NOT NULL AND a.due_at < NOW() THEN 0 ELSE 1 END,
|
||||||
|
CASE a.status WHEN 'blocked' THEN 0 WHEN 'review_required' THEN 1 ELSE 2 END,
|
||||||
|
CASE a.priority
|
||||||
|
WHEN 'high' THEN 0
|
||||||
|
WHEN 'normal' THEN 1
|
||||||
|
WHEN 'low' THEN 2
|
||||||
|
END,
|
||||||
|
a.due_at ASC NULLS LAST,
|
||||||
|
a.updated_at DESC
|
||||||
|
""" if order_today else """
|
||||||
|
ORDER BY
|
||||||
|
CASE a.priority
|
||||||
|
WHEN 'high' THEN 0
|
||||||
|
WHEN 'normal' THEN 1
|
||||||
|
WHEN 'low' THEN 2
|
||||||
|
END,
|
||||||
|
a.updated_at DESC
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
||||||
a.status, a.priority, a.created_at, a.updated_at,
|
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
|
||||||
i.title AS initiative_title
|
i.title AS initiative_title,
|
||||||
|
(a.due_at IS NOT NULL AND a.due_at < NOW()) AS is_overdue
|
||||||
FROM actions a
|
FROM actions a
|
||||||
JOIN action_assignments aa
|
JOIN action_assignments aa
|
||||||
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
|
@ -39,17 +61,13 @@ def _list_assigned_actions(
|
||||||
WHERE a.tenant_id = %s
|
WHERE a.tenant_id = %s
|
||||||
AND aa.actor_id = %s
|
AND aa.actor_id = %s
|
||||||
AND a.status = ANY(%s)
|
AND a.status = ANY(%s)
|
||||||
ORDER BY
|
{order_clause}
|
||||||
CASE a.priority
|
|
||||||
WHEN 'high' THEN 0
|
|
||||||
WHEN 'normal' THEN 1
|
|
||||||
WHEN 'low' THEN 2
|
|
||||||
END,
|
|
||||||
a.updated_at DESC
|
|
||||||
""",
|
""",
|
||||||
(tenant_id, actor_id, statuses),
|
(tenant_id, actor_id, statuses),
|
||||||
)
|
)
|
||||||
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||||
|
for action in actions:
|
||||||
|
action["is_overdue"] = bool(action.pop("is_overdue", False))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _attach_assignments(actions, tenant_id=tenant_id)
|
return _attach_assignments(actions, tenant_id=tenant_id)
|
||||||
|
|
@ -83,3 +101,19 @@ def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
statuses=list(OPEN_ACTION_STATUSES),
|
statuses=list(OPEN_ACTION_STATUSES),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_today_actions(ctx: TenantContext, *, limit: int = 15) -> list[dict[str, Any]]:
|
||||||
|
"""Maßnahmen für heute — überfällig zuerst, dann blockiert/review, dann Priorität."""
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 50:
|
||||||
|
limit = 50
|
||||||
|
actor_id = _require_actor(ctx)
|
||||||
|
actions = _list_assigned_actions(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_id=actor_id,
|
||||||
|
statuses=["open", "ready", "in_progress", "blocked", "review_required"],
|
||||||
|
order_today=True,
|
||||||
|
)
|
||||||
|
return actions[:limit]
|
||||||
|
|
|
||||||
|
|
@ -567,3 +567,140 @@ def get_next_action_candidates(
|
||||||
return candidates[:limit]
|
return candidates[:limit]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_action_candidates_for_initiative(
|
||||||
|
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""NextActionCandidates für ein Vorhaben — tenant-scoped."""
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 20:
|
||||||
|
limit = 20
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'resolve_blocker' AS kind,
|
||||||
|
b.title AS title,
|
||||||
|
'Blocker klären oder Status aktualisieren' AS summary,
|
||||||
|
b.initiative_id,
|
||||||
|
b.action_id,
|
||||||
|
NULL::uuid AS backlog_item_id,
|
||||||
|
'blocker_open' AS reason_code,
|
||||||
|
'Blocker bearbeiten' AS recommended_action
|
||||||
|
FROM blockers b
|
||||||
|
WHERE b.tenant_id = %s AND b.initiative_id = %s
|
||||||
|
AND b.status IN ('open', 'in_progress')
|
||||||
|
ORDER BY b.updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id, limit),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
if item.get("action_id"):
|
||||||
|
item["action_id"] = str(item["action_id"])
|
||||||
|
candidates.append(item)
|
||||||
|
|
||||||
|
remaining = limit - len(candidates)
|
||||||
|
if remaining > 0:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'assign_action' AS kind,
|
||||||
|
a.title AS title,
|
||||||
|
'Actor zuweisen' AS summary,
|
||||||
|
a.initiative_id,
|
||||||
|
a.id AS action_id,
|
||||||
|
NULL::uuid AS backlog_item_id,
|
||||||
|
'action_unassigned' AS reason_code,
|
||||||
|
'Maßnahme zuweisen' AS recommended_action
|
||||||
|
FROM actions a
|
||||||
|
WHERE a.tenant_id = %s AND a.initiative_id = %s
|
||||||
|
AND a.status IN ('open', 'ready', 'in_progress')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM action_assignments aa
|
||||||
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
)
|
||||||
|
ORDER BY a.updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id, remaining),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
item["action_id"] = str(item["action_id"])
|
||||||
|
candidates.append(item)
|
||||||
|
|
||||||
|
remaining = limit - len(candidates)
|
||||||
|
if remaining > 0:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'convert_backlog' AS kind,
|
||||||
|
bi.title AS title,
|
||||||
|
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
|
||||||
|
bi.initiative_id,
|
||||||
|
NULL::uuid AS action_id,
|
||||||
|
bi.id AS backlog_item_id,
|
||||||
|
'backlog_accepted_not_converted' AS reason_code,
|
||||||
|
'In Maßnahme umwandeln' AS recommended_action
|
||||||
|
FROM backlog_items bi
|
||||||
|
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
|
||||||
|
AND bi.status = 'accepted'
|
||||||
|
AND bi.converted_action_id IS NULL
|
||||||
|
ORDER BY bi.updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id, remaining),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
item["backlog_item_id"] = str(item["backlog_item_id"])
|
||||||
|
candidates.append(item)
|
||||||
|
|
||||||
|
remaining = limit - len(candidates)
|
||||||
|
if remaining > 0:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM actions a
|
||||||
|
WHERE a.initiative_id = %s AND a.tenant_id = %s
|
||||||
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(initiative_id, ctx.tenant_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT title FROM initiatives
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(initiative_id, ctx.tenant_id),
|
||||||
|
)
|
||||||
|
init_row = cur.fetchone()
|
||||||
|
if init_row:
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"kind": "create_action",
|
||||||
|
"title": init_row["title"],
|
||||||
|
"summary": "Nächste Maßnahme für Vorhaben anlegen",
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"action_id": None,
|
||||||
|
"backlog_item_id": None,
|
||||||
|
"reason_code": "initiative_no_open_action",
|
||||||
|
"recommended_action": "Maßnahme anlegen",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates[:limit]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from typing import Any, Literal, Optional
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
|
from data_layer.attention import get_next_action_candidates_for_initiative
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
@ -296,12 +297,27 @@ def get_initiative_steering_snapshot(
|
||||||
planned_reviews_due=planned_reviews_due,
|
planned_reviews_due=planned_reviews_due,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
upcoming_milestones = sorted(
|
||||||
|
[
|
||||||
|
m
|
||||||
|
for m in milestones
|
||||||
|
if m["status"] in ("planned", "active", "at_risk")
|
||||||
|
],
|
||||||
|
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
||||||
|
)[:5]
|
||||||
|
|
||||||
|
next_actions = get_next_action_candidates_for_initiative(
|
||||||
|
ctx, initiative_id=initiative_id, limit=5
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"initiative_id": initiative_id,
|
"initiative_id": initiative_id,
|
||||||
"initiative_title": initiative["title"],
|
"initiative_title": initiative["title"],
|
||||||
"initiative_status": initiative["status"],
|
"initiative_status": initiative["status"],
|
||||||
"operating_phase": phase,
|
"operating_phase": phase,
|
||||||
"phase_signals": phase_signals,
|
"phase_signals": phase_signals,
|
||||||
|
"upcoming_milestones": upcoming_milestones,
|
||||||
|
"next_actions": next_actions,
|
||||||
"counts": {
|
"counts": {
|
||||||
"actions_open": open_actions,
|
"actions_open": open_actions,
|
||||||
"actions_blocked": blocked_actions,
|
"actions_blocked": blocked_actions,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,17 @@ def workspace_blocked_actions(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actions/today")
|
||||||
|
def workspace_today_actions(
|
||||||
|
limit: Optional[int] = Query(default=15, ge=1, le=50),
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return dl_actions.get_my_today_actions(_require_actor_ctx(ctx), limit=limit or 15)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/initiatives/active")
|
@router.get("/initiatives/active")
|
||||||
def workspace_active_initiatives(
|
def workspace_active_initiatives(
|
||||||
limit: Optional[int] = Query(default=None, ge=1, le=100),
|
limit: Optional[int] = Query(default=None, ge=1, le=100),
|
||||||
|
|
|
||||||
73
backend/tests/test_ap10b_steering_ui.py
Normal file
73
backend/tests/test_ap10b_steering_ui.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""AP0.10b — Steering UI Minimum tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import (
|
||||||
|
_auth,
|
||||||
|
_create_action,
|
||||||
|
_create_initiative,
|
||||||
|
_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_next_actions(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/blockers",
|
||||||
|
json={"title": "Blocker für NextAction"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/next-actions?limit=5", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
items = res.json()
|
||||||
|
assert len(items) >= 1
|
||||||
|
assert items[0]["recommended_action"]
|
||||||
|
assert items[0]["initiative_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_today_actions(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
action = _create_action(
|
||||||
|
client, token, initiative_id, assigned_actor_ids=[user["actor_id"]]
|
||||||
|
).json()
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{action['id']}",
|
||||||
|
json={"due_at": past},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/actions/today", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
items = res.json()
|
||||||
|
assert len(items) >= 1
|
||||||
|
assert items[0]["is_overdue"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_steering_snapshot_v2_fields(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/milestones",
|
||||||
|
json={"title": "M1", "status": "planned", "target_date": "2026-12-31"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
data = snap.json()
|
||||||
|
assert "upcoming_milestones" in data
|
||||||
|
assert "next_actions" in data
|
||||||
|
assert len(data["upcoming_milestones"]) >= 1
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.10.0-ap0.10"
|
APP_VERSION = "0.10.1-ap0.10b"
|
||||||
DB_SCHEMA_VERSION = "008"
|
DB_SCHEMA_VERSION = "008"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
189
docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md
Normal file
189
docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
# Kairo — MVP Usability Recovery Plan
|
||||||
|
|
||||||
|
**Status:** Planungsdokument (Product Direction)
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Auslöser:** AP0.8–0.10 liefern Entitäten, aber **keinen täglich nutzbaren Steuerungswert** — schlechter als To-do-/PM-Tools für den Alltag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Diagnose
|
||||||
|
|
||||||
|
| Was Nutzer erwarten | Was Kairo heute liefert |
|
||||||
|
|---------------------|-------------------------|
|
||||||
|
| „Was mache ich jetzt?“ | Parallele Listen, keine Priorität in der UI |
|
||||||
|
| „Wo steht das Vorhaben?“ | Heuristik-Badge ohne Lifecycle-Modell |
|
||||||
|
| „Was kommt als Nächstes?“ | NextAction-API existiert, **kein Widget** |
|
||||||
|
| „Was blockiert?“ | Blocker-Liste, kaum mit Maßnahme verbunden |
|
||||||
|
| Meilensteine / Fristen | CRUD-Sektion, kein Horizont |
|
||||||
|
| Weniger als Todoist/Linear | **Ja** — mehr Klicks, weniger Antworten |
|
||||||
|
|
||||||
|
Das Product Reset (`Kairo_Product_Definition_and_MVP_Reset_v0.1.md` §4) warnt genau davor:
|
||||||
|
*Initiative → Action allein ist weniger als To-do-Listen und verfehlt den Program-Director-Anspruch.*
|
||||||
|
|
||||||
|
**Technisch:** Foundation + OM-Inventar ✓
|
||||||
|
**Produkt:** Steuerungsantwort auf die Leitfrage ✗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MVP-Nutzbarkeits-Bar (Definition of Done)
|
||||||
|
|
||||||
|
Kairo ist **minimal täglich nutzbar**, wenn ein Nutzer ohne Erklärung:
|
||||||
|
|
||||||
|
1. **In ≤30 Sekunden** seine nächste sinnvolle Maßnahme findet (Workspace).
|
||||||
|
2. **Pro Vorhaben** sieht: Phase (grober Zustand), nächster Meilenstein, größtes Hindernis, empfohlene Aktion.
|
||||||
|
3. **Backlog vs. Maßnahme** unterscheiden und Backlog in Maßnahme überführen kann.
|
||||||
|
4. **Blocker** erkennt, die echte Arbeit stoppen (nicht nur als Liste).
|
||||||
|
5. Das **ohne** Prompt/KI/MCP/Method Designer schafft.
|
||||||
|
|
||||||
|
Das ist **nicht** Feature-Parität mit Todoist — sondern **Steuerungsmehrwert** gegenüber reinen Listen:
|
||||||
|
|
||||||
|
> Nicht nur *was* existiert, sondern *was jetzt wirkt*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Strategische Korrektur der Roadmap
|
||||||
|
|
||||||
|
Die bisherige Reihenfolge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AP0.8 Entitäten → AP0.9 Entitäten → AP0.10 Validation → Sprint 1 Steering Core
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Validation auf CRUD-Listen liefert nur „fehlt alles“ — ohne vorherige **Steering Surface**.
|
||||||
|
|
||||||
|
**Korrigierte Reihenfolge:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
AP0.10b Steering UI Minimum ← zuerst (Nutzen sichtbar)
|
||||||
|
AP0.10c Initiative Flow Minimum
|
||||||
|
AP0.10d MVP Validation (5 Vorhaben)
|
||||||
|
AP1.0 Steering Core Foundation (nur mit Validation-Ergebnis)
|
||||||
|
AP1.1 Method Registry minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regel bis AP1.0:** Keine neuen OM-Tabellen. Nur verbinden, anzeigen, orchestrieren, validieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Empfohlene Pakete
|
||||||
|
|
||||||
|
### AP0.10b — Steering UI Minimum (Priorität 1)
|
||||||
|
|
||||||
|
**Ziel:** Leitfrage im Workspace beantworten.
|
||||||
|
|
||||||
|
| # | Lieferung | Aufwand | Nutzen |
|
||||||
|
|---|-----------|---------|--------|
|
||||||
|
| 1 | **NextActionWidget** — `/api/workspace/next-actions`, Top 5, Link zu Vorhaben/Maßnahme | S | ★★★ |
|
||||||
|
| 2 | **Workspace „Heute“** — meine offenen Maßnahmen sortiert: überfällig → blockiert → high priority | S | ★★★ |
|
||||||
|
| 3 | **Steuerungszustand erweitern** — nächster Meilenstein (`target_date`), Top-Next-Action *für dieses Vorhaben*, Klartext statt `work_in_progress` | M | ★★★ |
|
||||||
|
| 4 | Attention-Widget: Kind + empfohlene Aktion lesbar | S | ★★ |
|
||||||
|
|
||||||
|
**Nicht-Scope:** Redesign, neue Entitäten, KI-Ranking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP0.10c — Initiative Flow Minimum (Priorität 2)
|
||||||
|
|
||||||
|
**Ziel:** Vorhaben-Detail beantwortet „Wo stehe ich?“ — nicht „Hier sind 8 Listen“.
|
||||||
|
|
||||||
|
| # | Lieferung | Aufwand | Nutzen |
|
||||||
|
|---|-----------|---------|--------|
|
||||||
|
| 1 | **Maßnahmen-zentrierte Ansicht** — Blocker/Evidence/Reviews unter der Maßnahme, nicht nur global | M | ★★★ |
|
||||||
|
| 2 | **Sektionen einklappen** — Backlog/Evidence/Decision/Recurring unter „Erweitert“ | S | ★★ |
|
||||||
|
| 3 | **Meilenstein-Horizont** — „Als Nächstes: …“ (planned/active, sortiert nach `target_date`) | S | ★★ |
|
||||||
|
| 4 | **Flows sichtbar machen** — Blocker→blockiert, Review→review_required→done (Hinweis in UI) | S | ★★ |
|
||||||
|
| 5 | Snapshot-API in UI: `next_actions[]`, `upcoming_milestones[]` Felder | M | ★★★ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP0.10d — MVP Validation (Priorität 3)
|
||||||
|
|
||||||
|
**Ziel:** Mit echten Vorhaben prüfen — dokumentiert, nicht Bauchgefühl.
|
||||||
|
|
||||||
|
Testvorhaben (Roadmap): Kairo, Gewaltschutzkurs, Karate, Familienorganisation, Server/Mindnet.
|
||||||
|
|
||||||
|
**Checkliste pro Vorhaben (15 Min):**
|
||||||
|
|
||||||
|
- [ ] Nächste Aktion in ≤30s gefunden?
|
||||||
|
- [ ] Blocker früh sichtbar?
|
||||||
|
- [ ] Meilenstein-Horizont hilfreich?
|
||||||
|
- [ ] Backlog→Maßnahme sinnvoll?
|
||||||
|
- [ ] Besser als Todo-Tool für *dieses* Vorhaben? Wenn nein: was fehlt?
|
||||||
|
|
||||||
|
**Output:** `Sprint0_AP0_10_Validation_Report_v0.1.md` + Go/No-Go für AP1.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP1.0 — Steering Core Foundation (Priorität 4, nach Validation)
|
||||||
|
|
||||||
|
**Ziel:** Heuristik durch echte Steuerungsstruktur ersetzen — schmal starten.
|
||||||
|
|
||||||
|
| Baustein | Minimal-Inhalt |
|
||||||
|
|----------|----------------|
|
||||||
|
| `SteeringContext` | `initiative_id`, `method_key` (default `generic_program`), `lifecycle_step` |
|
||||||
|
| Lifecycle | 5 Schritte: `intake`, `planning`, `execution`, `review`, `closure` |
|
||||||
|
| `operating_phase` | Liest aus `lifecycle_step`, nicht nur Zähler |
|
||||||
|
| Hooks (stub) | `on_blocker_open`, `on_next_action_requested` — Logging only |
|
||||||
|
| Migration | `009_steering_context.sql` |
|
||||||
|
|
||||||
|
**Nicht in AP1.0:** Method Designer, Workflow Runtime, Dependencies, Sprints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP1.1 — Method Registry minimal (Priorität 5)
|
||||||
|
|
||||||
|
- Eine eingebaute Methode: **`generic_program`**
|
||||||
|
- Signal Rules + NextAction Strategy für diese Methode
|
||||||
|
- Später: `software_delivery`, `content_development`, …
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was bewusst warten muss
|
||||||
|
|
||||||
|
| Thema | Warum später |
|
||||||
|
|-------|--------------|
|
||||||
|
| Project-Entität | Erst wenn Initiative-Steuerung trägt |
|
||||||
|
| Abhängigkeiten / Gantt | Steering Core + Validation |
|
||||||
|
| Sprints / Work Cycles | Method Registry |
|
||||||
|
| KI-Priorisierung | Regelbasiert muss zuerst gut genug sein |
|
||||||
|
| UI-Redesign | Erst wenn Informationsarchitektur steht |
|
||||||
|
| Weitere OM-Tabellen | Stop — Inventar reicht |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Erfolgskriterien (Go für Sprint 1)
|
||||||
|
|
||||||
|
Sprint 1 (Steering Core) startet nur wenn:
|
||||||
|
|
||||||
|
1. **≥3 von 5** Testvorhaben: „Hilft bei täglicher Steuerung“ = ja (schwach ja reicht nicht).
|
||||||
|
2. **Leitfrage** auf Workspace in User-Test beantwortbar ohne Schulung.
|
||||||
|
3. **NextActionWidget** und erweiterter Steuerungszustand deployed und genutzt.
|
||||||
|
4. Validation-Report dokumentiert verbleibende Lücken — keine stillen Annahmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Empfohlene unmittelbare Reihenfolge (nächste 2–3 Umsetzungssprints)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sprint A (AP0.10b) NextActionWidget + Workspace Heute + Steuerungszustand v2
|
||||||
|
Sprint B (AP0.10c) Maßnahmen-Hub + Meilenstein-Horizont + UI entrauschen
|
||||||
|
Sprint C (AP0.10d) Validation + Report + AP1.0 Auftrag schärfen
|
||||||
|
Sprint D (AP1.0) SteeringContext + Lifecycle stub
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sofort starten mit Sprint A** — größter Hebel, kleinster Scope, API existiert bereits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Einordnung: Kairo vs. To-do-Tool
|
||||||
|
|
||||||
|
Kairo muss **nicht** mehr Features als Todoist haben.
|
||||||
|
Es muss **eine Frage besser beantworten**:
|
||||||
|
|
||||||
|
> *Welcher nächste Schritt bringt dieses Vorhaben jetzt am wirksamsten voran — und warum?*
|
||||||
|
|
||||||
|
Solange diese Frage in der UI nicht zentral beantwortet wird, ist jeder OM-Ausbau wertlos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*v0.1 — Product Direction, ergänzt Corrected MVP Roadmap AP0.10*
|
||||||
31
docs/sprints/Sprint0_AP0_10b_Assignment_v0.1.md
Normal file
31
docs/sprints/Sprint0_AP0_10b_Assignment_v0.1.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# AP0.10b — Steering UI Minimum
|
||||||
|
## Implementierungsauftrag v0.1
|
||||||
|
|
||||||
|
**Status:** umgesetzt
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Version:** `0.10.1-ap0.10b` · Schema `008`
|
||||||
|
**Basis:** `Kairo_MVP_Usability_Recovery_Plan_v0.1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Leitfrage im Workspace beantworten — **ohne** neue Tabellen, **ohne** Steering Core.
|
||||||
|
|
||||||
|
## Geliefert
|
||||||
|
|
||||||
|
| # | Lieferung |
|
||||||
|
|---|-----------|
|
||||||
|
| 1 | **NextActionWidget** — Top 5, Link zum Vorhaben |
|
||||||
|
| 2 | **WorkspaceTodayWidget** — überfällig → blockiert → Priorität |
|
||||||
|
| 3 | **Steuerungszustand v2** — Phase-Klartext, Meilenstein-Horizont, Next Actions pro Vorhaben |
|
||||||
|
| 4 | **Attention** — Kind-Labels lesbar |
|
||||||
|
| 5 | API `/api/workspace/actions/today`, Snapshot-Felder `upcoming_milestones`, `next_actions` |
|
||||||
|
|
||||||
|
## Abnahme
|
||||||
|
|
||||||
|
Workspace zeigt oben Next Actions + Heute; Vorhaben-Detail zeigt erweiterten Steuerungszustand; pytest `test_ap10b_steering_ui.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Nächster Schritt: AP0.10c Initiative Flow Minimum*
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.10.0-ap0.10",
|
"version": "0.10.1-ap0.10b",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export function listWorkspaceBlockedActions() {
|
||||||
return apiFetch('/api/workspace/actions/blocked')
|
return apiFetch('/api/workspace/actions/blocked')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listWorkspaceTodayActions(limit = 15) {
|
||||||
|
return apiFetch(`/api/workspace/actions/today?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function listWorkspaceActiveInitiatives(limit = 5) {
|
export function listWorkspaceActiveInitiatives(limit = 5) {
|
||||||
return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
|
return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,20 @@
|
||||||
import { PHASE_LABELS } from '../constants/operating.js'
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
PHASE_LABELS,
|
||||||
|
PHASE_DESCRIPTIONS,
|
||||||
|
SIGNAL_LABELS,
|
||||||
|
NEXT_ACTION_KIND_LABELS,
|
||||||
|
} from '../constants/operating.js'
|
||||||
|
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE')
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -22,8 +38,15 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { operating_phase, phase_signals, counts } = snapshot
|
const {
|
||||||
|
operating_phase,
|
||||||
|
phase_signals,
|
||||||
|
counts,
|
||||||
|
upcoming_milestones = [],
|
||||||
|
next_actions = [],
|
||||||
|
} = snapshot
|
||||||
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
|
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
|
||||||
|
const phaseDesc = PHASE_DESCRIPTIONS[operating_phase] || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card steering-snapshot">
|
<section className="card steering-snapshot">
|
||||||
|
|
@ -31,11 +54,54 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
<h2>Steuerungszustand</h2>
|
<h2>Steuerungszustand</h2>
|
||||||
<span className="badge status-badge status-active">{phaseLabel}</span>
|
<span className="badge status-badge status-active">{phaseLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||||
|
|
||||||
{phase_signals?.length > 0 && (
|
{phase_signals?.length > 0 && (
|
||||||
<p className="muted snapshot-signals">
|
<p className="muted snapshot-signals">
|
||||||
Signale: {phase_signals.join(' · ')}
|
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{next_actions.length > 0 && (
|
||||||
|
<div className="steering-next-actions">
|
||||||
|
<h3>Nächste Schritte für dieses Vorhaben</h3>
|
||||||
|
<ol className="item-list compact-list">
|
||||||
|
{next_actions.map((item, i) => (
|
||||||
|
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<span className="next-action-rank">{i + 1}</span>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span className="muted list-item-sub">
|
||||||
|
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||||
|
{item.recommended_action ? ` — ${item.recommended_action}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upcoming_milestones.length > 0 && (
|
||||||
|
<div className="steering-milestones">
|
||||||
|
<h3>Meilenstein-Horizont</h3>
|
||||||
|
<ul className="item-list compact-list">
|
||||||
|
{upcoming_milestones.map((ms) => (
|
||||||
|
<li key={ms.id} className="list-item card-list-item compact">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{ms.title}</strong>
|
||||||
|
<span className="muted list-item-sub">
|
||||||
|
{MILESTONE_STATUS_LABELS[ms.status] || ms.status}
|
||||||
|
{ms.target_date ? ` · Ziel: ${formatDate(ms.target_date)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<dl className="snapshot-counts">
|
<dl className="snapshot-counts">
|
||||||
<div>
|
<div>
|
||||||
<dt>Offene Maßnahmen</dt>
|
<dt>Offene Maßnahmen</dt>
|
||||||
|
|
@ -62,8 +128,9 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p className="muted snapshot-hint">
|
<p className="muted snapshot-hint">
|
||||||
Kanonischer Operating Cycle — noch heuristisch, später methodengeführt über Steering Core.
|
Heuristischer Steuerungszustand — später methodengeführt über Steering Core.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,48 @@ export const PHASE_LABELS = {
|
||||||
adapt: 'Anpassung',
|
adapt: 'Anpassung',
|
||||||
closure: 'Abschluss',
|
closure: 'Abschluss',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PHASE_DESCRIPTIONS = {
|
||||||
|
capture: 'Ideen und Eingänge erfassen — noch wenig strukturierte Arbeit.',
|
||||||
|
triage: 'Backlog wartet auf Commit — noch keine offene nächste Maßnahme.',
|
||||||
|
structure: 'Vorhaben braucht Struktur — keine offene Maßnahme, Backlog leer.',
|
||||||
|
commit: 'Maßnahmen committen und zuweisen.',
|
||||||
|
execute: 'Aktive Umsetzung — offene Maßnahmen oder Blocker bearbeiten.',
|
||||||
|
verify: 'Nachweise und Ergebnisse belegen.',
|
||||||
|
review: 'Reviews oder Maßnahmen mit Review-Status klären.',
|
||||||
|
adapt: 'Anpassen — Backlog, Meilensteine oder Prioritäten prüfen.',
|
||||||
|
closure: 'Vorhaben abgeschlossen oder archiviert.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIGNAL_LABELS = {
|
||||||
|
initiative_terminal: 'Vorhaben abgeschlossen',
|
||||||
|
backlog_awaiting_commit: 'Backlog wartet auf Maßnahme',
|
||||||
|
no_open_work: 'Keine offene Arbeit',
|
||||||
|
actions_blocked: 'Maßnahmen blockiert',
|
||||||
|
open_blockers: 'Offene Blocker',
|
||||||
|
actions_need_review: 'Maßnahmen brauchen Review',
|
||||||
|
reviews_due: 'Reviews fällig',
|
||||||
|
work_in_progress: 'Arbeit in Umsetzung',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEXT_ACTION_KIND_LABELS = {
|
||||||
|
resolve_blocker: 'Blocker klären',
|
||||||
|
assign_action: 'Maßnahme zuweisen',
|
||||||
|
convert_backlog: 'Backlog umwandeln',
|
||||||
|
create_action: 'Maßnahme anlegen',
|
||||||
|
review_milestone: 'Meilenstein prüfen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ATTENTION_KIND_LABELS = {
|
||||||
|
blocked_action: 'Blockierte Maßnahme',
|
||||||
|
open_blocker: 'Offener Blocker',
|
||||||
|
high_priority_action: 'High Priority',
|
||||||
|
unassigned_action: 'Ohne Zuweisung',
|
||||||
|
initiative_without_next_action: 'Keine nächste Maßnahme',
|
||||||
|
stale_initiative: 'Inaktives Vorhaben',
|
||||||
|
milestone_at_risk: 'Meilenstein gefährdet',
|
||||||
|
overdue_action: 'Überfällig',
|
||||||
|
review_due: 'Review fällig',
|
||||||
|
recurring_due: 'Wiederkehrend fällig',
|
||||||
|
action_review_required: 'Review ausstehend',
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export function WorkspacePage() {
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Workspace</h1>
|
<h1>Workspace</h1>
|
||||||
<p className="page-lead">Dein Überblick über Vorhaben und Maßnahmen.</p>
|
<p className="page-lead">Was ist dein nächster sinnvoller Schritt?</p>
|
||||||
</div>
|
</div>
|
||||||
{hasCapability('kairo.initiative.manage') && (
|
{hasCapability('kairo.initiative.manage') && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ describe('widgetRegistry', () => {
|
||||||
expect(keys).toContain('kairo.blocked_actions')
|
expect(keys).toContain('kairo.blocked_actions')
|
||||||
expect(keys).toContain('kairo.tenant_context')
|
expect(keys).toContain('kairo.tenant_context')
|
||||||
expect(keys).toContain('kairo.workspace_summary')
|
expect(keys).toContain('kairo.workspace_summary')
|
||||||
|
expect(keys).toContain('kairo.next_actions')
|
||||||
|
expect(keys).toContain('kairo.workspace_today')
|
||||||
expect(keys).toContain('kairo.attention')
|
expect(keys).toContain('kairo.attention')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -20,7 +22,7 @@ describe('widgetRegistry', () => {
|
||||||
'kairo.initiative.read',
|
'kairo.initiative.read',
|
||||||
'kairo.workspace.read',
|
'kairo.workspace.read',
|
||||||
])
|
])
|
||||||
expect(all.length).toBe(6)
|
expect(all.length).toBe(8)
|
||||||
|
|
||||||
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
|
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
|
||||||
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
|
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
||||||
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
||||||
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
||||||
import { AttentionWidget } from '../widgets/AttentionWidget.jsx'
|
import { AttentionWidget } from '../widgets/AttentionWidget.jsx'
|
||||||
|
import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
|
||||||
|
import { WorkspaceTodayWidget } from '../widgets/WorkspaceTodayWidget.jsx'
|
||||||
|
|
||||||
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
||||||
|
|
||||||
|
|
@ -28,6 +30,24 @@ export const WIDGETS = [
|
||||||
defaultOrder: 0,
|
defaultOrder: 0,
|
||||||
component: TenantContextWidget,
|
component: TenantContextWidget,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.next_actions',
|
||||||
|
title: 'Nächste Schritte',
|
||||||
|
description: 'Regelbasierte NextAction-Empfehlungen',
|
||||||
|
area: 'workspace',
|
||||||
|
requiredCapability: 'kairo.workspace.read',
|
||||||
|
defaultOrder: 2,
|
||||||
|
component: NextActionWidget,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.workspace_today',
|
||||||
|
title: 'Heute',
|
||||||
|
description: 'Meine Maßnahmen — überfällig zuerst',
|
||||||
|
area: 'workspace',
|
||||||
|
requiredCapability: 'kairo.workspace.read',
|
||||||
|
defaultOrder: 4,
|
||||||
|
component: WorkspaceTodayWidget,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kairo.workspace_summary',
|
key: 'kairo.workspace_summary',
|
||||||
title: 'Überblick',
|
title: 'Überblick',
|
||||||
|
|
@ -52,7 +72,7 @@ export const WIDGETS = [
|
||||||
description: 'Offene Maßnahmen, die mir zugewiesen sind',
|
description: 'Offene Maßnahmen, die mir zugewiesen sind',
|
||||||
area: 'workspace',
|
area: 'workspace',
|
||||||
requiredCapability: 'kairo.action.read',
|
requiredCapability: 'kairo.action.read',
|
||||||
defaultOrder: 10,
|
defaultOrder: 25,
|
||||||
component: MyOpenActionsWidget,
|
component: MyOpenActionsWidget,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -61,7 +81,7 @@ export const WIDGETS = [
|
||||||
description: 'Mir zugewiesene blockierte Maßnahmen',
|
description: 'Mir zugewiesene blockierte Maßnahmen',
|
||||||
area: 'workspace',
|
area: 'workspace',
|
||||||
requiredCapability: 'kairo.action.read',
|
requiredCapability: 'kairo.action.read',
|
||||||
defaultOrder: 20,
|
defaultOrder: 35,
|
||||||
component: BlockedActionsWidget,
|
component: BlockedActionsWidget,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -70,7 +90,7 @@ export const WIDGETS = [
|
||||||
description: 'Aktive und pausierte Vorhaben im Tenant',
|
description: 'Aktive und pausierte Vorhaben im Tenant',
|
||||||
area: 'workspace',
|
area: 'workspace',
|
||||||
requiredCapability: 'kairo.initiative.read',
|
requiredCapability: 'kairo.initiative.read',
|
||||||
defaultOrder: 30,
|
defaultOrder: 40,
|
||||||
component: InitiativesWidget,
|
component: InitiativesWidget,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,55 @@
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.steering-phase-desc {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-next-actions,
|
||||||
|
.steering-milestones {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-next-actions h3,
|
||||||
|
.steering-milestones h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-list .list-item.compact {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-action-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-action-rank {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--jk-surface-raised, #f3f4f6);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-action-kind {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-action {
|
||||||
|
color: var(--jk-accent, #2563eb);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.action-links {
|
.action-links {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
|
||||||
|
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
||||||
|
|
||||||
const SEVERITY_LABELS = {
|
const SEVERITY_LABELS = {
|
||||||
critical: 'Kritisch',
|
critical: 'Kritisch',
|
||||||
warning: 'Warnung',
|
warning: 'Warnung',
|
||||||
|
|
@ -69,6 +71,9 @@ export function AttentionWidget() {
|
||||||
<span className={`attention-severity attention-severity--${item.severity}`}>
|
<span className={`attention-severity attention-severity--${item.severity}`}>
|
||||||
{SEVERITY_LABELS[item.severity] || item.severity}
|
{SEVERITY_LABELS[item.severity] || item.severity}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="muted list-item-sub attention-kind">
|
||||||
|
{ATTENTION_KIND_LABELS[item.kind] || item.kind}
|
||||||
|
</span>
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
78
frontend/src/widgets/NextActionWidget.jsx
Normal file
78
frontend/src/widgets/NextActionWidget.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { getNextActionCandidates } from '../api/attention.js'
|
||||||
|
import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js'
|
||||||
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
|
export function NextActionWidget() {
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getNextActionCandidates(5)
|
||||||
|
setItems(Array.isArray(data) ? data : [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetCard
|
||||||
|
title="Nächste sinnvolle Schritte"
|
||||||
|
subtitle="Was bringt deine Vorhaben jetzt am wirksamsten voran?"
|
||||||
|
className="widget-card--span-full"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
|
{!loading && !error && items.length === 0 && (
|
||||||
|
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
|
||||||
|
)}
|
||||||
|
{!loading && !error && items.length > 0 && (
|
||||||
|
<ol className="item-list next-action-list">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={`${item.kind}-${item.initiative_id}-${item.action_id || item.backlog_item_id || index}`} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<span className="next-action-rank">{index + 1}</span>
|
||||||
|
<span className="badge status-badge status-active next-action-kind">
|
||||||
|
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||||
|
</span>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
||||||
|
{item.recommended_action && (
|
||||||
|
<p className="list-item-sub recommended-action">
|
||||||
|
→ {item.recommended_action}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta">
|
||||||
|
{item.initiative_id && (
|
||||||
|
<Link to={`/initiatives/${item.initiative_id}`} className="btn btn-primary btn-sm">
|
||||||
|
Zum Vorhaben
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</WidgetCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
frontend/src/widgets/WorkspaceTodayWidget.jsx
Normal file
98
frontend/src/widgets/WorkspaceTodayWidget.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { listWorkspaceTodayActions } from '../api/workspace.js'
|
||||||
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
|
function formatDue(iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceTodayWidget() {
|
||||||
|
const [actions, setActions] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await listWorkspaceTodayActions(10)
|
||||||
|
setActions(Array.isArray(data) ? data : [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetCard
|
||||||
|
title="Heute"
|
||||||
|
subtitle="Deine Maßnahmen — überfällig und dringend zuerst"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
actions.length > 0 ? (
|
||||||
|
<Link to="/my-actions" className="link-inline">
|
||||||
|
Alle meine Maßnahmen →
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
|
{!loading && !error && actions.length === 0 && (
|
||||||
|
<EmptyState message="Keine Maßnahmen für heute — prüfe „Nächste sinnvolle Schritte“ oben." />
|
||||||
|
)}
|
||||||
|
{!loading && !error && actions.length > 0 && (
|
||||||
|
<ul className="item-list">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<li key={action.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
{action.is_overdue && (
|
||||||
|
<span className="badge status-badge status-blocked">Überfällig</span>
|
||||||
|
)}
|
||||||
|
<strong>{action.title}</strong>
|
||||||
|
{action.initiative_title && (
|
||||||
|
<span className="muted list-item-sub">{action.initiative_title}</span>
|
||||||
|
)}
|
||||||
|
{action.due_at && (
|
||||||
|
<span className="muted list-item-sub">Fällig: {formatDue(action.due_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta">
|
||||||
|
<StatusBadge status={action.status} />
|
||||||
|
<PriorityBadge priority={action.priority} />
|
||||||
|
<Link to={`/initiatives/${action.initiative_id}`} className="btn btn-secondary btn-sm">
|
||||||
|
Öffnen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</WidgetCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user