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 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(
|
||||
from services.maturity_practice import seed_practices_for_stage
|
||||
|
||||
exercises = seed_practices_for_stage(
|
||||
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),
|
||||
stage=stage_items[0],
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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 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(
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8,19 +8,11 @@ from db import get_connection
|
|||
from psycopg2.extras import RealDictCursor
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
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 = """
|
||||
SELECT
|
||||
'recurring_due' AS kind,
|
||||
re.title AS title,
|
||||
'Wiederkehrendes Element fällig' AS summary,
|
||||
COALESCE(NULLIF(re.description, ''), 'Heutige Übung') AS summary,
|
||||
re.initiative_id,
|
||||
re.id AS recurring_id,
|
||||
NULL::uuid AS action_id,
|
||||
|
|
@ -28,23 +20,43 @@ def recurring_due_candidates(
|
|||
'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 NOT NULL
|
||||
AND re.next_due_at <= NOW()
|
||||
ORDER BY re.next_due_at ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id, limit),
|
||||
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 cur.fetchall():
|
||||
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
|
||||
) -> 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:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,54 +12,74 @@ function formatDueAt(iso) {
|
|||
}
|
||||
}
|
||||
|
||||
function activeMaturityStage(roadmapItems = []) {
|
||||
return [...roadmapItems]
|
||||
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0]
|
||||
}
|
||||
|
||||
function practicesForStage(recurringItems = [], stageId) {
|
||||
if (!stageId) {
|
||||
return (recurringItems || []).filter((item) => item.roadmap_item_id)
|
||||
}
|
||||
return (recurringItems || []).filter((item) => item.roadmap_item_id === stageId)
|
||||
}
|
||||
|
||||
export function RecurringRhythmPanel({
|
||||
initiativeId,
|
||||
roadmapItems = [],
|
||||
recurringItems = [],
|
||||
canManage = false,
|
||||
embedded = true,
|
||||
}) {
|
||||
const sorted = [...recurringItems].sort((a, b) => {
|
||||
const statusOrder = { active: 0, paused: 1, archived: 2 }
|
||||
const diff = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9)
|
||||
if (diff !== 0) return diff
|
||||
return (a.title || '').localeCompare(b.title || '', 'de')
|
||||
})
|
||||
|
||||
const active = sorted.filter((item) => item.status === 'active')
|
||||
const stage = activeMaturityStage(roadmapItems)
|
||||
const stagePractices = practicesForStage(recurringItems, stage?.id)
|
||||
const active = stagePractices.filter((item) => item.status === 'active')
|
||||
const paused = stagePractices.filter((item) => item.status === 'paused')
|
||||
|
||||
const body =
|
||||
sorted.length === 0 ? (
|
||||
<EmptyState message="Noch keine Rhythmen — Starter-Kit oder Journey legt Übungen an." />
|
||||
!stage ? (
|
||||
<EmptyState message="Keine aktive Reifegrad-Stufe — Plan → Stufen prüfen." />
|
||||
) : stagePractices.length === 0 ? (
|
||||
<EmptyState message="Keine Übungen für diese Stufe — Starter-Kit oder Stufenwechsel prüfen." />
|
||||
) : (
|
||||
<>
|
||||
{active.length > 0 && (
|
||||
<p className="recurring-rhythm-summary muted">
|
||||
{active.length} aktive Routine{active.length === 1 ? '' : 'n'} — fällige Übung steuert
|
||||
Next Action.
|
||||
Stufe <strong>{stage.title}</strong> — {active.length} aktiv
|
||||
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
|
||||
</p>
|
||||
)}
|
||||
<ul className="item-list recurring-rhythm-list">
|
||||
{sorted.map((item) => (
|
||||
{[...active, ...paused].map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={
|
||||
'list-item card-list-item recurring-rhythm-item' +
|
||||
(item.status === 'active' ? ' recurring-rhythm-item--active' : '')
|
||||
(item.status === 'active' ? ' recurring-rhythm-item--active' : '') +
|
||||
(item.today_completed ? ' recurring-rhythm-item--done-today' : '')
|
||||
}
|
||||
>
|
||||
<div className="list-item-main">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted list-item-sub">
|
||||
{RECURRING_STATUS_LABELS[item.status] || item.status}
|
||||
{item.next_due_at ? ` · Fällig: ${formatDueAt(item.next_due_at)}` : ''}
|
||||
{item.today_completed ? ' · heute erledigt' : ''}
|
||||
{!item.today_completed && item.next_due_at
|
||||
? ` · Fällig: ${formatDueAt(item.next_due_at)}`
|
||||
: ''}
|
||||
</span>
|
||||
{item.today_measurement_note && (
|
||||
<span className="muted list-item-sub">Messwert: {item.today_measurement_note}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted recurring-rhythm-hint">
|
||||
Rhythmen pflegen unter{' '}
|
||||
{canManage
|
||||
? 'Übungen anlegen, pausieren oder löschen unter '
|
||||
: 'Rhythmen pflegen unter '}
|
||||
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
|
||||
Kontrolle → Journey
|
||||
Kontrolle → Journey → Wiederkehrend
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
@ -72,9 +92,9 @@ export function RecurringRhythmPanel({
|
|||
<section className="card recurring-rhythm-panel">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Rhythmen & Übungen</h2>
|
||||
<h2>Übungen der Stufe</h2>
|
||||
<p className="section-lead muted">
|
||||
Aktive Routinen am Reifegrad-Pfad — Leading Next aus fälliger Übung (A1).
|
||||
Activity Set der aktiven Reifegrad-Stufe — nicht die Gates selbst (A1).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { completeRecurringPractice } from '../api/recurring.js'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
import { scopedPath } from '../utils/routes.js'
|
||||
|
||||
function formatDue(iso) {
|
||||
if (!iso) return null
|
||||
|
|
@ -11,41 +13,34 @@ function formatDue(iso) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A1 — Heutige Übung (CadenceInstance): Tages-Tracking mit optionaler Messnotiz.
|
||||
*/
|
||||
export function WorkTodayPracticePanel({
|
||||
initiativeId,
|
||||
recurringItems = [],
|
||||
canManage = false,
|
||||
onCompleted,
|
||||
busy = false,
|
||||
}) {
|
||||
function activeMaturityStage(roadmapItems = []) {
|
||||
return [...roadmapItems]
|
||||
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0]
|
||||
}
|
||||
|
||||
function exercisesForStage(recurringItems = [], stageId) {
|
||||
const active = (recurringItems || []).filter((item) => item.status === 'active')
|
||||
if (!stageId) return active
|
||||
const bound = active.filter((item) => item.roadmap_item_id === stageId)
|
||||
return bound.length > 0 ? bound : active
|
||||
}
|
||||
|
||||
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
||||
const [measurement, setMeasurement] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const activePractice = useMemo(() => {
|
||||
return (recurringItems || []).find((item) => item.status === 'active')
|
||||
}, [recurringItems])
|
||||
|
||||
const openInstance = activePractice?.open_cadence_instance
|
||||
|
||||
if (!activePractice) {
|
||||
return (
|
||||
<section className="card work-today-practice" id="today-practice">
|
||||
<EmptyState message="Keine aktive Übung — Starter-Kit oder Stufenwechsel prüfen." />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const completedToday = Boolean(exercise.today_completed)
|
||||
const savedNote = exercise.today_measurement_note
|
||||
|
||||
async function handleComplete(e) {
|
||||
e.preventDefault()
|
||||
if (!canManage || submitting) return
|
||||
if (!canManage || submitting || completedToday) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await completeRecurringPractice(activePractice.id, {
|
||||
await completeRecurringPractice(exercise.id, {
|
||||
measurement_note: measurement.trim() || undefined,
|
||||
})
|
||||
setMeasurement('')
|
||||
|
|
@ -58,42 +53,115 @@ export function WorkTodayPracticePanel({
|
|||
}
|
||||
|
||||
return (
|
||||
<section className="card work-today-practice" id="today-practice">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Heutige Übung</h2>
|
||||
<p className="section-lead muted">
|
||||
Tages-Tracking — erledigt markieren, optional Messwert notieren (z. B. Grad, Wiederholungen).
|
||||
<li className="list-item card-list-item work-today-practice__item">
|
||||
<div className="list-item-main">
|
||||
<strong>{exercise.title}</strong>
|
||||
{exercise.description && (
|
||||
<p className="muted list-item-sub">{exercise.description}</p>
|
||||
)}
|
||||
{completedToday ? (
|
||||
<p className="work-today-practice__done muted">
|
||||
Heute erledigt
|
||||
{savedNote ? ` — ${savedNote}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="work-today-practice__focus">
|
||||
<strong>{activePractice.title}</strong>
|
||||
{openInstance?.due_at && (
|
||||
<p className="muted list-item-sub">Fällig: {formatDue(openInstance.due_at)}</p>
|
||||
) : (
|
||||
exercise.open_cadence_instance?.due_at && (
|
||||
<p className="muted list-item-sub">
|
||||
Fällig: {formatDue(exercise.open_cadence_instance.due_at)}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
{canManage && !completedToday && (
|
||||
<form className="inline-form-block work-today-practice__form" onSubmit={handleComplete}>
|
||||
<label>
|
||||
Messwert / Notiz (optional)
|
||||
<input
|
||||
value={measurement}
|
||||
onChange={(e) => setMeasurement(e.target.value)}
|
||||
placeholder="z. B. 45° Spagat, 3×30 Sek."
|
||||
placeholder="z. B. 45°, 3×30 Sek."
|
||||
maxLength={2000}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting || busy}>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={submitting || busy}>
|
||||
{submitting ? 'Speichern …' : 'Heute erledigt'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">Keine Berechtigung zum Markieren der Übung.</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A1 — Heutige Übungen der aktiven Stufe (CadenceInstance je Übung).
|
||||
*/
|
||||
export function WorkTodayPracticePanel({
|
||||
initiativeId,
|
||||
roadmapItems = [],
|
||||
recurringItems = [],
|
||||
canManage = false,
|
||||
onCompleted,
|
||||
busy = false,
|
||||
}) {
|
||||
const stage = useMemo(() => activeMaturityStage(roadmapItems), [roadmapItems])
|
||||
|
||||
const todayExercises = useMemo(
|
||||
() => exercisesForStage(recurringItems, stage?.id),
|
||||
[recurringItems, stage?.id],
|
||||
)
|
||||
|
||||
const pending = todayExercises.filter((item) => !item.today_completed)
|
||||
const allDone = todayExercises.length > 0 && pending.length === 0
|
||||
|
||||
return (
|
||||
<section className="card work-today-practice" id="today-practice">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Heutige Übungen</h2>
|
||||
<p className="section-lead muted">
|
||||
{stage ? (
|
||||
<>
|
||||
Aktive Stufe: <strong>{stage.title}</strong> — pro Übung erledigen, optional Messwert
|
||||
notieren.
|
||||
</>
|
||||
) : (
|
||||
'Tages-Tracking für die aktive Reifegrad-Stufe.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{todayExercises.length === 0 ? (
|
||||
<EmptyState message="Keine Übungen für die aktive Stufe — Starter-Kit oder Stufenwechsel prüfen." />
|
||||
) : (
|
||||
<>
|
||||
{allDone && (
|
||||
<p className="work-today-practice__all-done muted">
|
||||
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
||||
</p>
|
||||
)}
|
||||
<ul className="item-list work-today-practice__list">
|
||||
{todayExercises.map((exercise) => (
|
||||
<ExerciseRow
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onCompleted={onCompleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="muted work-today-practice__hint">
|
||||
Übungen verwalten unter{' '}
|
||||
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
|
||||
Kontrolle → Journey
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user