diff --git a/backend/migrations/031_recurring_stage_binding.sql b/backend/migrations/031_recurring_stage_binding.sql
new file mode 100644
index 0000000..4587411
--- /dev/null
+++ b/backend/migrations/031_recurring_stage_binding.sql
@@ -0,0 +1,9 @@
+-- AP A1.2 — RecurringElement an Reifegrad-Stufe binden (Activity Set)
+
+ALTER TABLE recurring_elements
+ ADD COLUMN roadmap_item_id UUID NULL
+ REFERENCES roadmap_items(id) ON DELETE SET NULL;
+
+CREATE INDEX idx_recurring_elements_stage
+ ON recurring_elements(tenant_id, roadmap_item_id)
+ WHERE roadmap_item_id IS NOT NULL;
diff --git a/backend/services/archetype_starter_kit.py b/backend/services/archetype_starter_kit.py
index 8b835a0..7da7fb3 100644
--- a/backend/services/archetype_starter_kit.py
+++ b/backend/services/archetype_starter_kit.py
@@ -65,7 +65,8 @@ def get_starter_preview(
{"kind": "maturity_stage", "title": "Stufe 2 — Aufbau", "status": "planned"},
{"kind": "maturity_stage", "title": "Stufe 3 — Ziel", "status": "planned"},
{"kind": "project", "title": "Training"},
- {"kind": "recurring", "title": "Tägliche Übung"},
+ {"kind": "recurring", "title": "Vorbeuge-Dehnung"},
+ {"kind": "recurring", "title": "Hüftöffner (Schmetterling)"},
]
elif archetype_key == "initiative.linear_project":
items = [
@@ -185,7 +186,6 @@ def _apply_a1(
user_id: Optional[str],
) -> dict[str, list[str]]:
from services.projects import create_project, list_projects_for_initiative
- from services.recurring import create_recurring_element, list_recurring_for_initiative
from services.roadmap import create_roadmap_item
roadmap_ids: list[str] = []
@@ -194,6 +194,7 @@ def _apply_a1(
("Stufe 2 — Aufbau", "planned"),
("Stufe 3 — Ziel", "planned"),
]
+ stage_items: list[dict[str, Any]] = []
for index, (title, status) in enumerate(stage_defs):
item = create_roadmap_item(
tenant_id=tenant_id,
@@ -205,6 +206,7 @@ def _apply_a1(
user_id=user_id,
)
roadmap_ids.append(item["id"])
+ stage_items.append(item)
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not _find_by_title(projects, "Training"):
@@ -217,19 +219,20 @@ def _apply_a1(
user_id=user_id,
)
- recurring = list_recurring_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
- if not _find_by_title(recurring, "Tägliche Übung"):
- create_recurring_element(
- tenant_id=tenant_id,
- initiative_id=initiative_id,
- title="Tägliche Übung",
- description="Regelmäßige Übung für die aktuelle Reifegrad-Stufe",
- interval_days=1,
- next_due_at=datetime.now(timezone.utc),
- user_id=user_id,
- )
+ from services.maturity_practice import seed_practices_for_stage
- return {"roadmap_items": roadmap_ids, "projects": ["Training"], "recurring": ["Tägliche Übung"]}
+ exercises = seed_practices_for_stage(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ stage=stage_items[0],
+ user_id=user_id,
+ )
+
+ return {
+ "roadmap_items": roadmap_ids,
+ "projects": ["Training"],
+ "recurring": [e["title"] for e in exercises],
+ }
def _apply_a2(
diff --git a/backend/services/cadence_instance.py b/backend/services/cadence_instance.py
index 1ba3cce..96da315 100644
--- a/backend/services/cadence_instance.py
+++ b/backend/services/cadence_instance.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, time, timedelta, timezone
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
@@ -86,6 +86,23 @@ def ensure_open_instance(
return row
+def _next_due_at(*, now: datetime, interval_days: int | None) -> datetime:
+ days = max(int(interval_days or 1), 1)
+ next_date = now.date() + timedelta(days=days)
+ return datetime.combine(next_date, time.min, tzinfo=timezone.utc)
+
+
+def _parse_due(value: Any) -> Optional[datetime]:
+ if value is None:
+ return None
+ if isinstance(value, datetime):
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+ try:
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ except ValueError:
+ return None
+
+
def complete_open_instance(
*,
tenant_id: str,
@@ -144,7 +161,7 @@ def complete_open_instance(
conn.close()
interval = recurring.get("interval_days") or 1
- next_due = now + timedelta(days=int(interval))
+ next_due = _next_due_at(now=now, interval_days=interval)
update_recurring_element(
tenant_id=tenant_id,
@@ -153,12 +170,6 @@ def complete_open_instance(
next_due_at=next_due,
)
- next_instance = ensure_open_instance(
- tenant_id=tenant_id,
- recurring_element_id=recurring_element_id,
- due_at=next_due,
- )
-
log_audit(
"cadence_instance.completed",
user_id=user_id,
@@ -175,7 +186,7 @@ def complete_open_instance(
)
return {
"completed_instance": completed,
- "next_instance": next_instance,
+ "next_due_at": next_due.isoformat(),
"recurring": updated_recurring,
}
@@ -186,6 +197,7 @@ def attach_open_instances_to_recurring_list(
if not items:
return items
ids = [item["id"] for item in items]
+ now = datetime.now(timezone.utc)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
@@ -203,17 +215,51 @@ def attach_open_instances_to_recurring_list(
str(row["recurring_element_id"]): _serialize_row(dict(row))
for row in cur.fetchall()
}
+ cur.execute(
+ """
+ SELECT DISTINCT ON (recurring_element_id)
+ recurring_element_id, measurement_note, completed_at
+ FROM cadence_instances
+ WHERE tenant_id = %s
+ AND recurring_element_id = ANY(%s::uuid[])
+ AND status = 'completed'
+ AND completed_at >= CURRENT_DATE
+ ORDER BY recurring_element_id, completed_at DESC
+ """,
+ (tenant_id, ids),
+ )
+ completed_today = {
+ str(row["recurring_element_id"]): {
+ "measurement_note": row.get("measurement_note") or "",
+ "completed_at": row["completed_at"].isoformat()
+ if row.get("completed_at")
+ else None,
+ }
+ for row in cur.fetchall()
+ }
finally:
conn.close()
enriched = []
for item in items:
copy = dict(item)
+ today = completed_today.get(item["id"])
+ copy["today_completed"] = today is not None
+ copy["today_measurement_note"] = today.get("measurement_note", "") if today else ""
+
open_inst = by_recurring.get(item["id"])
- if not open_inst and item.get("status") == "active":
+ next_due = _parse_due(item.get("next_due_at"))
+ due_now = next_due is None or next_due <= now
+ if (
+ not open_inst
+ and item.get("status") == "active"
+ and due_now
+ and not copy["today_completed"]
+ ):
open_inst = ensure_open_instance(
tenant_id=tenant_id,
recurring_element_id=item["id"],
+ due_at=next_due or now,
)
copy["open_cadence_instance"] = open_inst
enriched.append(copy)
diff --git a/backend/services/maturity_practice.py b/backend/services/maturity_practice.py
new file mode 100644
index 0000000..62cbe64
--- /dev/null
+++ b/backend/services/maturity_practice.py
@@ -0,0 +1,191 @@
+"""A1 — Stage-bound practice exercises (Activity Set)."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+from services.recurring import (
+ create_recurring_element,
+ list_recurring_for_initiative,
+ update_recurring_element,
+)
+
+# Starter exercise sets per stage title (Spagat reference path).
+STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
+ "Stufe 1 — Basis": [
+ ("Vorbeuge-Dehnung", "Täglich 3×30 Sek. — Hüfte und Beinrückseite"),
+ ("Hüftöffner (Schmetterling)", "Täglich sitzend, leichter Druck"),
+ ],
+ "Stufe 2 — Aufbau": [
+ ("Seitlicher Spagat — links", "Täglich halten, optional Messwert (Grad)"),
+ ("Seitlicher Spagat — rechts", "Täglich halten, optional Messwert (Grad)"),
+ ("Aktive Vorbeuge", "Täglich 3× kurz halten"),
+ ],
+ "Stufe 3 — Ziel": [
+ ("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"),
+ ],
+}
+
+
+def get_active_maturity_stage(
+ *, tenant_id: str, initiative_id: str
+) -> Optional[dict[str, Any]]:
+ from services.roadmap import list_roadmap_items_for_initiative
+
+ stages = [
+ item
+ for item in list_roadmap_items_for_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id
+ )
+ if item.get("item_type") == "maturity_stage" and item.get("status") == "active"
+ ]
+ if not stages:
+ return None
+ stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
+ return stages[0]
+
+
+def enforce_single_active_maturity_stage(
+ cur,
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ keep_active_id: str,
+) -> None:
+ """At most one maturity_stage may be active."""
+ cur.execute(
+ """
+ UPDATE roadmap_items
+ SET status = 'planned', updated_at = NOW()
+ WHERE tenant_id = %s
+ AND initiative_id = %s
+ AND item_type = 'maturity_stage'
+ AND status IN ('active', 'at_risk')
+ AND id != %s
+ """,
+ (tenant_id, initiative_id, keep_active_id),
+ )
+
+
+def list_practices_for_stage(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ stage_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 item.get("roadmap_item_id") == stage_id
+ ]
+
+
+def pause_all_practices(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ user_id: Optional[str] = None,
+) -> list[str]:
+ paused: list[str] = []
+ for item in list_recurring_for_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id
+ ):
+ if item.get("status") == "active":
+ update_recurring_element(
+ tenant_id=tenant_id,
+ recurring_id=item["id"],
+ status="paused",
+ user_id=user_id,
+ )
+ paused.append(item["title"])
+ return paused
+
+
+def seed_practices_for_stage(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ stage: dict[str, Any],
+ user_id: Optional[str] = None,
+) -> list[dict[str, Any]]:
+ """Idempotent: create missing exercises for a stage; activate existing."""
+ stage_id = stage["id"]
+ stage_title = stage.get("title") or ""
+ defs = STAGE_PRACTICE_SETS.get(stage_title)
+ if not defs:
+ defs = [
+ (
+ f"Übung — {stage_title}",
+ f"Tägliche Routine für {stage_title}",
+ )
+ ]
+
+ existing = list_practices_for_stage(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ stage_id=stage_id,
+ )
+ existing_by_title = {item["title"]: item for item in existing}
+ created: list[dict[str, Any]] = []
+ now = datetime.now(timezone.utc)
+
+ for title, description in defs:
+ if title in existing_by_title:
+ item = existing_by_title[title]
+ if item.get("status") != "active":
+ updated = update_recurring_element(
+ tenant_id=tenant_id,
+ recurring_id=item["id"],
+ status="active",
+ next_due_at=now,
+ user_id=user_id,
+ )
+ if updated:
+ created.append(updated)
+ else:
+ created.append(item)
+ continue
+
+ row = create_recurring_element(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ title=title,
+ description=description,
+ status="active",
+ interval_days=1,
+ next_due_at=now,
+ roadmap_item_id=stage_id,
+ user_id=user_id,
+ )
+ created.append(row)
+
+ return created
+
+
+def activate_stage_practice_set(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ stage: dict[str, Any],
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ paused = pause_all_practices(
+ tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
+ )
+ exercises = seed_practices_for_stage(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ stage=stage,
+ user_id=user_id,
+ )
+ return {
+ "stage_id": stage["id"],
+ "stage_title": stage.get("title"),
+ "paused_practices": paused,
+ "active_practices": [e["title"] for e in exercises],
+ "practice_ids": [e["id"] for e in exercises],
+ }
diff --git a/backend/services/maturity_stage_transition.py b/backend/services/maturity_stage_transition.py
index 7f0f0db..9c8c8c0 100644
--- a/backend/services/maturity_stage_transition.py
+++ b/backend/services/maturity_stage_transition.py
@@ -2,15 +2,10 @@
from __future__ import annotations
-from datetime import datetime, timezone
from typing import Any, Optional
-from services.recurring import (
- create_recurring_element,
- list_recurring_for_initiative,
- update_recurring_element,
-)
-from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative, update_roadmap_item
+from services.maturity_practice import activate_stage_practice_set
+from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative
def on_maturity_stage_reached(
@@ -20,7 +15,7 @@ def on_maturity_stage_reached(
reached_item_id: str,
user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
- """Pause old routines, activate next stage, seed new routine for next stage."""
+ """Pause old routines, activate next stage, seed exercise set for next stage."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id)
if not item or item.get("item_type") != "maturity_stage":
return None
@@ -47,41 +42,50 @@ def on_maturity_stage_reached(
next_stage = stage
break
- paused: list[str] = []
- for recurring in list_recurring_for_initiative(
- tenant_id=tenant_id, initiative_id=initiative_id
- ):
- if recurring.get("status") == "active":
- update_recurring_element(
- tenant_id=tenant_id,
- recurring_id=recurring["id"],
- status="paused",
- user_id=user_id,
- )
- paused.append(recurring["title"])
-
- created_recurring = None
+ practice_transition = None
if next_stage:
+ from services.roadmap import update_roadmap_item
+
update_roadmap_item(
tenant_id=tenant_id,
item_id=next_stage["id"],
status="active",
user_id=user_id,
)
- created_recurring = create_recurring_element(
- tenant_id=tenant_id,
- initiative_id=initiative_id,
- title=f"Übung — {next_stage['title']}",
- description=f"Routine für {next_stage['title']} (automatisch nach Stufenübergang)",
- status="active",
- interval_days=1,
- next_due_at=datetime.now(timezone.utc),
- user_id=user_id,
- )
+ next_stage = get_roadmap_item(tenant_id=tenant_id, item_id=next_stage["id"])
+ if next_stage:
+ practice_transition = activate_stage_practice_set(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ stage=next_stage,
+ user_id=user_id,
+ )
return {
"reached_stage_id": reached_item_id,
"next_stage_id": next_stage["id"] if next_stage else None,
- "paused_recurring": paused,
- "new_recurring_id": created_recurring["id"] if created_recurring else None,
+ "paused_recurring": (practice_transition or {}).get("paused_practices", []),
+ "new_recurring_id": (
+ (practice_transition or {}).get("practice_ids") or [None]
+ )[0],
+ "active_practices": (practice_transition or {}).get("active_practices", []),
}
+
+
+def on_maturity_stage_reopened(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ stage_id: str,
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ """Re-enable exercise set for reopened stage; pause others."""
+ stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id)
+ if not stage:
+ return {"stage_id": stage_id, "active_practices": []}
+ return activate_stage_practice_set(
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ stage=stage,
+ user_id=user_id,
+ )
diff --git a/backend/services/recurring.py b/backend/services/recurring.py
index 2075d16..4a8e82e 100644
--- a/backend/services/recurring.py
+++ b/backend/services/recurring.py
@@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"]
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
_RECURRING_COLUMNS = """
- id, tenant_id, initiative_id, title, description, status,
+ id, tenant_id, initiative_id, roadmap_item_id, title, description, status,
interval_days, next_due_at, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
- for key in ("id", "tenant_id", "initiative_id"):
+ for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at", "next_due_at"):
@@ -46,6 +46,7 @@ def create_recurring_element(
status: RecurringStatus = "active",
interval_days: Optional[int] = None,
next_due_at: Optional[datetime] = None,
+ roadmap_item_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
@@ -63,15 +64,16 @@ def create_recurring_element(
cur.execute(
f"""
INSERT INTO recurring_elements (
- tenant_id, initiative_id, title, description, status,
+ tenant_id, initiative_id, roadmap_item_id, title, description, status,
interval_days, next_due_at
)
- VALUES (%s, %s, %s, %s, %s, %s, %s)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_RECURRING_COLUMNS}
""",
(
tenant_id,
initiative_id,
+ roadmap_item_id,
title,
description,
status,
@@ -162,6 +164,8 @@ def update_recurring_element(
clear_interval_days: bool = False,
next_due_at: Optional[datetime] = None,
clear_next_due_at: bool = False,
+ roadmap_item_id: Optional[str] = None,
+ clear_roadmap_item_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
if not existing:
@@ -196,6 +200,11 @@ def update_recurring_element(
elif next_due_at is not None:
updates.append("next_due_at = %s")
params.append(next_due_at)
+ if clear_roadmap_item_id:
+ updates.append("roadmap_item_id = NULL")
+ elif roadmap_item_id is not None:
+ updates.append("roadmap_item_id = %s")
+ params.append(roadmap_item_id)
if not updates:
return existing
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
index 247997a..0898b74 100644
--- a/backend/services/roadmap.py
+++ b/backend/services/roadmap.py
@@ -383,6 +383,15 @@ def update_roadmap_item(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ if status == "active" and existing.get("item_type") == "maturity_stage":
+ from services.maturity_practice import enforce_single_active_maturity_stage
+
+ enforce_single_active_maturity_stage(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=str(existing["initiative_id"]),
+ keep_active_id=item_id,
+ )
cur.execute(
f"""
UPDATE roadmap_items
@@ -760,6 +769,15 @@ def reopen_roadmap_item(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ if item.get("item_type") == "maturity_stage":
+ from services.maturity_practice import enforce_single_active_maturity_stage
+
+ enforce_single_active_maturity_stage(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=str(item["initiative_id"]),
+ keep_active_id=item_id,
+ )
cur.execute(
"""
UPDATE roadmap_items
@@ -783,6 +801,16 @@ def reopen_roadmap_item(
finally:
conn.close()
+ if row.get("item_type") == "maturity_stage":
+ from services.maturity_stage_transition import on_maturity_stage_reopened
+
+ row["maturity_practice"] = on_maturity_stage_reopened(
+ tenant_id=tenant_id,
+ initiative_id=str(item["initiative_id"]),
+ stage_id=item_id,
+ user_id=user_id,
+ )
+
log_audit(
"roadmap_item.reopened",
user_id=user_id,
diff --git a/backend/services/roadmap_criteria.py b/backend/services/roadmap_criteria.py
index aef7fab..7490509 100644
--- a/backend/services/roadmap_criteria.py
+++ b/backend/services/roadmap_criteria.py
@@ -73,6 +73,15 @@ def _reopen_item_if_reached(
) -> None:
if item["status"] != "reached":
return
+ if item.get("item_type") == "maturity_stage":
+ from services.maturity_practice import enforce_single_active_maturity_stage
+
+ enforce_single_active_maturity_stage(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=str(item["initiative_id"]),
+ keep_active_id=item["id"],
+ )
cur.execute(
"""
UPDATE roadmap_items
diff --git a/backend/steering/strategies/next_action/maturity_progression.py b/backend/steering/strategies/next_action/maturity_progression.py
index de7b7bc..34a864c 100644
--- a/backend/steering/strategies/next_action/maturity_progression.py
+++ b/backend/steering/strategies/next_action/maturity_progression.py
@@ -14,10 +14,7 @@ from steering.strategies.next_action.execution_ready import (
from steering.strategies.next_action.product_milestone_driven import (
ProductMilestoneDrivenStrategy,
)
-from steering.strategies.next_action.recurring_helpers import (
- active_recurring_candidates,
- recurring_due_candidates,
-)
+from steering.strategies.next_action.recurring_helpers import recurring_due_candidates
from steering.strategies.next_action.registry import (
get_next_action_strategy,
register_next_action_strategy,
@@ -44,8 +41,6 @@ class MaturityProgressionStrategy:
return _default.evaluate(ctx, limit=limit)
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
- if not recurring:
- recurring = active_recurring_candidates(ctx, initiative_id, limit=1)
remaining = limit - len(recurring)
gate_scope = first_active_gate_id(ctx, initiative_id)
diff --git a/backend/steering/strategies/next_action/recurring_helpers.py b/backend/steering/strategies/next_action/recurring_helpers.py
index acba6fc..06daf8c 100644
--- a/backend/steering/strategies/next_action/recurring_helpers.py
+++ b/backend/steering/strategies/next_action/recurring_helpers.py
@@ -8,6 +8,46 @@ from db import get_connection
from psycopg2.extras import RealDictCursor
from tenant_context import TenantContext
+_PRACTICE_DUE_SQL = """
+ SELECT
+ 'recurring_due' AS kind,
+ re.title AS title,
+ COALESCE(NULLIF(re.description, ''), 'Heutige Übung') AS summary,
+ re.initiative_id,
+ re.id AS recurring_id,
+ NULL::uuid AS action_id,
+ NULL::uuid AS backlog_item_id,
+ 'recurring_due' AS reason_code,
+ 'Übung heute erledigen' AS recommended_action
+ FROM recurring_elements re
+ LEFT JOIN roadmap_items ri
+ ON ri.id = re.roadmap_item_id AND ri.tenant_id = re.tenant_id
+ WHERE re.tenant_id = %s AND re.initiative_id = %s
+ AND re.status = 'active'
+ AND (re.next_due_at IS NULL OR re.next_due_at <= NOW())
+ AND (re.roadmap_item_id IS NULL OR (ri.status = 'active' AND ri.item_type = 'maturity_stage'))
+ AND NOT EXISTS (
+ SELECT 1 FROM cadence_instances ci
+ WHERE ci.recurring_element_id = re.id
+ AND ci.tenant_id = re.tenant_id
+ AND ci.status = 'completed'
+ AND ci.completed_at >= CURRENT_DATE
+ )
+ ORDER BY re.next_due_at ASC NULLS FIRST, re.title
+ LIMIT %s
+"""
+
+
+def _serialize_recurring_rows(rows) -> list[dict[str, Any]]:
+ items: list[dict[str, Any]] = []
+ for row in rows:
+ item = dict(row)
+ item["initiative_id"] = str(item["initiative_id"])
+ if item.get("recurring_id"):
+ item["recurring_id"] = str(item["recurring_id"])
+ items.append(item)
+ return items
+
def recurring_due_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int
@@ -15,36 +55,8 @@ def recurring_due_candidates(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- """
- SELECT
- 'recurring_due' AS kind,
- re.title AS title,
- 'Wiederkehrendes Element fällig' AS summary,
- re.initiative_id,
- re.id AS recurring_id,
- NULL::uuid AS action_id,
- NULL::uuid AS backlog_item_id,
- 'recurring_due' AS reason_code,
- 'Übung heute erledigen' AS recommended_action
- FROM recurring_elements re
- WHERE re.tenant_id = %s AND re.initiative_id = %s
- AND re.status = 'active'
- AND re.next_due_at IS NOT NULL
- AND re.next_due_at <= NOW()
- ORDER BY re.next_due_at ASC
- LIMIT %s
- """,
- (ctx.tenant_id, initiative_id, limit),
- )
- items: list[dict[str, Any]] = []
- for row in cur.fetchall():
- item = dict(row)
- item["initiative_id"] = str(item["initiative_id"])
- if item.get("recurring_id"):
- item["recurring_id"] = str(item["recurring_id"])
- items.append(item)
- return items
+ cur.execute(_PRACTICE_DUE_SQL, (ctx.tenant_id, initiative_id, limit))
+ return _serialize_recurring_rows(cur.fetchall())
finally:
conn.close()
@@ -52,37 +64,5 @@ def recurring_due_candidates(
def active_recurring_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int = 1
) -> list[dict[str, Any]]:
- """Active recurring as 'heutige Übung' when nothing is due yet."""
- conn = get_connection()
- try:
- with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- """
- SELECT
- 'recurring_due' AS kind,
- re.title AS title,
- 'Heutige Übung / Routine' AS summary,
- re.initiative_id,
- re.id AS recurring_id,
- NULL::uuid AS action_id,
- NULL::uuid AS backlog_item_id,
- 'recurring_active' AS reason_code,
- 'Übung heute erledigen' AS recommended_action
- FROM recurring_elements re
- WHERE re.tenant_id = %s AND re.initiative_id = %s
- AND re.status = 'active'
- ORDER BY re.next_due_at ASC NULLS LAST, re.title
- LIMIT %s
- """,
- (ctx.tenant_id, initiative_id, limit),
- )
- items: list[dict[str, Any]] = []
- for row in cur.fetchall():
- item = dict(row)
- item["initiative_id"] = str(item["initiative_id"])
- if item.get("recurring_id"):
- item["recurring_id"] = str(item["recurring_id"])
- items.append(item)
- return items
- finally:
- conn.close()
+ """Due practices only — no fallback when already completed today."""
+ return recurring_due_candidates(ctx, initiative_id, limit=limit)
diff --git a/backend/tests/test_a1_cadence_mvp.py b/backend/tests/test_a1_cadence_mvp.py
index 10b6eee..f6b9647 100644
--- a/backend/tests/test_a1_cadence_mvp.py
+++ b/backend/tests/test_a1_cadence_mvp.py
@@ -37,14 +37,15 @@ def test_complete_daily_practice_advances_cadence(client):
body = completed.json()
assert body["completed_instance"]["status"] == "completed"
assert body["completed_instance"]["measurement_note"] == "45° Spagat gehalten"
- assert body["next_instance"]["status"] == "open"
+ assert body["next_due_at"]
assert body["recurring"]["next_due_at"]
refreshed = client.get(
f"/api/initiatives/{initiative_id}/recurring",
headers=_auth(token),
).json()
- assert refreshed[0]["open_cadence_instance"]["id"] == body["next_instance"]["id"]
+ assert refreshed[0]["today_completed"] is True
+ assert refreshed[0]["today_measurement_note"] == "45° Spagat gehalten"
def test_steering_next_action_includes_recurring_id(client):
@@ -68,3 +69,40 @@ def test_steering_next_action_includes_recurring_id(client):
recurring_items = [a for a in next_actions if a.get("kind") == "recurring_due"]
assert recurring_items
assert recurring_items[0].get("recurring_id")
+
+
+def test_steering_next_action_excludes_completed_today(client):
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+
+ created = _create_initiative(
+ client,
+ token,
+ title="Spagat Complete Today",
+ archetype_key="initiative.maturity_journey",
+ )
+ initiative_id = created.json()["id"]
+
+ recurring = client.get(
+ f"/api/initiatives/{initiative_id}/recurring",
+ headers=_auth(token),
+ ).json()
+ recurring_id = recurring[0]["id"]
+
+ completed = client.post(
+ f"/api/recurring/{recurring_id}/complete",
+ json={"measurement_note": "done"},
+ headers=_auth(token),
+ )
+ assert completed.status_code == 200
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.status_code == 200
+ next_actions = snap.json().get("next_actions") or []
+ completed_again = [
+ a for a in next_actions if a.get("recurring_id") == recurring_id
+ ]
+ assert not completed_again
diff --git a/backend/tests/test_ap22a_starter_kit.py b/backend/tests/test_ap22a_starter_kit.py
index 427362b..bd06d8c 100644
--- a/backend/tests/test_ap22a_starter_kit.py
+++ b/backend/tests/test_ap22a_starter_kit.py
@@ -51,7 +51,8 @@ def test_create_initiative_a1_applies_starter_kit(client):
headers=_auth(token),
)
assert recurring.status_code == 200
- assert any(r["title"] == "Tägliche Übung" for r in recurring.json())
+ assert any(r["title"] == "Vorbeuge-Dehnung" for r in recurring.json())
+ assert any(r["roadmap_item_id"] for r in recurring.json())
def test_create_initiative_b2b_starter_kit(client):
diff --git a/backend/tests/test_ap22c_maturity_e2e.py b/backend/tests/test_ap22c_maturity_e2e.py
index 2bfa5ae..f0c8f59 100644
--- a/backend/tests/test_ap22c_maturity_e2e.py
+++ b/backend/tests/test_ap22c_maturity_e2e.py
@@ -6,7 +6,7 @@ from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
_STAGE1 = "Stufe 1 — Basis"
-_STARTER_RECURRING = "Tägliche Übung"
+_STAGE1_EXERCISES = ("Vorbeuge-Dehnung", "Hüftöffner (Schmetterling)")
def _create_spagat(client, token):
@@ -54,7 +54,8 @@ def test_a1_starter_kit_spagat_structure(client):
headers=_auth(token),
)
assert recurring.status_code == 200
- assert any(r["title"] == _STARTER_RECURRING and r["status"] == "active" for r in recurring.json())
+ assert any(r["title"] == _STAGE1_EXERCISES[0] and r["status"] == "active" for r in recurring.json())
+ assert any(r["title"] == _STAGE1_EXERCISES[1] and r["status"] == "active" for r in recurring.json())
projects = client.get(
f"/api/initiatives/{initiative_id}/projects",
@@ -122,8 +123,11 @@ def test_a1_stage_verify_rotates_recurring_and_next_action(client):
headers=_auth(token),
).json()
titles = {r["title"]: r["status"] for r in recurring}
- assert titles.get(_STARTER_RECURRING) == "paused"
- assert any(t.startswith("Übung — Stufe 2") and titles[t] == "active" for t in titles)
+ for title in _STAGE1_EXERCISES:
+ assert titles.get(title) == "paused"
+ assert any(
+ t.startswith("Seitlicher Spagat") and titles[t] == "active" for t in titles
+ )
items_after = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
@@ -139,8 +143,10 @@ def test_a1_stage_verify_rotates_recurring_and_next_action(client):
assert snap_after.status_code == 200
next_after = snap_after.json().get("next_actions") or []
assert next_after
- assert next_after[0].get("kind") == "recurring_due" or next_after[0].get("title", "").startswith(
- "Übung —"
+ assert next_after[0].get("kind") == "recurring_due" or next_after[0].get("title") in (
+ "Seitlicher Spagat — links",
+ "Seitlicher Spagat — rechts",
+ "Aktive Vorbeuge",
)
diff --git a/frontend/src/components/RecurringRhythmPanel.jsx b/frontend/src/components/RecurringRhythmPanel.jsx
index fbdf8be..77482d7 100644
--- a/frontend/src/components/RecurringRhythmPanel.jsx
+++ b/frontend/src/components/RecurringRhythmPanel.jsx
@@ -12,54 +12,74 @@ function formatDueAt(iso) {
}
}
+function activeMaturityStage(roadmapItems = []) {
+ return [...roadmapItems]
+ .filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
+ .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0]
+}
+
+function practicesForStage(recurringItems = [], stageId) {
+ if (!stageId) {
+ return (recurringItems || []).filter((item) => item.roadmap_item_id)
+ }
+ return (recurringItems || []).filter((item) => item.roadmap_item_id === stageId)
+}
+
export function RecurringRhythmPanel({
initiativeId,
+ roadmapItems = [],
recurringItems = [],
+ canManage = false,
embedded = true,
}) {
- const sorted = [...recurringItems].sort((a, b) => {
- const statusOrder = { active: 0, paused: 1, archived: 2 }
- const diff = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9)
- if (diff !== 0) return diff
- return (a.title || '').localeCompare(b.title || '', 'de')
- })
-
- const active = sorted.filter((item) => item.status === 'active')
+ const stage = activeMaturityStage(roadmapItems)
+ const stagePractices = practicesForStage(recurringItems, stage?.id)
+ const active = stagePractices.filter((item) => item.status === 'active')
+ const paused = stagePractices.filter((item) => item.status === 'paused')
const body =
- sorted.length === 0 ? (
-
- {active.length} aktive Routine{active.length === 1 ? '' : 'n'} — fällige Übung steuert - Next Action. -
- )} ++ Stufe {stage.title} — {active.length} aktiv + {paused.length > 0 ? `, ${paused.length} pausiert` : ''}. +
- Rhythmen pflegen unter{' '} + {canManage + ? 'Übungen anlegen, pausieren oder löschen unter ' + : 'Rhythmen pflegen unter '} - Kontrolle → Journey + Kontrolle → Journey → Wiederkehrend .
@@ -72,9 +92,9 @@ export function RecurringRhythmPanel({- Aktive Routinen am Reifegrad-Pfad — Leading Next aus fälliger Übung (A1). + Activity Set der aktiven Reifegrad-Stufe — nicht die Gates selbst (A1).
- Tages-Tracking — erledigt markieren, optional Messwert notieren (z. B. Grad, Wiederholungen). +
{exercise.description}
+ )} + {completedToday ? ( ++ Heute erledigt + {savedNote ? ` — ${savedNote}` : ''}
-Fällig: {formatDue(openInstance.due_at)}
+ ) : ( + exercise.open_cadence_instance?.due_at && ( ++ Fällig: {formatDue(exercise.open_cadence_instance.due_at)} +
+ ) )}Keine Berechtigung zum Markieren der Übung.
)} + + ) +} + +/** + * A1 — Heutige Übungen der aktiven Stufe (CadenceInstance je Übung). + */ +export function WorkTodayPracticePanel({ + initiativeId, + roadmapItems = [], + recurringItems = [], + canManage = false, + onCompleted, + busy = false, +}) { + const stage = useMemo(() => activeMaturityStage(roadmapItems), [roadmapItems]) + + const todayExercises = useMemo( + () => exercisesForStage(recurringItems, stage?.id), + [recurringItems, stage?.id], + ) + + const pending = todayExercises.filter((item) => !item.today_completed) + const allDone = todayExercises.length > 0 && pending.length === 0 + + return ( ++ {stage ? ( + <> + Aktive Stufe: {stage.title} — pro Übung erledigen, optional Messwert + notieren. + > + ) : ( + 'Tages-Tracking für die aktive Reifegrad-Stufe.' + )} +
++ Alle Übungen für heute erledigt — nächste Fälligkeit morgen. +
+ )} ++ Übungen verwalten unter{' '} + + Kontrolle → Journey + + . +