feat(A1): Activity Set am Gate-Detail — Übungen an Zielzustand binden
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 4m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s

Gate-Detail: Übungen anlegen mit Rhythmus, Gate aktivieren, Link zu Today. API: /roadmap-items/{id}/practices und roadmap_item_id auf Recurring-Create. Damit ist A1 ohne Starter-Kit manuell durchspielbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-08-03 19:36:50 +02:00
parent 17add13d9d
commit 87ecee5301
11 changed files with 473 additions and 5 deletions

View File

@ -132,6 +132,7 @@ class RecurringCreateRequest(BaseModel):
status: Literal["active", "paused", "ended"] = "active" status: Literal["active", "paused", "ended"] = "active"
interval_days: Optional[int] = Field(default=None, gt=0) interval_days: Optional[int] = Field(default=None, gt=0)
next_due_at: Optional[str] = None next_due_at: Optional[str] = None
roadmap_item_id: Optional[str] = None
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]: def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
@ -648,6 +649,7 @@ def create_initiative_recurring(
status=body.status, status=body.status,
interval_days=body.interval_days, interval_days=body.interval_days,
next_due_at=next_due_at, next_due_at=next_due_at,
roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id, user_id=ctx.user_id,
) )
except ValueError as exc: except ValueError as exc:

View File

