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 ( +
+
+
+

Übungen (Activity Set)

+

+ Wiederkehrende Routinen an diesem Zielzustand — erscheinen in Today, wenn das Gate{' '} + aktiv ist. +

+
+ {canManage && ( + + )} +
+ + {error &&

{error}

} + + {!isActiveGate && ( +

+ Gate-Status ist {gateStatus} — Übungen werden in Today sichtbar, sobald + das Gate auf aktiv steht (Stammdaten bearbeiten). +

+ )} + + {isActiveGate && practices.some((item) => item.status === 'active') && initiativeId && ( +

+ + Ausführen → Today + {' '} + — fällige Übungen dieses Gates abhaken. +

+ )} + + {showForm && canManage && ( +
+ +