AP1.8a: Portfolio-Rang im Cockpit mit Reorder und Next-Action-Sortierung.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m21s
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 21s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 09:39:49 +02:00
parent d7aefed0de
commit a19c6abd26
16 changed files with 426 additions and 34 deletions

View File

@ -22,10 +22,10 @@ def get_active_initiatives(
with conn.cursor(cursor_factory=RealDictCursor) as cur:
sql = """
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, owner_actor_id, created_at, updated_at
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
FROM initiatives
WHERE tenant_id = %s AND status IN ('active', 'paused')
ORDER BY updated_at DESC, title
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
"""
params: list[Any] = [ctx.tenant_id]
if limit is not None:

View File

@ -0,0 +1,24 @@
-- AP1.8a: Portfolio-Priorität (relatives Ranking im Tenant)
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS portfolio_rank INT;
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY tenant_id
ORDER BY updated_at DESC, title
) - 1 AS rn
FROM initiatives
)
UPDATE initiatives AS i
SET portfolio_rank = ranked.rn
FROM ranked
WHERE i.id = ranked.id AND i.portfolio_rank IS NULL;
CREATE INDEX IF NOT EXISTS idx_initiatives_tenant_portfolio_rank
ON initiatives (tenant_id, portfolio_rank);
COMMENT ON COLUMN initiatives.portfolio_rank IS
'Relatives Portfolio-Ranking (0 = höchste Aufmerksamkeit). AP1.8a';

View File

