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 ? ( - + !stage ? ( + + ) : stagePractices.length === 0 ? ( + ) : ( <> - {active.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` : ''}. +

    - {sorted.map((item) => ( + {[...active, ...paused].map((item) => (
  • {item.title} {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)}` + : ''} + {item.today_measurement_note && ( + Messwert: {item.today_measurement_note} + )}
  • ))}

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

Rhythmen & Übungen

+

Übungen der Stufe

- Aktive Routinen am Reifegrad-Pfad — Leading Next aus fälliger Übung (A1). + Activity Set der aktiven Reifegrad-Stufe — nicht die Gates selbst (A1).

diff --git a/frontend/src/components/WorkTodayPracticePanel.jsx b/frontend/src/components/WorkTodayPracticePanel.jsx index 56900ad..ad36453 100644 --- a/frontend/src/components/WorkTodayPracticePanel.jsx +++ b/frontend/src/components/WorkTodayPracticePanel.jsx @@ -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 ( -
- -
- ) - } + 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 ( -
-
-
-

Heutige Übung

-

- Tages-Tracking — erledigt markieren, optional Messwert notieren (z. B. Grad, Wiederholungen). +

  • +
    + {exercise.title} + {exercise.description && ( +

    {exercise.description}

    + )} + {completedToday ? ( +

    + Heute erledigt + {savedNote ? ` — ${savedNote}` : ''}

    -
    -
  • - -
    - {activePractice.title} - {openInstance?.due_at && ( -

    Fällig: {formatDue(openInstance.due_at)}

    + ) : ( + exercise.open_cadence_instance?.due_at && ( +

    + Fällig: {formatDue(exercise.open_cadence_instance.due_at)} +

    + ) )}
    - - {canManage ? ( + {canManage && !completedToday && (
    {error &&

    {error}

    } -
    - ) : ( -

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

    Heutige Übungen

    +

    + {stage ? ( + <> + Aktive Stufe: {stage.title} — pro Übung erledigen, optional Messwert + notieren. + + ) : ( + 'Tages-Tracking für die aktive Reifegrad-Stufe.' + )} +

    +
    +
    + + {todayExercises.length === 0 ? ( + + ) : ( + <> + {allDone && ( +

    + Alle Übungen für heute erledigt — nächste Fälligkeit morgen. +

    + )} +
      + {todayExercises.map((exercise) => ( + + ))} +
    + + )} + +

    + Übungen verwalten unter{' '} + + Kontrolle → Journey + + . +

    ) } diff --git a/frontend/src/composition/resolveSteeringComposition.js b/frontend/src/composition/resolveSteeringComposition.js index da6ab65..1c5761e 100644 --- a/frontend/src/composition/resolveSteeringComposition.js +++ b/frontend/src/composition/resolveSteeringComposition.js @@ -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, diff --git a/frontend/src/composition/resolveSteeringComposition.test.js b/frontend/src/composition/resolveSteeringComposition.test.js index d6ea736..0efd3fd 100644 --- a/frontend/src/composition/resolveSteeringComposition.test.js +++ b/frontend/src/composition/resolveSteeringComposition.test.js @@ -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, },