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

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:
Lars 2026-07-05 15:55:05 +02:00
parent 6ea663b4c4
commit 451d62ed46
20 changed files with 892 additions and 21 deletions

View File

@ -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`
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`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`

View File

@ -22,15 +22,37 @@ def _list_assigned_actions(
tenant_id: str,
actor_id: str,
statuses: list[str],
order_today: bool = False,
) -> list[dict[str, Any]]:
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:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
f"""
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
a.status, a.priority, a.created_at, a.updated_at,
i.title AS initiative_title
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
i.title AS initiative_title,
(a.due_at IS NOT NULL AND a.due_at < NOW()) AS is_overdue
FROM actions a
JOIN action_assignments aa
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
AND aa.actor_id = %s
AND a.status = ANY(%s)
ORDER BY
CASE a.priority
WHEN 'high' THEN 0
WHEN 'normal' THEN 1
WHEN 'low' THEN 2
END,
a.updated_at DESC
{order_clause}
""",
(tenant_id, actor_id, statuses),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
for action in actions:
action["is_overdue"] = bool(action.pop("is_overdue", False))
finally:
conn.close()
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,
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]

View File

@ -567,3 +567,140 @@ def get_next_action_candidates(
return candidates[:limit]
finally:
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()

View File

@ -10,6 +10,7 @@ from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from data_layer.attention import get_next_action_candidates_for_initiative
from services.initiatives import get_initiative
from tenant_context import TenantContext
@ -296,12 +297,27 @@ def get_initiative_steering_snapshot(
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 {
"initiative_id": initiative_id,
"initiative_title": initiative["title"],
"initiative_status": initiative["status"],
"operating_phase": phase,
"phase_signals": phase_signals,
"upcoming_milestones": upcoming_milestones,
"next_actions": next_actions,
"counts": {
"actions_open": open_actions,
"actions_blocked": blocked_actions,

View File

@ -49,6 +49,17 @@ def workspace_blocked_actions(
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")
def workspace_active_initiatives(
limit: Optional[int] = Query(default=None, ge=1, le=100),

View 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

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.10.0-ap0.10"
APP_VERSION = "0.10.1-ap0.10b"
DB_SCHEMA_VERSION = "008"
APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,189 @@
# Kairo — MVP Usability Recovery Plan
**Status:** Planungsdokument (Product Direction)
**Stand:** 2026-07-05
**Auslöser:** AP0.80.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 23 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*

View 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*

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.10.0-ap0.10",
"version": "0.10.1-ap0.10b",
"private": true,
"type": "module",
"scripts": {

View File

@ -12,6 +12,10 @@ export function listWorkspaceBlockedActions() {
return apiFetch('/api/workspace/actions/blocked')
}
export function listWorkspaceTodayActions(limit = 15) {
return apiFetch(`/api/workspace/actions/today?limit=${limit}`)
}
export function listWorkspaceActiveInitiatives(limit = 5) {
return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
}

View File

@ -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 }) {
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 phaseDesc = PHASE_DESCRIPTIONS[operating_phase] || ''
return (
<section className="card steering-snapshot">
@ -31,11 +54,54 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
<h2>Steuerungszustand</h2>
<span className="badge status-badge status-active">{phaseLabel}</span>
</div>
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
{phase_signals?.length > 0 && (
<p className="muted snapshot-signals">
Signale: {phase_signals.join(' · ')}
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
</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">
<div>
<dt>Offene Maßnahmen</dt>
@ -62,8 +128,9 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
</div>
)}
</dl>
<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>
</section>
)

View File

@ -21,3 +21,48 @@ export const PHASE_LABELS = {
adapt: 'Anpassung',
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',
}

View File

@ -38,7 +38,7 @@ export function WorkspacePage() {
<header className="page-header">
<div>
<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>
{hasCapability('kairo.initiative.manage') && (
<button

View File

@ -11,6 +11,8 @@ describe('widgetRegistry', () => {
expect(keys).toContain('kairo.blocked_actions')
expect(keys).toContain('kairo.tenant_context')
expect(keys).toContain('kairo.workspace_summary')
expect(keys).toContain('kairo.next_actions')
expect(keys).toContain('kairo.workspace_today')
expect(keys).toContain('kairo.attention')
})
@ -20,7 +22,7 @@ describe('widgetRegistry', () => {
'kairo.initiative.read',
'kairo.workspace.read',
])
expect(all.length).toBe(6)
expect(all.length).toBe(8)
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)

View File

@ -4,6 +4,8 @@ import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.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 */
@ -28,6 +30,24 @@ export const WIDGETS = [
defaultOrder: 0,
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',
title: 'Überblick',
@ -52,7 +72,7 @@ export const WIDGETS = [
description: 'Offene Maßnahmen, die mir zugewiesen sind',
area: 'workspace',
requiredCapability: 'kairo.action.read',
defaultOrder: 10,
defaultOrder: 25,
component: MyOpenActionsWidget,
},
{
@ -61,7 +81,7 @@ export const WIDGETS = [
description: 'Mir zugewiesene blockierte Maßnahmen',
area: 'workspace',
requiredCapability: 'kairo.action.read',
defaultOrder: 20,
defaultOrder: 35,
component: BlockedActionsWidget,
},
{
@ -70,7 +90,7 @@ export const WIDGETS = [
description: 'Aktive und pausierte Vorhaben im Tenant',
area: 'workspace',
requiredCapability: 'kairo.initiative.read',
defaultOrder: 30,
defaultOrder: 40,
component: InitiativesWidget,
},
]

View File

@ -629,6 +629,55 @@
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 {
font-size: 0.85rem;
}

View File

@ -6,6 +6,8 @@ import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
const SEVERITY_LABELS = {
critical: 'Kritisch',
warning: 'Warnung',
@ -69,6 +71,9 @@ export function AttentionWidget() {
<span className={`attention-severity attention-severity--${item.severity}`}>
{SEVERITY_LABELS[item.severity] || item.severity}
</span>
<span className="muted list-item-sub attention-kind">
{ATTENTION_KIND_LABELS[item.kind] || item.kind}
</span>
<strong>{item.title}</strong>
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
</div>

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

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