diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py
index 1852741..488734c 100644
--- a/backend/routers/initiatives.py
+++ b/backend/routers/initiatives.py
@@ -132,6 +132,7 @@ class RecurringCreateRequest(BaseModel):
status: Literal["active", "paused", "ended"] = "active"
interval_days: Optional[int] = Field(default=None, gt=0)
next_due_at: Optional[str] = None
+ roadmap_item_id: Optional[str] = None
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
@@ -648,6 +649,7 @@ def create_initiative_recurring(
status=body.status,
interval_days=body.interval_days,
next_due_at=next_due_at,
+ roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id,
)
except ValueError as exc:
diff --git a/backend/routers/recurring.py b/backend/routers/recurring.py
index a4768cd..a45e361 100644
--- a/backend/routers/recurring.py
+++ b/backend/routers/recurring.py
@@ -22,6 +22,8 @@ class RecurringUpdateRequest(BaseModel):
clear_interval_days: bool = False
next_due_at: Optional[str] = None
clear_next_due_at: bool = False
+ roadmap_item_id: Optional[str] = None
+ clear_roadmap_item_id: bool = False
class RecurringCompleteRequest(BaseModel):
@@ -69,6 +71,8 @@ def update_recurring_element(
clear_interval_days=body.clear_interval_days,
next_due_at=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:
raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
index 2a5c4c2..1b7905e 100644
--- a/backend/routers/roadmap.py
+++ b/backend/routers/roadmap.py
@@ -87,6 +87,13 @@ class ReopenRequest(BaseModel):
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):
reason: str = Field(default="", max_length=2000)
@@ -309,6 +316,63 @@ def delete_roadmap_item(
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")
def verify_roadmap_item_reached(
item_id: str,
diff --git a/backend/services/recurring.py b/backend/services/recurring.py
index 2e6b3a1..2ffc531 100644
--- a/backend/services/recurring.py
+++ b/backend/services/recurring.py
@@ -134,6 +134,15 @@ def list_recurring_for_initiative(
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(
*, tenant_id: str, recurring_id: str
) -> Optional[dict[str, Any]]:
diff --git a/backend/tests/test_a1_gate_practices.py b/backend/tests/test_a1_gate_practices.py
new file mode 100644
index 0000000..c46086a
--- /dev/null
+++ b/backend/tests/test_a1_gate_practices.py
@@ -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
diff --git a/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md b/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md
index 852fee0..84c068e 100644
--- a/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md
+++ b/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md
@@ -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 | | |
| **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) | | |
-| **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 | | |
| **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 | | |
diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js
index 5c39ad9..67ecf3d 100644
--- a/frontend/src/api/roadmap.js
+++ b/frontend/src/api/roadmap.js
@@ -70,6 +70,17 @@ export function listRoadmapItemCriteria(itemId) {
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) {
return apiFetch(`/api/roadmap-items/${itemId}/criteria`, {
method: 'POST',
diff --git a/frontend/src/components/GateActivitySetSection.jsx b/frontend/src/components/GateActivitySetSection.jsx
new file mode 100644
index 0000000..e95f33f
--- /dev/null
+++ b/frontend/src/components/GateActivitySetSection.jsx
@@ -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 (
+
+ Wiederkehrende Routinen an diesem Zielzustand — erscheinen in Today, wenn das Gate{' '}
+ aktiv ist.
+ {error}
+ Gate-Status ist {gateStatus} — Übungen werden in Today sichtbar, sobald
+ das Gate auf aktiv steht (Stammdaten bearbeiten).
+
+
+ Ausführen → Today
+ {' '}
+ — fällige Übungen dieses Gates abhaken.
+ Übungen werden geladen… {practice.description}
+ {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' : ''}
+ Übungen (Activity Set)
+
+ {practices.map((practice) => (
+
+ )}
+
{canManage - ? 'Übungen anlegen, pausieren oder löschen unter ' - : 'Rhythmen pflegen unter '} - - Kontrolle → Journey → Wiederkehrend + ? 'Übungen anlegen und Rhythmus pflegen am ' + : 'Übungen am '} + + Zielzustand (Gate-Detail) + + {' '}— Activity Set pro Gate. Heute erledigen unter{' '} + + Ausführen → Today .
diff --git a/frontend/src/components/RecurringSection.jsx b/frontend/src/components/RecurringSection.jsx index 415ba81..beec5ef 100644 --- a/frontend/src/components/RecurringSection.jsx +++ b/frontend/src/components/RecurringSection.jsx @@ -44,6 +44,12 @@ export function RecurringSection({ items, canManage, onCreate, onUpdateStatus, o )} ++ Für Reifegrad-Vorhaben (A1): Übungen am{' '} + Zielzustand (Gate-Detail) anlegen — dort Activity Set mit Rhythmus. + Diese Liste ist für initiative-weite Elemente ohne Gate-Bezug. +
+ {showForm && canManage && (