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

View File

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

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 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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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):

View File

@ -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",
)

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({
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 ? (
<EmptyState message="Noch keine Rhythmen — Starter-Kit oder Journey legt Übungen an." />
!stage ? (
<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">
{active.length} aktive Routine{active.length === 1 ? '' : 'n'} fällige Übung steuert
Next Action.
</p>
)}
<p className="recurring-rhythm-summary muted">
Stufe <strong>{stage.title}</strong> {active.length} aktiv
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
</p>
<ul className="item-list recurring-rhythm-list">
{sorted.map((item) => (
{[...active, ...paused].map((item) => (
<li
key={item.id}
className={
'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">
<strong>{item.title}</strong>
<span className="muted list-item-sub">
{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>
{item.today_measurement_note && (
<span className="muted list-item-sub">Messwert: {item.today_measurement_note}</span>
)}
</div>
</li>
))}
</ul>
<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">
Kontrolle Journey
Kontrolle Journey Wiederkehrend
</Link>
.
</p>
@ -72,9 +92,9 @@ export function RecurringRhythmPanel({
<section className="card recurring-rhythm-panel">
<div className="section-header">
<div>
<h2>Rhythmen & Übungen</h2>
<h2>Übungen der Stufe</h2>
<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>
</div>
</div>

View File

@ -1,6 +1,8 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { completeRecurringPractice } from '../api/recurring.js'
import { EmptyState } from './EmptyState.jsx'
import { scopedPath } from '../utils/routes.js'
function formatDue(iso) {
if (!iso) return null
@ -11,41 +13,34 @@ function formatDue(iso) {
}
}
/**
* A1 Heutige Übung (CadenceInstance): Tages-Tracking mit optionaler Messnotiz.
*/
export function WorkTodayPracticePanel({
initiativeId,
recurringItems = [],
canManage = false,
onCompleted,
busy = false,
}) {
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 exercisesForStage(recurringItems = [], stageId) {
const active = (recurringItems || []).filter((item) => item.status === 'active')
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 [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const activePractice = useMemo(() => {
return (recurringItems || []).find((item) => item.status === 'active')
}, [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>
)
}
const completedToday = Boolean(exercise.today_completed)
const savedNote = exercise.today_measurement_note
async function handleComplete(e) {
e.preventDefault()
if (!canManage || submitting) return
if (!canManage || submitting || completedToday) return
setSubmitting(true)
setError(null)
try {
await completeRecurringPractice(activePractice.id, {
await completeRecurringPractice(exercise.id, {
measurement_note: measurement.trim() || undefined,
})
setMeasurement('')
@ -58,42 +53,115 @@ export function WorkTodayPracticePanel({
}
return (
<section className="card work-today-practice" id="today-practice">
<div className="section-header">
<div>
<h2>Heutige Übung</h2>
<p className="section-lead muted">
Tages-Tracking erledigt markieren, optional Messwert notieren (z.B. Grad, Wiederholungen).
<li className="list-item card-list-item work-today-practice__item">
<div className="list-item-main">
<strong>{exercise.title}</strong>
{exercise.description && (
<p className="muted list-item-sub">{exercise.description}</p>
)}
{completedToday ? (
<p className="work-today-practice__done muted">
Heute erledigt
{savedNote ? `${savedNote}` : ''}
</p>
</div>
</div>
<div className="work-today-practice__focus">
<strong>{activePractice.title}</strong>
{openInstance?.due_at && (
<p className="muted list-item-sub">Fällig: {formatDue(openInstance.due_at)}</p>
) : (
exercise.open_cadence_instance?.due_at && (
<p className="muted list-item-sub">
Fällig: {formatDue(exercise.open_cadence_instance.due_at)}
</p>
)
)}
</div>
{canManage ? (
{canManage && !completedToday && (
<form className="inline-form-block work-today-practice__form" onSubmit={handleComplete}>
<label>
Messwert / Notiz (optional)
<input
value={measurement}
onChange={(e) => setMeasurement(e.target.value)}
placeholder="z.B. 45° Spagat, 3×30 Sek."
placeholder="z. B. 45°, 3×30 Sek."
maxLength={2000}
/>
</label>
{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'}
</button>
</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>
)
}

View File

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

View File

@ -287,7 +287,8 @@ describe('resolveSteeringComposition', () => {
const props = buildProviderProps(provider, {
opsContext: {
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,
refreshOm,
},