fix(A1): Übungen an Stufen binden, Gates entkoppeln, Tages-Abschluss
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 4m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

Activity Set pro Stufe statt Gate-Titel-Recurring; Single-Active-Stufe bei Reopen; Complete heute schliesst Next Action; Heute-Panel fuer alle Uebungen der aktiven Stufe.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-28 13:00:06 +02:00
parent b7d88ce24a
commit 19e962d8ec
17 changed files with 620 additions and 209 deletions

View File

@ -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;

View File

@ -65,7 +65,8 @@ def get_starter_preview(
{"kind": "maturity_stage", "title": "Stufe 2 — Aufbau", "status": "planned"}, {"kind": "maturity_stage", "title": "Stufe 2 — Aufbau", "status": "planned"},
{"kind": "maturity_stage", "title": "Stufe 3 — Ziel", "status": "planned"}, {"kind": "maturity_stage", "title": "Stufe 3 — Ziel", "status": "planned"},
{"kind": "project", "title": "Training"}, {"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": elif archetype_key == "initiative.linear_project":
items = [ items = [
@ -185,7 +186,6 @@ def _apply_a1(
user_id: Optional[str], user_id: Optional[str],
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
from services.projects import create_project, list_projects_for_initiative 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 from services.roadmap import create_roadmap_item
roadmap_ids: list[str] = [] roadmap_ids: list[str] = []
@ -194,6 +194,7 @@ def _apply_a1(
("Stufe 2 — Aufbau", "planned"), ("Stufe 2 — Aufbau", "planned"),
("Stufe 3 — Ziel", "planned"), ("Stufe 3 — Ziel", "planned"),
] ]
stage_items: list[dict[str, Any]] = []
for index, (title, status) in enumerate(stage_defs): for index, (title, status) in enumerate(stage_defs):
item = create_roadmap_item( item = create_roadmap_item(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -205,6 +206,7 @@ def _apply_a1(
user_id=user_id, user_id=user_id,
) )
roadmap_ids.append(item["id"]) roadmap_ids.append(item["id"])
stage_items.append(item)
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id) projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not _find_by_title(projects, "Training"): if not _find_by_title(projects, "Training"):
@ -217,19 +219,20 @@ def _apply_a1(
user_id=user_id, user_id=user_id,
) )
recurring = list_recurring_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id) from services.maturity_practice import seed_practices_for_stage
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,
)
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( def _apply_a2(

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, time, timedelta, timezone
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
@ -86,6 +86,23 @@ def ensure_open_instance(
return row 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( def complete_open_instance(
*, *,
tenant_id: str, tenant_id: str,
@ -144,7 +161,7 @@ def complete_open_instance(
conn.close() conn.close()
interval = recurring.get("interval_days") or 1 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( update_recurring_element(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -153,12 +170,6 @@ def complete_open_instance(
next_due_at=next_due, 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( log_audit(
"cadence_instance.completed", "cadence_instance.completed",
user_id=user_id, user_id=user_id,
@ -175,7 +186,7 @@ def complete_open_instance(
) )
return { return {
"completed_instance": completed, "completed_instance": completed,
"next_instance": next_instance, "next_due_at": next_due.isoformat(),
"recurring": updated_recurring, "recurring": updated_recurring,
} }
@ -186,6 +197,7 @@ def attach_open_instances_to_recurring_list(
if not items: if not items:
return items return items
ids = [item["id"] for item in items] ids = [item["id"] for item in items]
now = datetime.now(timezone.utc)
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: 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)) str(row["recurring_element_id"]): _serialize_row(dict(row))
for row in cur.fetchall() 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: finally:
conn.close() conn.close()
enriched = [] enriched = []
for item in items: for item in items:
copy = dict(item) 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"]) 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( open_inst = ensure_open_instance(
tenant_id=tenant_id, tenant_id=tenant_id,
recurring_element_id=item["id"], recurring_element_id=item["id"],
due_at=next_due or now,
) )
copy["open_cadence_instance"] = open_inst copy["open_cadence_instance"] = open_inst
enriched.append(copy) enriched.append(copy)

View File

@ -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],
}

View File

@ -2,15 +2,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from services.recurring import ( from services.maturity_practice import activate_stage_practice_set
create_recurring_element, from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative
list_recurring_for_initiative,
update_recurring_element,
)
from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative, update_roadmap_item
def on_maturity_stage_reached( def on_maturity_stage_reached(
@ -20,7 +15,7 @@ def on_maturity_stage_reached(
reached_item_id: str, reached_item_id: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]: ) -> 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) item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id)
if not item or item.get("item_type") != "maturity_stage": if not item or item.get("item_type") != "maturity_stage":
return None return None
@ -47,41 +42,50 @@ def on_maturity_stage_reached(
next_stage = stage next_stage = stage
break break
paused: list[str] = [] practice_transition = None
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
if next_stage: if next_stage:
from services.roadmap import update_roadmap_item
update_roadmap_item( update_roadmap_item(
tenant_id=tenant_id, tenant_id=tenant_id,
item_id=next_stage["id"], item_id=next_stage["id"],
status="active", status="active",
user_id=user_id, user_id=user_id,
) )
created_recurring = create_recurring_element( next_stage = get_roadmap_item(tenant_id=tenant_id, item_id=next_stage["id"])
tenant_id=tenant_id, if next_stage:
initiative_id=initiative_id, practice_transition = activate_stage_practice_set(
title=f"Übung — {next_stage['title']}", tenant_id=tenant_id,
description=f"Routine für {next_stage['title']} (automatisch nach Stufenübergang)", initiative_id=initiative_id,
status="active", stage=next_stage,
interval_days=1, user_id=user_id,
next_due_at=datetime.now(timezone.utc), )
user_id=user_id,
)
return { return {
"reached_stage_id": reached_item_id, "reached_stage_id": reached_item_id,
"next_stage_id": next_stage["id"] if next_stage else None, "next_stage_id": next_stage["id"] if next_stage else None,
"paused_recurring": paused, "paused_recurring": (practice_transition or {}).get("paused_practices", []),
"new_recurring_id": created_recurring["id"] if created_recurring else None, "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,
)

View File

@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"]
RECURRING_STATUSES = frozenset({"active", "paused", "ended"}) RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
_RECURRING_COLUMNS = """ _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 interval_days, next_due_at, created_at, updated_at
""" """
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row) 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): if result.get(key):
result[key] = str(result[key]) result[key] = str(result[key])
for ts_key in ("created_at", "updated_at", "next_due_at"): for ts_key in ("created_at", "updated_at", "next_due_at"):
@ -46,6 +46,7 @@ def create_recurring_element(
status: RecurringStatus = "active", status: RecurringStatus = "active",
interval_days: Optional[int] = None, interval_days: Optional[int] = None,
next_due_at: Optional[datetime] = None, next_due_at: Optional[datetime] = None,
roadmap_item_id: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
title = title.strip() title = title.strip()
@ -63,15 +64,16 @@ def create_recurring_element(
cur.execute( cur.execute(
f""" f"""
INSERT INTO recurring_elements ( 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 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} RETURNING {_RECURRING_COLUMNS}
""", """,
( (
tenant_id, tenant_id,
initiative_id, initiative_id,
roadmap_item_id,
title, title,
description, description,
status, status,
@ -162,6 +164,8 @@ def update_recurring_element(
clear_interval_days: bool = False, clear_interval_days: bool = False,
next_due_at: Optional[datetime] = None, next_due_at: Optional[datetime] = 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,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
if not existing: if not existing:
@ -196,6 +200,11 @@ def update_recurring_element(
elif next_due_at is not None: elif next_due_at is not None:
updates.append("next_due_at = %s") updates.append("next_due_at = %s")
params.append(next_due_at) 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: if not updates:
return existing return existing

View File

@ -383,6 +383,15 @@ def update_roadmap_item(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: 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( cur.execute(
f""" f"""
UPDATE roadmap_items UPDATE roadmap_items
@ -760,6 +769,15 @@ def reopen_roadmap_item(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: 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( cur.execute(
""" """
UPDATE roadmap_items UPDATE roadmap_items
@ -783,6 +801,16 @@ def reopen_roadmap_item(
finally: finally:
conn.close() 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( log_audit(
"roadmap_item.reopened", "roadmap_item.reopened",
user_id=user_id, user_id=user_id,

View File

@ -73,6 +73,15 @@ def _reopen_item_if_reached(
) -> None: ) -> None:
if item["status"] != "reached": if item["status"] != "reached":
return 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( cur.execute(
""" """
UPDATE roadmap_items UPDATE roadmap_items

View File

@ -14,10 +14,7 @@ from steering.strategies.next_action.execution_ready import (
from steering.strategies.next_action.product_milestone_driven import ( from steering.strategies.next_action.product_milestone_driven import (
ProductMilestoneDrivenStrategy, ProductMilestoneDrivenStrategy,
) )
from steering.strategies.next_action.recurring_helpers import ( from steering.strategies.next_action.recurring_helpers import recurring_due_candidates
active_recurring_candidates,
recurring_due_candidates,
)
from steering.strategies.next_action.registry import ( from steering.strategies.next_action.registry import (
get_next_action_strategy, get_next_action_strategy,
register_next_action_strategy, register_next_action_strategy,
@ -44,8 +41,6 @@ class MaturityProgressionStrategy:
return _default.evaluate(ctx, limit=limit) return _default.evaluate(ctx, limit=limit)
recurring = recurring_due_candidates(ctx, initiative_id, 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) remaining = limit - len(recurring)
gate_scope = first_active_gate_id(ctx, initiative_id) gate_scope = first_active_gate_id(ctx, initiative_id)

View File

@ -8,6 +8,46 @@ from db import get_connection
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from tenant_context import TenantContext 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( def recurring_due_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int ctx: TenantContext, initiative_id: str, *, limit: int
@ -15,36 +55,8 @@ def recurring_due_candidates(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(_PRACTICE_DUE_SQL, (ctx.tenant_id, initiative_id, limit))
""" return _serialize_recurring_rows(cur.fetchall())
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
finally: finally:
conn.close() conn.close()
@ -52,37 +64,5 @@ def recurring_due_candidates(
def active_recurring_candidates( def active_recurring_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int = 1 ctx: TenantContext, initiative_id: str, *, limit: int = 1
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Active recurring as 'heutige Übung' when nothing is due yet.""" """Due practices only — no fallback when already completed today."""
conn = get_connection() return recurring_due_candidates(ctx, initiative_id, limit=limit)
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()

View File

@ -37,14 +37,15 @@ def test_complete_daily_practice_advances_cadence(client):
body = completed.json() body = completed.json()
assert body["completed_instance"]["status"] == "completed" assert body["completed_instance"]["status"] == "completed"
assert body["completed_instance"]["measurement_note"] == "45° Spagat gehalten" 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"] assert body["recurring"]["next_due_at"]
refreshed = client.get( refreshed = client.get(
f"/api/initiatives/{initiative_id}/recurring", f"/api/initiatives/{initiative_id}/recurring",
headers=_auth(token), headers=_auth(token),
).json() ).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): 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"] recurring_items = [a for a in next_actions if a.get("kind") == "recurring_due"]
assert recurring_items assert recurring_items
assert recurring_items[0].get("recurring_id") 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

View File

@ -51,7 +51,8 @@ def test_create_initiative_a1_applies_starter_kit(client):
headers=_auth(token), headers=_auth(token),
) )
assert recurring.status_code == 200 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): def test_create_initiative_b2b_starter_kit(client):

View File

@ -6,7 +6,7 @@ from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login from tests.test_initiatives_actions import _auth, _create_initiative, _login
_STAGE1 = "Stufe 1 — Basis" _STAGE1 = "Stufe 1 — Basis"
_STARTER_RECURRING = "Tägliche Übung" _STAGE1_EXERCISES = ("Vorbeuge-Dehnung", "Hüftöffner (Schmetterling)")
def _create_spagat(client, token): def _create_spagat(client, token):
@ -54,7 +54,8 @@ def test_a1_starter_kit_spagat_structure(client):
headers=_auth(token), headers=_auth(token),
) )
assert recurring.status_code == 200 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( projects = client.get(
f"/api/initiatives/{initiative_id}/projects", f"/api/initiatives/{initiative_id}/projects",
@ -122,8 +123,11 @@ def test_a1_stage_verify_rotates_recurring_and_next_action(client):
headers=_auth(token), headers=_auth(token),
).json() ).json()
titles = {r["title"]: r["status"] for r in recurring} titles = {r["title"]: r["status"] for r in recurring}
assert titles.get(_STARTER_RECURRING) == "paused" for title in _STAGE1_EXERCISES:
assert any(t.startswith("Übung — Stufe 2") and titles[t] == "active" for t in titles) assert titles.get(title) == "paused"
assert any(
t.startswith("Seitlicher Spagat") and titles[t] == "active" for t in titles
)
items_after = client.get( items_after = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items", 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 assert snap_after.status_code == 200
next_after = snap_after.json().get("next_actions") or [] next_after = snap_after.json().get("next_actions") or []
assert next_after assert next_after
assert next_after[0].get("kind") == "recurring_due" or next_after[0].get("title", "").startswith( assert next_after[0].get("kind") == "recurring_due" or next_after[0].get("title") in (
"Übung —" "Seitlicher Spagat — links",
"Seitlicher Spagat — rechts",
"Aktive Vorbeuge",
) )

View File

@ -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({ export function RecurringRhythmPanel({
initiativeId, initiativeId,
roadmapItems = [],
recurringItems = [], recurringItems = [],
canManage = false,
embedded = true, embedded = true,
}) { }) {
const sorted = [...recurringItems].sort((a, b) => { const stage = activeMaturityStage(roadmapItems)
const statusOrder = { active: 0, paused: 1, archived: 2 } const stagePractices = practicesForStage(recurringItems, stage?.id)
const diff = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9) const active = stagePractices.filter((item) => item.status === 'active')
if (diff !== 0) return diff const paused = stagePractices.filter((item) => item.status === 'paused')
return (a.title || '').localeCompare(b.title || '', 'de')
})
const active = sorted.filter((item) => item.status === 'active')
const body = const body =
sorted.length === 0 ? ( !stage ? (
<EmptyState message="Noch keine Rhythmen — Starter-Kit oder Journey legt Übungen an." /> <EmptyState message="Keine aktive Reifegrad-Stufe — Plan → Stufen prüfen." />
) : stagePractices.length === 0 ? (
<EmptyState message="Keine Übungen für diese Stufe — Starter-Kit oder Stufenwechsel prüfen." />
) : ( ) : (
<> <>
{active.length > 0 && ( <p className="recurring-rhythm-summary muted">
<p className="recurring-rhythm-summary muted"> Stufe <strong>{stage.title}</strong> {active.length} aktiv
{active.length} aktive Routine{active.length === 1 ? '' : 'n'} fällige Übung steuert {paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
Next Action. </p>
</p>
)}
<ul className="item-list recurring-rhythm-list"> <ul className="item-list recurring-rhythm-list">
{sorted.map((item) => ( {[...active, ...paused].map((item) => (
<li <li
key={item.id} key={item.id}
className={ className={
'list-item card-list-item recurring-rhythm-item' + 'list-item card-list-item recurring-rhythm-item' +
(item.status === 'active' ? ' recurring-rhythm-item--active' : '') (item.status === 'active' ? ' recurring-rhythm-item--active' : '') +
(item.today_completed ? ' recurring-rhythm-item--done-today' : '')
} }
> >
<div className="list-item-main"> <div className="list-item-main">
<strong>{item.title}</strong> <strong>{item.title}</strong>
<span className="muted list-item-sub"> <span className="muted list-item-sub">
{RECURRING_STATUS_LABELS[item.status] || item.status} {RECURRING_STATUS_LABELS[item.status] || item.status}
{item.next_due_at ? ` · Fällig: ${formatDueAt(item.next_due_at)}` : ''} {item.today_completed ? ' · heute erledigt' : ''}
{!item.today_completed && item.next_due_at
? ` · Fällig: ${formatDueAt(item.next_due_at)}`
: ''}
</span> </span>
{item.today_measurement_note && (
<span className="muted list-item-sub">Messwert: {item.today_measurement_note}</span>
)}
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
<p className="muted recurring-rhythm-hint"> <p className="muted recurring-rhythm-hint">
Rhythmen pflegen unter{' '} {canManage
? 'Übungen anlegen, pausieren oder löschen unter '
: 'Rhythmen pflegen unter '}
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline"> <Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
Kontrolle Journey Kontrolle Journey Wiederkehrend
</Link> </Link>
. .
</p> </p>
@ -72,9 +92,9 @@ export function RecurringRhythmPanel({
<section className="card recurring-rhythm-panel"> <section className="card recurring-rhythm-panel">
<div className="section-header"> <div className="section-header">
<div> <div>
<h2>Rhythmen & Übungen</h2> <h2>Übungen der Stufe</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Aktive Routinen am Reifegrad-Pfad Leading Next aus fälliger Übung (A1). Activity Set der aktiven Reifegrad-Stufe nicht die Gates selbst (A1).
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,6 +1,8 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { completeRecurringPractice } from '../api/recurring.js' import { completeRecurringPractice } from '../api/recurring.js'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
import { scopedPath } from '../utils/routes.js'
function formatDue(iso) { function formatDue(iso) {
if (!iso) return null if (!iso) return null
@ -11,41 +13,34 @@ function formatDue(iso) {
} }
} }
/** function activeMaturityStage(roadmapItems = []) {
* A1 Heutige Übung (CadenceInstance): Tages-Tracking mit optionaler Messnotiz. return [...roadmapItems]
*/ .filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
export function WorkTodayPracticePanel({ .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0]
initiativeId, }
recurringItems = [],
canManage = false, function exercisesForStage(recurringItems = [], stageId) {
onCompleted, const active = (recurringItems || []).filter((item) => item.status === 'active')
busy = false, if (!stageId) return active
}) { const bound = active.filter((item) => item.roadmap_item_id === stageId)
return bound.length > 0 ? bound : active
}
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
const [measurement, setMeasurement] = useState('') const [measurement, setMeasurement] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const activePractice = useMemo(() => { const completedToday = Boolean(exercise.today_completed)
return (recurringItems || []).find((item) => item.status === 'active') const savedNote = exercise.today_measurement_note
}, [recurringItems])
const openInstance = activePractice?.open_cadence_instance
if (!activePractice) {
return (
<section className="card work-today-practice" id="today-practice">
<EmptyState message="Keine aktive Übung — Starter-Kit oder Stufenwechsel prüfen." />
</section>
)
}
async function handleComplete(e) { async function handleComplete(e) {
e.preventDefault() e.preventDefault()
if (!canManage || submitting) return if (!canManage || submitting || completedToday) return
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
try { try {
await completeRecurringPractice(activePractice.id, { await completeRecurringPractice(exercise.id, {
measurement_note: measurement.trim() || undefined, measurement_note: measurement.trim() || undefined,
}) })
setMeasurement('') setMeasurement('')
@ -58,42 +53,115 @@ export function WorkTodayPracticePanel({
} }
return ( return (
<section className="card work-today-practice" id="today-practice"> <li className="list-item card-list-item work-today-practice__item">
<div className="section-header"> <div className="list-item-main">
<div> <strong>{exercise.title}</strong>
<h2>Heutige Übung</h2> {exercise.description && (
<p className="section-lead muted"> <p className="muted list-item-sub">{exercise.description}</p>
Tages-Tracking erledigt markieren, optional Messwert notieren (z.B. Grad, Wiederholungen). )}
{completedToday ? (
<p className="work-today-practice__done muted">
Heute erledigt
{savedNote ? `${savedNote}` : ''}
</p> </p>
</div> ) : (
</div> exercise.open_cadence_instance?.due_at && (
<p className="muted list-item-sub">
<div className="work-today-practice__focus"> Fällig: {formatDue(exercise.open_cadence_instance.due_at)}
<strong>{activePractice.title}</strong> </p>
{openInstance?.due_at && ( )
<p className="muted list-item-sub">Fällig: {formatDue(openInstance.due_at)}</p>
)} )}
</div> </div>
{canManage && !completedToday && (
{canManage ? (
<form className="inline-form-block work-today-practice__form" onSubmit={handleComplete}> <form className="inline-form-block work-today-practice__form" onSubmit={handleComplete}>
<label> <label>
Messwert / Notiz (optional) Messwert / Notiz (optional)
<input <input
value={measurement} value={measurement}
onChange={(e) => setMeasurement(e.target.value)} onChange={(e) => setMeasurement(e.target.value)}
placeholder="z.B. 45° Spagat, 3×30 Sek." placeholder="z. B. 45°, 3×30 Sek."
maxLength={2000} maxLength={2000}
/> />
</label> </label>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<button type="submit" className="btn btn-primary" disabled={submitting || busy}> <button type="submit" className="btn btn-primary btn-sm" disabled={submitting || busy}>
{submitting ? 'Speichern …' : 'Heute erledigt'} {submitting ? 'Speichern …' : 'Heute erledigt'}
</button> </button>
</form> </form>
) : (
<p className="muted">Keine Berechtigung zum Markieren der Übung.</p>
)} )}
</li>
)
}
/**
* 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 (
<section className="card work-today-practice" id="today-practice">
<div className="section-header">
<div>
<h2>Heutige Übungen</h2>
<p className="section-lead muted">
{stage ? (
<>
Aktive Stufe: <strong>{stage.title}</strong> pro Übung erledigen, optional Messwert
notieren.
</>
) : (
'Tages-Tracking für die aktive Reifegrad-Stufe.'
)}
</p>
</div>
</div>
{todayExercises.length === 0 ? (
<EmptyState message="Keine Übungen für die aktive Stufe — Starter-Kit oder Stufenwechsel prüfen." />
) : (
<>
{allDone && (
<p className="work-today-practice__all-done muted">
Alle Übungen für heute erledigt nächste Fälligkeit morgen.
</p>
)}
<ul className="item-list work-today-practice__list">
{todayExercises.map((exercise) => (
<ExerciseRow
key={exercise.id}
exercise={exercise}
canManage={canManage}
busy={busy}
onCompleted={onCompleted}
/>
))}
</ul>
</>
)}
<p className="muted work-today-practice__hint">
Übungen verwalten unter{' '}
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
Kontrolle Journey
</Link>
.
</p>
</section> </section>
) )
} }

View File

@ -199,11 +199,14 @@ export function buildProviderProps(provider, input) {
case 'RecurringRhythmPanel': case 'RecurringRhythmPanel':
return { return {
initiativeId: ops.initiativeId, initiativeId: ops.initiativeId,
roadmapItems: ops.roadmapItems || [],
recurringItems: ops.recurringItems || [], recurringItems: ops.recurringItems || [],
canManage: capabilities.has('kairo.recurring.manage'),
} }
case 'WorkTodayPracticePanel': case 'WorkTodayPracticePanel':
return { return {
initiativeId: ops.initiativeId, initiativeId: ops.initiativeId,
roadmapItems: ops.roadmapItems || [],
recurringItems: ops.recurringItems || [], recurringItems: ops.recurringItems || [],
canManage: capabilities.has('kairo.recurring.manage'), canManage: capabilities.has('kairo.recurring.manage'),
busy: ops.formBusy, busy: ops.formBusy,

View File

@ -287,7 +287,8 @@ describe('resolveSteeringComposition', () => {
const props = buildProviderProps(provider, { const props = buildProviderProps(provider, {
opsContext: { opsContext: {
initiativeId: 'init-1', initiativeId: 'init-1',
recurringItems: [{ id: 'rec-1', status: 'active', title: 'Spagat' }], roadmapItems: [{ id: 'stage-1', item_type: 'maturity_stage', status: 'active', title: 'Stufe 1' }],
recurringItems: [{ id: 'rec-1', status: 'active', title: 'Vorbeuge', roadmap_item_id: 'stage-1' }],
formBusy: false, formBusy: false,
refreshOm, refreshOm,
}, },