@ -11,11 +11,17 @@ from data_layer import attention as dl_attention
from data_layer import initiatives as dl_initiatives
from data_layer import workspace as dl_workspace
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from services import initiatives as initiative_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
class PortfolioReorderRequest(BaseModel):
initiative_ids: list[str] = Field(min_length=1)
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
if not ctx.actor_id:
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
@ -88,3 +94,18 @@ def workspace_next_actions(
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
):
return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)
@router.post("/portfolio/reorder")
def workspace_portfolio_reorder(
body: PortfolioReorderRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
try:
return initiative_service.reorder_portfolio(
tenant_id=ctx.tenant_id,
initiative_ids=body.initiative_ids,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -18,7 +18,7 @@ PRIORITIES = frozenset({"low", "normal", "high"})
_INITIATIVE_COLUMNS = """
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, owner_actor_id, created_at, updated_at
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
"""
@ -87,13 +87,22 @@ def create_initiative(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT COALESCE(MAX(portfolio_rank), -1) + 1 AS next_rank
FROM initiatives
WHERE tenant_id = %s
""",
(tenant_id,),
)
next_rank = int(cur.fetchone()["next_rank"])
cur.execute(
f"""
INSERT INTO initiatives (
tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, owner_actor_id
status, priority, portfolio_rank, owner_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_INITIATIVE_COLUMNS}
""",
(
@ -105,6 +114,7 @@ def create_initiative(
archetype_key,
status,
priority,
next_rank,
owner_actor_id,
),
)
@ -140,7 +150,7 @@ def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE tenant_id = %s
ORDER BY updated_at DESC, title
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
@ -294,3 +304,86 @@ def delete_initiative(
details={"initiative_id": initiative_id},
)
return deleted
def load_portfolio_rank_map(*, tenant_id: str) -> dict[str, Optional[int]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, portfolio_rank
FROM initiatives
WHERE tenant_id = %s
""",
(tenant_id,),
)
return {
str(row["id"]): row["portfolio_rank"]
for row in cur.fetchall()
}
finally:
conn.close()
def reorder_portfolio(
*,
tenant_id: str,
initiative_ids: list[str],
user_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Setzt portfolio_rank für alle übergebenen Initiativen (0 = höchste Prio)."""
if not initiative_ids:
raise ValueError("initiative_ids darf nicht leer sein")
unique_ids = list(dict.fromkeys(initiative_ids))
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id
FROM initiatives
WHERE tenant_id = %s
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
all_ids = [str(row["id"]) for row in cur.fetchall()]
all_set = set(all_ids)
if set(unique_ids) - all_set:
raise ValueError("Unbekannte Initiative im Tenant")
if len(unique_ids) != len(all_ids):
raise ValueError("Alle Initiativen des Tenants müssen enthalten sein")
for rank, initiative_id in enumerate(unique_ids):
cur.execute(
"""
UPDATE initiatives
SET portfolio_rank = %s, updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(rank, initiative_id, tenant_id),
)
cur.execute(
f"""
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE tenant_id = %s
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
rows = [_serialize_row(dict(row)) for row in cur.fetchall()]
conn.commit()
finally:
conn.close()
log_audit(
"portfolio.reordered",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_ids": unique_ids},
)
return rows

View File

@ -0,0 +1 @@
"""Portfolio steering helpers."""

View File

@ -0,0 +1,39 @@
"""Portfolio-Priorität — Sortierung für Next Action (AP1.8a)."""
from __future__ import annotations
from typing import Any, Optional
def portfolio_rank_sort_key(
initiative_id: Optional[str],
rank_map: dict[str, Optional[int]],
) -> tuple[int, str]:
"""Niedrigerer Rank = höhere Priorität; NULL ans Ende."""
if not initiative_id:
return (1_000_000, "")
rank = rank_map.get(initiative_id)
if rank is None:
return (999_999, initiative_id)
return (rank, initiative_id)
def sort_candidates_by_portfolio_rank(
candidates: list[dict[str, Any]],
rank_map: dict[str, Optional[int]],
) -> list[dict[str, Any]]:
"""Sortiert Next-Action-Kandidaten nach Portfolio-Rang."""
enriched = []
for candidate in candidates:
item = dict(candidate)
initiative_id = item.get("initiative_id")
if initiative_id:
item["portfolio_rank"] = rank_map.get(str(initiative_id))
enriched.append(item)
enriched.sort(
key=lambda c: portfolio_rank_sort_key(
str(c["initiative_id"]) if c.get("initiative_id") else None,
rank_map,
)
)
return enriched

View File

@ -632,11 +632,22 @@ def get_next_action_candidates(
item["initiative_id"] = str(item["initiative_id"])
candidates.append(item)
return candidates[:limit]
sorted_candidates = _apply_portfolio_rank_sort(ctx, candidates)
return sorted_candidates[:limit]
finally:
conn.close()
def _apply_portfolio_rank_sort(
ctx: TenantContext, candidates: list[dict[str, Any]]
) -> list[dict[str, Any]]:
from services.initiatives import load_portfolio_rank_map
from steering.portfolio.priority import sort_candidates_by_portfolio_rank
rank_map = load_portfolio_rank_map(tenant_id=ctx.tenant_id)
return sort_candidates_by_portfolio_rank(candidates, rank_map)
def get_next_action_candidates_for_initiative(
ctx: TenantContext, *, initiative_id: str, limit: int = 5
) -> list[dict[str, Any]]:

View File

@ -0,0 +1,47 @@
"""Unit tests for portfolio priority sorting — AP1.8a."""
from steering.portfolio.priority import sort_candidates_by_portfolio_rank
def test_sort_candidates_by_portfolio_rank():
candidates = [
{"kind": "a", "initiative_id": "low"},
{"kind": "b", "initiative_id": "high"},
{"kind": "c", "initiative_id": "mid"},
]
rank_map = {"high": 0, "mid": 1, "low": 2}
sorted_items = sort_candidates_by_portfolio_rank(candidates, rank_map)
assert [c["initiative_id"] for c in sorted_items] == ["high", "mid", "low"]
assert sorted_items[0]["portfolio_rank"] == 0
def test_null_rank_sorts_last():
candidates = [
{"initiative_id": "unknown"},
{"initiative_id": "ranked"},
]
rank_map = {"ranked": 0}
sorted_items = sort_candidates_by_portfolio_rank(candidates, rank_map)
assert sorted_items[0]["initiative_id"] == "ranked"
def test_portfolio_reorder_api(client):
from tests.test_initiatives_actions import _auth, _create_initiative, _login
from tests.factories import provision_user_in_tenant
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
first = _create_initiative(client, token, title="Alpha").json()
second = _create_initiative(client, token, title="Beta").json()
reorder = client.post(
"/api/workspace/portfolio/reorder",
json={"initiative_ids": [second["id"], first["id"]]},
headers=_auth(token),
)
assert reorder.status_code == 200
ordered = reorder.json()
assert ordered[0]["id"] == second["id"]
assert ordered[0]["portfolio_rank"] == 0
assert ordered[1]["portfolio_rank"] == 1

View File

@ -314,6 +314,11 @@ REST für Agent-Actors. **Nach AP1.5 + Gate-Verify tragfähig.**
**Version:** `0.17.0-ap1.8`
| Slice | Inhalt |
|-------|--------|
| **AP1.8a** | `portfolio_rank`, Cockpit-Reorder, Next-Action nach Portfolio-Rang |
| **AP1.8b** | Situativer Actor-Kontext, context_tags (deferred) |
---
## 4.1 Erledigte Pakete (Referenzdetail)

View File

@ -68,7 +68,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Method Registry | ◐ | generic_operating, product_milestone_driven; **Ziel AP2.0a:** program_delivery, continuous_product, … — siehe `ADP_Archetype_and_Method_Catalog_v0.2.md` |
| Archetyp-/Methoden-Katalog PO | ✓ | ADP v0.2 + MVP v0.3 freigegeben 2026-07-10 |
| NextActionCandidate | ◐ | API + Widget; nicht konfigurierbar auf beiden Ebenen |
| Portfolio-Priorität (Initiativen) | ✗ | |
| Portfolio-Priorität (Initiativen) | ◐ | AP1.8a: portfolio_rank, Cockpit-Reorder, Next-Action-Sort |
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
| AttentionItem | ◐ | |
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |

View File

@ -23,3 +23,10 @@ export function listWorkspaceActiveInitiatives(limit = 5) {
export function getActorWorkload() {
return apiFetch('/api/workspace/actors/workload')
}
export function reorderPortfolio(initiativeIds) {
return apiFetch('/api/workspace/portfolio/reorder', {
method: 'POST',
body: JSON.stringify({ initiative_ids: initiativeIds }),
})
}

View File

@ -779,6 +779,11 @@
margin-right: 0.35rem;
}
.next-action-portfolio-rank {
font-size: 0.8125rem;
margin-right: 0.35rem;
}
.next-action-kind {
font-size: 0.75rem;
margin-right: 0.35rem;
@ -1174,10 +1179,43 @@
}
.initiative-portfolio-card {
position: relative;
display: flex;
flex-direction: column;
padding: 0;
transition: border-color 0.15s ease;
}
.initiative-portfolio-card__reorder {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 1;
}
.initiative-portfolio-card__link {
display: block;
text-decoration: none;
color: inherit;
padding: 1rem;
color: inherit;
text-decoration: none;
}
.initiative-portfolio-card__link:hover {
text-decoration: none;
}
.initiative-portfolio-card__head {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-right: 3rem;
}
.initiative-portfolio-card__rank {
flex: 0 0 auto;
font-size: 0.75rem;
font-weight: 600;
color: var(--jk-accent, #2563eb);
}
.initiative-portfolio-card:hover {
@ -1185,7 +1223,7 @@
}
.initiative-portfolio-card-title {
margin: 0 0 0.35rem;
margin: 0;
font-size: 1rem;
}

View File

@ -0,0 +1,24 @@
/**
* Portfolio-Rang (AP1.8a) niedrigerer Rank = höhere Aufmerksamkeit.
*/
/**
* @template {{ id: string, portfolio_rank?: number | null }} T
* @param {T[]} items
*/
export function sortByPortfolioRank(items) {
return [...items].sort(
(a, b) =>
(a.portfolio_rank ?? Number.MAX_SAFE_INTEGER) -
(b.portfolio_rank ?? Number.MAX_SAFE_INTEGER) ||
String(a.id).localeCompare(String(b.id)),
)
}
/**
* @param {number | null | undefined} rank
*/
export function formatPortfolioRank(rank) {
if (rank == null || Number.isNaN(rank)) return null
return `#${rank + 1}`
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { formatPortfolioRank, sortByPortfolioRank } from './portfolioRank.js'
describe('portfolioRank', () => {
it('sorts by portfolio_rank ascending', () => {
const items = [
{ id: 'c', portfolio_rank: 2 },
{ id: 'a', portfolio_rank: 0 },
{ id: 'b', portfolio_rank: 1 },
]
expect(sortByPortfolioRank(items).map((i) => i.id)).toEqual(['a', 'b', 'c'])
})
it('formats rank as 1-based display', () => {
expect(formatPortfolioRank(0)).toBe('#1')
expect(formatPortfolioRank(null)).toBeNull()
})
})

View File

@ -1,20 +1,27 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { listInitiatives } from '../api/initiatives.js'
import { reorderPortfolio } 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 { ReorderControls } from '../components/ReorderControls.jsx'
import { scopedPath } from '../utils/routes.js'
import { formatPortfolioRank, sortByPortfolioRank } from '../utils/portfolioRank.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
import { useCapabilities } from '../hooks/useCapabilities.js'
export function InitiativePortfolioWidget() {
const { initiativeArchetypes } = useEntityArchetypes()
const { hasCapability } = useCapabilities()
const canManage = hasCapability('kairo.initiative.manage')
const [initiatives, setInitiatives] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const load = useCallback(async () => {
@ -34,13 +41,42 @@ export function InitiativePortfolioWidget() {
load()
}, [load])
const sortedInitiatives = useMemo(() => sortByPortfolioRank(initiatives), [initiatives])
async function handleMove(itemId, direction) {
const sorted = sortByPortfolioRank(initiatives)
const fromIndex = sorted.findIndex((item) => item.id === itemId)
if (fromIndex < 0) return
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
if (toIndex < 0 || toIndex >= sorted.length) return
const next = [...sorted]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
setBusy(true)
setError(null)
try {
const updated = await reorderPortfolio(next.map((item) => item.id))
setInitiatives(Array.isArray(updated) ? updated : next)
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<WidgetCard
title="Portfolio — alle Vorhaben"
subtitle="Querschnitt über deine Initiativen. Klick öffnet die Steuerungs-Übersicht."
subtitle={
canManage
? 'Nach Portfolio-Priorität sortiert — ↑↓ setzt die Aufmerksamkeits-Reihenfolge.'
: 'Querschnitt über deine Initiativen. Klick öffnet die Steuerungs-Übersicht.'
}
className="widget-card--span-full"
actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading || busy}>
Aktualisieren
</button>
}
@ -52,13 +88,33 @@ export function InitiativePortfolioWidget() {
)}
{!loading && !error && initiatives.length > 0 && (
<div className="initiative-portfolio-grid">
{initiatives.map((item) => (
{sortedInitiatives.map((item, index) => {
const rankLabel = formatPortfolioRank(item.portfolio_rank ?? index)
return (
<article key={item.id} className="initiative-portfolio-card card">
{canManage && (
<div className="initiative-portfolio-card__reorder">
<ReorderControls
itemId={item.id}
canMoveUp={index > 0}
canMoveDown={index < sortedInitiatives.length - 1}
onMove={(direction) => handleMove(item.id, direction)}
busy={busy}
/>
</div>
)}
<Link
key={item.id}
to={scopedPath('/control/status', { initiativeId: item.id })}
className="initiative-portfolio-card card"
className="initiative-portfolio-card__link"
>
<div className="initiative-portfolio-card__head">
{rankLabel && (
<span className="initiative-portfolio-card__rank" title="Portfolio-Priorität">
{rankLabel}
</span>
)}
<h3 className="initiative-portfolio-card-title">{item.title}</h3>
</div>
{item.goal && (
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
)}
@ -71,7 +127,9 @@ export function InitiativePortfolioWidget() {
<PriorityBadge priority={item.priority} />
</div>
</Link>
))}
</article>
)
})}
</div>
)}
</WidgetCard>

View File

@ -7,6 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx'
import { actionPath, scopedPath } from '../utils/routes.js'
import { formatPortfolioRank } from '../utils/portfolioRank.js'
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
if (items.length === 0) {
@ -43,6 +44,11 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
>
<div className="list-item-main">
<span className="next-action-rank">{index + 1}</span>
{item.portfolio_rank != null && (
<span className="next-action-portfolio-rank muted" title="Portfolio-Priorität">
{formatPortfolioRank(item.portfolio_rank)}
</span>
)}
<span className="badge status-badge status-active next-action-kind">
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
</span>