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
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:
parent
b7d88ce24a
commit
19e962d8ec
9
backend/migrations/031_recurring_stage_binding.sql
Normal file
9
backend/migrations/031_recurring_stage_binding.sql
Normal 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;
|
||||||
|
|
@ -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(
|
exercises = seed_practices_for_stage(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
title="Tägliche Übung",
|
stage=stage_items[0],
|
||||||
description="Regelmäßige Übung für die aktuelle Reifegrad-Stufe",
|
|
||||||
interval_days=1,
|
|
||||||
next_due_at=datetime.now(timezone.utc),
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"roadmap_items": roadmap_ids, "projects": ["Training"], "recurring": ["Tägliche Übung"]}
|
return {
|
||||||
|
"roadmap_items": roadmap_ids,
|
||||||
|
"projects": ["Training"],
|
||||||
|
"recurring": [e["title"] for e in exercises],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _apply_a2(
|
def _apply_a2(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
191
backend/services/maturity_practice.py
Normal file
191
backend/services/maturity_practice.py
Normal 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],
|
||||||
|
}
|
||||||
|
|
@ -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"])
|
||||||
|
if next_stage:
|
||||||
|
practice_transition = activate_stage_practice_set(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
title=f"Übung — {next_stage['title']}",
|
stage=next_stage,
|
||||||
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,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,11 @@ 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 = """
|
||||||
def recurring_due_candidates(
|
|
||||||
ctx: TenantContext, initiative_id: str, *, limit: int
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
'recurring_due' AS kind,
|
'recurring_due' AS kind,
|
||||||
re.title AS title,
|
re.title AS title,
|
||||||
'Wiederkehrendes Element fällig' AS summary,
|
COALESCE(NULLIF(re.description, ''), 'Heutige Übung') AS summary,
|
||||||
re.initiative_id,
|
re.initiative_id,
|
||||||
re.id AS recurring_id,
|
re.id AS recurring_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
|
|
@ -28,23 +20,43 @@ def recurring_due_candidates(
|
||||||
'recurring_due' AS reason_code,
|
'recurring_due' AS reason_code,
|
||||||
'Übung heute erledigen' AS recommended_action
|
'Übung heute erledigen' AS recommended_action
|
||||||
FROM recurring_elements re
|
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
|
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||||
AND re.status = 'active'
|
AND re.status = 'active'
|
||||||
AND re.next_due_at IS NOT NULL
|
AND (re.next_due_at IS NULL OR re.next_due_at <= NOW())
|
||||||
AND re.next_due_at <= NOW()
|
AND (re.roadmap_item_id IS NULL OR (ri.status = 'active' AND ri.item_type = 'maturity_stage'))
|
||||||
ORDER BY re.next_due_at ASC
|
AND NOT EXISTS (
|
||||||
LIMIT %s
|
SELECT 1 FROM cadence_instances ci
|
||||||
""",
|
WHERE ci.recurring_element_id = re.id
|
||||||
(ctx.tenant_id, initiative_id, limit),
|
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]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for row in cur.fetchall():
|
for row in rows:
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["initiative_id"] = str(item["initiative_id"])
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
if item.get("recurring_id"):
|
if item.get("recurring_id"):
|
||||||
item["recurring_id"] = str(item["recurring_id"])
|
item["recurring_id"] = str(item["recurring_id"])
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def recurring_due_candidates(
|
||||||
|
ctx: TenantContext, initiative_id: str, *, limit: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(_PRACTICE_DUE_SQL, (ctx.tenant_id, initiative_id, limit))
|
||||||
|
return _serialize_recurring_rows(cur.fetchall())
|
||||||
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()
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
{active.length} aktive Routine{active.length === 1 ? '' : 'n'} — fällige Übung steuert
|
Stufe <strong>{stage.title}</strong> — {active.length} aktiv
|
||||||
Next Action.
|
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user