@ -22,6 +22,8 @@ class RecurringUpdateRequest(BaseModel):
clear_interval_days: bool = False clear_interval_days: bool = False
next_due_at: Optional[str] = None next_due_at: Optional[str] = None
clear_next_due_at: bool = False clear_next_due_at: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item_id: bool = False
class RecurringCompleteRequest(BaseModel): class RecurringCompleteRequest(BaseModel):
@ -69,6 +71,8 @@ def update_recurring_element(
clear_interval_days=body.clear_interval_days, clear_interval_days=body.clear_interval_days,
next_due_at=next_due_at, next_due_at=next_due_at,
clear_next_due_at=body.clear_next_due_at, clear_next_due_at=body.clear_next_due_at,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item_id=body.clear_roadmap_item_id,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -87,6 +87,13 @@ class ReopenRequest(BaseModel):
reason: str = "" reason: str = ""
class GatePracticeCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "ended"] = "active"
interval_days: int = Field(default=1, gt=0)
class PlanSnapshotCreateRequest(BaseModel): class PlanSnapshotCreateRequest(BaseModel):
reason: str = Field(default="", max_length=2000) reason: str = Field(default="", max_length=2000)
@ -309,6 +316,63 @@ def delete_roadmap_item(
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
def _work_gate_or_400(*, tenant_id: str, item_id: str) -> dict[str, Any]:
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
if item.get("item_type") == "join_gate":
raise HTTPException(
status_code=400,
detail="Join-Gates haben kein Activity Set (Übungen)",
)
return item
@items_router.get("/{item_id}/practices")
def list_gate_practices(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
):
from services import recurring as recurring_service
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
try:
return recurring_service.list_recurring_for_gate(
tenant_id=ctx.tenant_id,
initiative_id=str(item["initiative_id"]),
gate_id=item_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/practices", status_code=201)
def create_gate_practice(
item_id: str,
body: GatePracticeCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
from datetime import datetime, timezone
from services import recurring as recurring_service
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
try:
return recurring_service.create_recurring_element(
tenant_id=ctx.tenant_id,
initiative_id=str(item["initiative_id"]),
title=body.title,
description=body.description,
status=body.status,
interval_days=body.interval_days,
next_due_at=datetime.now(timezone.utc),
roadmap_item_id=item_id,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/verify-reached") @items_router.post("/{item_id}/verify-reached")
def verify_roadmap_item_reached( def verify_roadmap_item_reached(
item_id: str, item_id: str,

View File

@ -134,6 +134,15 @@ def list_recurring_for_initiative(
return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id) return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id)
def list_recurring_for_gate(
*, tenant_id: str, initiative_id: str, gate_id: str
) -> list[dict[str, Any]]:
items = list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return [item for item in items if str(item.get("roadmap_item_id") or "") == str(gate_id)]
def get_recurring_element( def get_recurring_element(
*, tenant_id: str, recurring_id: str *, tenant_id: str, recurring_id: str
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:

View File

@ -0,0 +1,94 @@
"""Gate Activity Set API — A1 minimal usability."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_gate_practice_create_and_list(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="A1 Gate Practices",
archetype_key="initiative.maturity_journey",
apply_starter_kit=False,
)
initiative_id = created.json()["id"]
gate = client.post(
f"/api/initiatives/{initiative_id}/roadmap/items",
json={
"title": "Dehnung — Einstieg",
"item_type": "maturity_stage",
"status": "active",
},
headers=_auth(token),
)
assert gate.status_code == 201
gate_id = gate.json()["id"]
empty = client.get(
f"/api/roadmap-items/{gate_id}/practices",
headers=_auth(token),
)
assert empty.status_code == 200
assert empty.json() == []
created_practice = client.post(
f"/api/roadmap-items/{gate_id}/practices",
json={
"title": "Vorbeuge halten",
"description": "3×30 Sek.",
"interval_days": 1,
},
headers=_auth(token),
)
assert created_practice.status_code == 201
body = created_practice.json()
assert body["roadmap_item_id"] == gate_id
assert body["interval_days"] == 1
listed = client.get(
f"/api/roadmap-items/{gate_id}/practices",
headers=_auth(token),
)
assert listed.status_code == 200
assert len(listed.json()) == 1
assert listed.json()[0]["title"] == "Vorbeuge halten"
def test_join_gate_rejects_practices(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Join no practices",
archetype_key="initiative.maturity_journey",
apply_starter_kit=False,
)
initiative_id = created.json()["id"]
join = client.post(
f"/api/initiatives/{initiative_id}/roadmap/items",
json={
"title": "Konsolidierung",
"item_type": "join_gate",
"status": "planned",
},
headers=_auth(token),
)
assert join.status_code == 201
join_id = join.json()["id"]
rejected = client.post(
f"/api/roadmap-items/{join_id}/practices",
json={"title": "Nope", "interval_days": 1},
headers=_auth(token),
)
assert rejected.status_code == 400

View File

@ -140,7 +140,7 @@ Alle Kanten: **Voraussetzung** (`requires`). Kein Parallelgruppe/Blockiert nöti
| **2** | Operating Context | `GET …/initiatives/{id}/operating-context` oder Kontrolle | `maturity_stage`, `recurring_rhythm`; Default-Route Kontrolle | | | | **2** | Operating Context | `GET …/initiatives/{id}/operating-context` oder Kontrolle | `maturity_stage`, `recurring_rhythm`; Default-Route Kontrolle | | |
| **3** | Gates + Join im Designer | Plan → Zielzustände → **Designer** | 5 Knoten: 4× `Reifegrad-Stufe` + 1× **Join/Schaltknoten**; D1+H1 **active**, Rest **planned** | | | | **3** | Gates + Join im Designer | Plan → Zielzustände → **Designer** | 5 Knoten: 4× `Reifegrad-Stufe` + 1× **Join/Schaltknoten**; D1+H1 **active**, Rest **planned** | | |
| **4** | Kanten ziehen | Designer, Kantentyp **Voraussetzung** | Join→D1, Join→H1, D2→Join, H2→Join (4 Kanten) | | | | **4** | Kanten ziehen | Designer, Kantentyp **Voraussetzung** | Join→D1, Join→H1, D2→Join, H2→Join (4 Kanten) | | |
| **5** | Activity Sets | Gate-Detail je Work-Gate: Recurring/Übung anlegen oder Kriterium + Übung | D1 und H1 je ≥1 Übung; Join **ohne** Checkliste/Verify-Button | | | | **5** | Activity Sets | Gate-Detail (Designer → Gate öffnen): **Übung anlegen** mit Rhythmus | Je aktivem Work-Gate ≥1 Übung; Join **ohne** Übungen | | |
| **6** | Today (Start) | `/work/today` (Initiative-Scope) | Übungen von **beiden** aktiven Gates; **keine** flache AP-Wand; Join **nicht** in Today | | | | **6** | Today (Start) | `/work/today` (Initiative-Scope) | Übungen von **beiden** aktiven Gates; **keine** flache AP-Wand; Join **nicht** in Today | | |
| **7** | Verify Pfad 1 | Gate D1: Evidence + **Verify** | D1 `reached`; Join wartet auf H1; **kein** auto-next auf D2; Today nur noch H1-Übungen | | | | **7** | Verify Pfad 1 | Gate D1: Evidence + **Verify** | D1 `reached`; Join wartet auf H1; **kein** auto-next auf D2; Today nur noch H1-Übungen | | |
| **8** | Join schaltet | Gate H1: Evidence + **Verify** | Join `reached` (automatisch); D2 + H2 **active**; neue Übungen in Today | | | | **8** | Join schaltet | Gate H1: Evidence + **Verify** | Join `reached` (automatisch); D2 + H2 **active**; neue Übungen in Today | | |

View File

@ -70,6 +70,17 @@ export function listRoadmapItemCriteria(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/criteria`) return apiFetch(`/api/roadmap-items/${itemId}/criteria`)
} }
export function listGatePractices(gateId) {
return apiFetch(`/api/roadmap-items/${gateId}/practices`)
}
export function createGatePractice(gateId, body) {
return apiFetch(`/api/roadmap-items/${gateId}/practices`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function createRoadmapItemCriterion(itemId, body) { export function createRoadmapItemCriterion(itemId, body) {
return apiFetch(`/api/roadmap-items/${itemId}/criteria`, { return apiFetch(`/api/roadmap-items/${itemId}/criteria`, {
method: 'POST', method: 'POST',

View File

@ -0,0 +1,250 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
createGatePractice,
listGatePractices,
} from '../api/roadmap.js'
import { deleteRecurring, updateRecurring } from '../api/recurring.js'
import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js'
import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx'
import { scopedPath } from '../utils/routes.js'
function formatDueAt(iso) {
if (!iso) return null
try {
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return iso
}
}
export function GateActivitySetSection({
gateId,
initiativeId,
gateStatus,
canManage = false,
}) {
const [practices, setPractices] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [showForm, setShowForm] = useState(false)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [intervalDays, setIntervalDays] = useState('1')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const items = await listGatePractices(gateId)
setPractices(Array.isArray(items) ? items : [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [gateId])
useEffect(() => {
load()
}, [load])
async function runAction(fn) {
setBusy(true)
setError(null)
try {
await fn()
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleCreate(e) {
e.preventDefault()
if (!title.trim()) return
const interval = Number.parseInt(intervalDays, 10)
if (!Number.isFinite(interval) || interval < 1) {
setError('Intervall muss mindestens 1 Tag sein.')
return
}
await runAction(async () => {
await createGatePractice(gateId, {
title: title.trim(),
description: description.trim(),
interval_days: interval,
status: 'active',
})
setTitle('')
setDescription('')
setIntervalDays('1')
setShowForm(false)
})
}
const isActiveGate = gateStatus === 'active' || gateStatus === 'at_risk'
return (
<section className="card gate-activity-set">
<div className="section-header">
<div>
<h3>Übungen (Activity Set)</h3>
<p className="section-lead muted">
Wiederkehrende Routinen an diesem Zielzustand erscheinen in Today, wenn das Gate{' '}
<strong>aktiv</strong> ist.
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setShowForm((value) => !value)}
disabled={busy}
>
{showForm ? 'Abbrechen' : 'Übung anlegen'}
</button>
)}
</div>
{error && <p className="error">{error}</p>}
{!isActiveGate && (
<p className="muted gate-activity-set__hint">
Gate-Status ist <strong>{gateStatus}</strong> Übungen werden in Today sichtbar, sobald
das Gate auf <strong>aktiv</strong> steht (Stammdaten bearbeiten).
</p>
)}
{isActiveGate && practices.some((item) => item.status === 'active') && initiativeId && (
<p className="muted gate-activity-set__hint">
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
Ausführen Today
</Link>{' '}
fällige Übungen dieses Gates abhaken.
</p>
)}
{showForm && canManage && (
<form className="inline-form-block gate-activity-set__form" onSubmit={handleCreate}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="z. B. Vorbeuge halten"
/>
</label>
<label>
Beschreibung (optional)
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Hinweise zur Ausführung"
/>
</label>
<label>
Rhythmus (Tage)
<input
type="number"
min={1}
value={intervalDays}
onChange={(e) => setIntervalDays(e.target.value)}
required
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Übung speichern
</button>
</form>
)}
{loading ? (
<p className="muted">Übungen werden geladen</p>
) : practices.length === 0 ? (
<EmptyState message="Noch keine Übungen an diesem Gate — mindestens eine für aktive Gates anlegen." />
) : (
<ul className="item-list gate-activity-set__list">
{practices.map((practice) => (
<li key={practice.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{practice.title}</strong>
{practice.description && (
<p className="muted list-item-sub">{practice.description}</p>
)}
<p className="muted list-item-sub">
{practice.interval_days
? `Alle ${practice.interval_days} Tag(e)`
: 'Kein Intervall'}
{practice.open_cadence_instance?.due_at
? ` · Fällig: ${formatDueAt(practice.open_cadence_instance.due_at)}`
: ''}
{practice.today_completed ? ' · heute erledigt' : ''}
</p>
</div>
{canManage && (
<div className="list-item-meta action-controls">
<StatusBadge kind="recurring" status={practice.status} />
<select
className="inline-select"
value={practice.status}
disabled={busy}
onChange={(e) =>
runAction(() =>
updateRecurring(practice.id, { status: e.target.value }),
)
}
aria-label="Übungs-Status"
>
{RECURRING_STATUSES.map((status) => (
<option key={status} value={status}>
{RECURRING_STATUS_LABELS[status]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => {
const next = window.prompt(
'Neues Intervall in Tagen:',
String(practice.interval_days || 1),
)
if (next == null) return
const days = Number.parseInt(next, 10)
if (!Number.isFinite(days) || days < 1) {
setError('Intervall muss mindestens 1 Tag sein.')
return
}
runAction(() => updateRecurring(practice.id, { interval_days: days }))
}}
>
Rhythmus
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => {
if (!window.confirm(`${practice.title}" löschen?`)) return
runAction(() => deleteRecurring(practice.id))
}}
>
Löschen
</button>
</div>
)}
</li>
))}
</ul>
)}
</section>
)
}

View File

@ -78,10 +78,14 @@ export function RecurringRhythmPanel({
</ul> </ul>
<p className="muted recurring-rhythm-hint"> <p className="muted recurring-rhythm-hint">
{canManage {canManage
? 'Übungen anlegen, pausieren oder löschen unter ' ? 'Übungen anlegen und Rhythmus pflegen am '
: 'Rhythmen pflegen unter '} : 'Übungen am '}
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline"> <Link to={scopedPath('/plan/gates', { initiativeId })} className="link-inline">
Kontrolle Journey Wiederkehrend Zielzustand (Gate-Detail)
</Link>
{' '} Activity Set pro Gate. Heute erledigen unter{' '}
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
Ausführen Today
</Link> </Link>
. .
</p> </p>

View File

@ -44,6 +44,12 @@ export function RecurringSection({ items, canManage, onCreate, onUpdateStatus, o
)} )}
</div> </div>
<p className="muted form-hint">
Für Reifegrad-Vorhaben (A1): Übungen am{' '}
<strong>Zielzustand (Gate-Detail)</strong> anlegen dort Activity Set mit Rhythmus.
Diese Liste ist für initiative-weite Elemente ohne Gate-Bezug.
</p>
{showForm && canManage && ( {showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}> <form className="inline-form-block" onSubmit={handleSubmit}>
<label> <label>

View File

@ -25,6 +25,7 @@ import { useCapabilities } from '../../hooks/useCapabilities.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { scopedPath } from '../../utils/routes.js' import { scopedPath } from '../../utils/routes.js'
import { listGateContributions } from '../../api/journey.js' import { listGateContributions } from '../../api/journey.js'
import { GateActivitySetSection } from '../../components/GateActivitySetSection.jsx'
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx' import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx' import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
import { GateCriteriaSection } from '../../components/GateCriteriaSection.jsx' import { GateCriteriaSection } from '../../components/GateCriteriaSection.jsx'
@ -172,6 +173,20 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
</div> </div>
<div className="section-header__actions"> <div className="section-header__actions">
<StatusBadge kind="milestone" status={item.status} /> <StatusBadge kind="milestone" status={item.status} />
{canManage && item.status === 'planned' && !joinGate && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() =>
runAction(async () => {
await updateRoadmapItem(itemId, { status: 'active' })
})
}
>
Gate aktivieren
</button>
)}
{canManage && item.status !== 'reached' && ( {canManage && item.status !== 'reached' && (
<button <button
type="button" type="button"
@ -221,6 +236,15 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))} onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
/> />
{!joinGate && (
<GateActivitySetSection
gateId={itemId}
initiativeId={initiativeId || item.initiative_id}
gateStatus={item.status}
canManage={capabilities.has('kairo.recurring.manage')}
/>
)}
{joinGate ? ( {joinGate ? (
<section className="card join-gate-detail"> <section className="card join-gate-detail">
<h3>Join / Schaltknoten</h3> <h3>Join / Schaltknoten</h3>