feat(A1): Übungen als AP mit Wochentagsplan
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 5m3s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 5m3s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
Gate- und Kit-Übungen legen ein Arbeitspaket plus Schedule an; Today zeigt nur fällige Wochentage, nicht die ganze Gate-Liste. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ea0aa18b8d
commit
a05bd34af3
|
|
@ -354,34 +354,32 @@ def create_gate_practice(
|
||||||
body: GatePracticeCreateRequest,
|
body: GatePracticeCreateRequest,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||||
):
|
):
|
||||||
from services import recurring as recurring_service
|
|
||||||
|
|
||||||
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||||
sched = body.schedule
|
sched = body.schedule
|
||||||
try:
|
try:
|
||||||
|
from services.scheduled_work import create_scheduled_action_at_gate
|
||||||
|
|
||||||
if sched:
|
if sched:
|
||||||
return recurring_service.create_recurring_element(
|
return create_scheduled_action_at_gate(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
|
gate_id=item_id,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
user_id=ctx.user_id,
|
||||||
schedule_kind=sched.schedule_kind,
|
schedule_kind=sched.schedule_kind,
|
||||||
interval_days=sched.interval_days,
|
interval_days=sched.interval_days,
|
||||||
weekday_mask=sched.weekday_mask,
|
weekday_mask=sched.weekday_mask,
|
||||||
pause_until=sched.pause_until_date(),
|
pause_until=sched.pause_until_date(),
|
||||||
roadmap_item_id=item_id,
|
|
||||||
user_id=ctx.user_id,
|
|
||||||
)
|
)
|
||||||
return recurring_service.create_recurring_element(
|
return create_scheduled_action_at_gate(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
|
gate_id=item_id,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
|
||||||
interval_days=body.interval_days,
|
|
||||||
roadmap_item_id=item_id,
|
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
|
schedule_kind="weekdays",
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from services.recurring import (
|
from services.recurring import (
|
||||||
create_recurring_element,
|
|
||||||
list_recurring_for_initiative,
|
list_recurring_for_initiative,
|
||||||
update_recurring_element,
|
update_recurring_element,
|
||||||
)
|
)
|
||||||
|
|
@ -14,28 +13,28 @@ from services.recurring import (
|
||||||
# Starter exercise sets per stage title (Spagat reference path).
|
# Starter exercise sets per stage title (Spagat reference path).
|
||||||
STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
|
STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
|
||||||
"Stufe 1 — Basis": [
|
"Stufe 1 — Basis": [
|
||||||
("Vorbeuge-Dehnung", "Täglich 3×30 Sek. — Hüfte und Beinrückseite"),
|
("Vorbeuge-Dehnung", "3×30 Sek. — Hüfte und Beinrückseite"),
|
||||||
("Hüftöffner (Schmetterling)", "Täglich sitzend, leichter Druck"),
|
("Hüftöffner (Schmetterling)", "Sitzend, leichter Druck"),
|
||||||
],
|
],
|
||||||
"Stufe 2 — Aufbau": [
|
"Stufe 2 — Aufbau": [
|
||||||
("Seitlicher Spagat — links", "Täglich halten, optional Messwert (Grad)"),
|
("Seitlicher Spagat — links", "Halten, optional Messwert (Grad)"),
|
||||||
("Seitlicher Spagat — rechts", "Täglich halten, optional Messwert (Grad)"),
|
("Seitlicher Spagat — rechts", "Halten, optional Messwert (Grad)"),
|
||||||
("Aktive Vorbeuge", "Täglich 3× kurz halten"),
|
("Aktive Vorbeuge", "3× kurz halten"),
|
||||||
],
|
],
|
||||||
"Stufe 3 — Ziel": [
|
"Stufe 3 — Ziel": [
|
||||||
("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"),
|
("Voller Spagat", "Zielposition — Messwert dokumentieren"),
|
||||||
],
|
],
|
||||||
"Dehnung — Einstieg": [
|
"Dehnung — Einstieg": [
|
||||||
("Vorbeuge halten", "Täglich 3×30 Sek."),
|
("Vorbeuge halten", "3×30 Sek."),
|
||||||
],
|
],
|
||||||
"Hüftrotation — Einstieg": [
|
"Hüftrotation — Einstieg": [
|
||||||
("Hüftkreisen im Stand", "Täglich 10 Wiederholungen je Richtung"),
|
("Hüftkreisen im Stand", "10 Wiederholungen je Richtung"),
|
||||||
],
|
],
|
||||||
"Dehnung — nach Join": [
|
"Dehnung — nach Join": [
|
||||||
("Enddehnung Mawashi-Vorbereitung", "Täglich 2×45 Sek."),
|
("Enddehnung Mawashi-Vorbereitung", "2×45 Sek."),
|
||||||
],
|
],
|
||||||
"Hüftrotation — nach Join": [
|
"Hüftrotation — nach Join": [
|
||||||
("Explosive Hüftrotation", "Täglich 3×8 Wiederholungen"),
|
("Explosive Hüftrotation", "3×8 Wiederholungen"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +174,7 @@ def seed_practices_for_stage(
|
||||||
defs = [
|
defs = [
|
||||||
(
|
(
|
||||||
f"Übung — {stage_title}",
|
f"Übung — {stage_title}",
|
||||||
f"Tägliche Routine für {stage_title}",
|
f"Routine für {stage_title}",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -188,6 +187,8 @@ def seed_practices_for_stage(
|
||||||
created: list[dict[str, Any]] = []
|
created: list[dict[str, Any]] = []
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
from services.scheduled_work import create_scheduled_action_at_gate
|
||||||
|
|
||||||
for title, description in defs:
|
for title, description in defs:
|
||||||
if title in existing_by_title:
|
if title in existing_by_title:
|
||||||
item = existing_by_title[title]
|
item = existing_by_title[title]
|
||||||
|
|
@ -205,16 +206,14 @@ def seed_practices_for_stage(
|
||||||
created.append(item)
|
created.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
row = create_recurring_element(
|
row = create_scheduled_action_at_gate(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
gate_id=stage_id,
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
status="active",
|
|
||||||
interval_days=1,
|
|
||||||
next_due_at=now,
|
|
||||||
roadmap_item_id=stage_id,
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
schedule_kind="weekdays",
|
||||||
)
|
)
|
||||||
created.append(row)
|
created.append(row)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,17 @@ def delete_recurring_element(
|
||||||
recurring_id: str,
|
recurring_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
action_id = existing.get("action_id")
|
||||||
|
if action_id:
|
||||||
|
from services.actions import delete_action
|
||||||
|
|
||||||
|
return delete_action(
|
||||||
|
tenant_id=tenant_id, action_id=action_id, user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ def weekday_bit(weekday: int) -> int:
|
||||||
return 1 << weekday
|
return 1 << weekday
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_mask_for_date(on_date: date) -> int:
|
||||||
|
return weekday_bit(on_date.weekday())
|
||||||
|
|
||||||
|
|
||||||
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"):
|
for key in ("id", "tenant_id"):
|
||||||
|
|
@ -63,8 +67,6 @@ def format_schedule_label(schedule: dict[str, Any]) -> str:
|
||||||
days = max(int(schedule.get("interval_days") or 1), 1)
|
days = max(int(schedule.get("interval_days") or 1), 1)
|
||||||
if days == 1:
|
if days == 1:
|
||||||
label = "Täglich"
|
label = "Täglich"
|
||||||
elif days == 7:
|
|
||||||
label = "Wöchentlich"
|
|
||||||
else:
|
else:
|
||||||
label = f"Alle {days} Tage"
|
label = f"Alle {days} Tage"
|
||||||
pause = schedule.get("pause_until")
|
pause = schedule.get("pause_until")
|
||||||
|
|
@ -154,6 +156,36 @@ def next_due_after_completion(
|
||||||
return _as_utc_midnight(next_date)
|
return _as_utc_midnight(next_date)
|
||||||
|
|
||||||
|
|
||||||
|
def is_due_on_date(
|
||||||
|
*,
|
||||||
|
schedule: Optional[dict[str, Any]] = None,
|
||||||
|
next_due_at: Optional[datetime] = None,
|
||||||
|
on_date: Optional[date] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True if the series should appear as due on on_date (includes overdue)."""
|
||||||
|
on_date = on_date or date.today()
|
||||||
|
schedule = schedule or {}
|
||||||
|
if is_paused_on(schedule, on_date):
|
||||||
|
return False
|
||||||
|
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
if isinstance(next_due_at, datetime):
|
||||||
|
due_date = next_due_at.date()
|
||||||
|
elif next_due_at is not None:
|
||||||
|
parsed = datetime.fromisoformat(str(next_due_at).replace("Z", "+00:00"))
|
||||||
|
due_date = parsed.date()
|
||||||
|
|
||||||
|
if due_date is not None and due_date > on_date:
|
||||||
|
return False
|
||||||
|
|
||||||
|
kind = schedule.get("schedule_kind") or "interval"
|
||||||
|
if kind == "weekdays":
|
||||||
|
if due_date is not None:
|
||||||
|
return True
|
||||||
|
return _weekday_matches(schedule, on_date)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def create_schedule(
|
def create_schedule(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
|
||||||
|
|
@ -260,3 +260,50 @@ def clear_action_schedule(
|
||||||
details={"action_id": action_id},
|
details={"action_id": action_id},
|
||||||
)
|
)
|
||||||
return {"action_id": action_id, "schedule": None, "recurring_id": None}
|
return {"action_id": action_id, "schedule": None, "recurring_id": None}
|
||||||
|
|
||||||
|
|
||||||
|
def create_scheduled_action_at_gate(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
gate_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
schedule_kind: str = "weekdays",
|
||||||
|
interval_days: Optional[int] = 1,
|
||||||
|
weekday_mask: int = 0,
|
||||||
|
pause_until=None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Plan a repeating AP at a work-gate. RecurringElement is the cadence bridge."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from services.actions import create_action
|
||||||
|
from services.schedule import weekday_mask_for_date
|
||||||
|
|
||||||
|
if schedule_kind == "weekdays" and weekday_mask <= 0:
|
||||||
|
weekday_mask = weekday_mask_for_date(date.today())
|
||||||
|
|
||||||
|
action = create_action(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
status="open",
|
||||||
|
roadmap_item_id=gate_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
bundle = upsert_action_schedule(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
action_id=str(action["id"]),
|
||||||
|
user_id=user_id,
|
||||||
|
schedule_kind=schedule_kind,
|
||||||
|
interval_days=interval_days,
|
||||||
|
weekday_mask=weekday_mask,
|
||||||
|
pause_until=pause_until,
|
||||||
|
)
|
||||||
|
recurring = bundle.get("recurring")
|
||||||
|
if not recurring:
|
||||||
|
raise ValueError("Cadence-Brücke für Arbeitspaket fehlt")
|
||||||
|
return recurring
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,15 @@ def test_gate_practice_create_and_list(client):
|
||||||
json={
|
json={
|
||||||
"title": "Vorbeuge halten",
|
"title": "Vorbeuge halten",
|
||||||
"description": "3×30 Sek.",
|
"description": "3×30 Sek.",
|
||||||
"interval_days": 1,
|
|
||||||
},
|
},
|
||||||
headers=_auth(token),
|
headers=_auth(token),
|
||||||
)
|
)
|
||||||
assert created_practice.status_code == 201
|
assert created_practice.status_code == 201
|
||||||
body = created_practice.json()
|
body = created_practice.json()
|
||||||
assert body["roadmap_item_id"] == gate_id
|
assert body["roadmap_item_id"] == gate_id
|
||||||
assert body["interval_days"] == 1
|
assert body.get("action_id")
|
||||||
|
assert (body.get("schedule") or {}).get("schedule_kind") == "weekdays"
|
||||||
|
assert body["schedule"]["weekday_mask"] > 0
|
||||||
|
|
||||||
listed = client.get(
|
listed = client.get(
|
||||||
f"/api/roadmap-items/{gate_id}/practices",
|
f"/api/roadmap-items/{gate_id}/practices",
|
||||||
|
|
@ -59,6 +60,58 @@ def test_gate_practice_create_and_list(client):
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
assert len(listed.json()) == 1
|
assert len(listed.json()) == 1
|
||||||
assert listed.json()[0]["title"] == "Vorbeuge halten"
|
assert listed.json()[0]["title"] == "Vorbeuge halten"
|
||||||
|
assert listed.json()[0].get("action_id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_practice_weekdays_creates_action_bridge(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="A1 Weekday Practices",
|
||||||
|
archetype_key="initiative.maturity_journey",
|
||||||
|
apply_starter_kit=False,
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
gate = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||||
|
json={
|
||||||
|
"title": "Dehnung — Einstieg",
|
||||||
|
"item_type": "maturity_stage",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert gate.status_code == 201
|
||||||
|
gate_id = gate.json()["id"]
|
||||||
|
|
||||||
|
created_practice = client.post(
|
||||||
|
f"/api/roadmap-items/{gate_id}/practices",
|
||||||
|
json={
|
||||||
|
"title": "Statisches Stretching",
|
||||||
|
"schedule": {
|
||||||
|
"schedule_kind": "weekdays",
|
||||||
|
"weekday_mask": 1 | 4 | 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert created_practice.status_code == 201, created_practice.text
|
||||||
|
body = created_practice.json()
|
||||||
|
assert body["action_id"]
|
||||||
|
assert body.get("schedule", {}).get("schedule_kind") == "weekdays"
|
||||||
|
assert body["schedule"]["weekday_mask"] == 1 | 4 | 64
|
||||||
|
|
||||||
|
action = client.get(
|
||||||
|
f"/api/actions/{body['action_id']}",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert action.status_code == 200
|
||||||
|
assert action.json()["roadmap_item_id"] == gate_id
|
||||||
|
assert action.json()["status"] == "open"
|
||||||
|
|
||||||
|
|
||||||
def test_join_gate_rejects_practices(client):
|
def test_join_gate_rejects_practices(client):
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,10 @@ 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"] == "Vorbeuge-Dehnung" for r in recurring.json())
|
stretch = next(r for r in recurring.json() if r["title"] == "Vorbeuge-Dehnung")
|
||||||
assert any(r["roadmap_item_id"] for r in recurring.json())
|
assert stretch.get("roadmap_item_id")
|
||||||
|
assert stretch.get("action_id")
|
||||||
|
assert (stretch.get("schedule") or {}).get("schedule_kind") == "weekdays"
|
||||||
|
|
||||||
|
|
||||||
def test_create_initiative_b2b_starter_kit(client):
|
def test_create_initiative_b2b_starter_kit(client):
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ def _create_spagat(client, token):
|
||||||
|
|
||||||
|
|
||||||
def test_a1_starter_kit_spagat_structure(client):
|
def test_a1_starter_kit_spagat_structure(client):
|
||||||
"""Starter-Kit: Stufen, Training-Project, tägliche Übung, Operating Context."""
|
"""Starter-Kit: Stufen, Training-Project, Wochentags-Übung, Operating Context."""
|
||||||
user = provision_user_in_tenant(tenant_role="admin")
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
token = _login(client, user)
|
token = _login(client, user)
|
||||||
initiative_id = _create_spagat(client, token)
|
initiative_id = _create_spagat(client, token)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ from datetime import date, datetime, timezone
|
||||||
from services.schedule import (
|
from services.schedule import (
|
||||||
format_schedule_label,
|
format_schedule_label,
|
||||||
initial_due_at,
|
initial_due_at,
|
||||||
|
is_due_on_date,
|
||||||
next_due_after_completion,
|
next_due_after_completion,
|
||||||
weekday_bit,
|
weekday_bit,
|
||||||
|
weekday_mask_for_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,6 +19,19 @@ def test_interval_daily_label():
|
||||||
assert label == "Täglich"
|
assert label == "Täglich"
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_seven_is_not_weekly():
|
||||||
|
label = format_schedule_label({"schedule_kind": "interval", "interval_days": 7, "weekday_mask": 0})
|
||||||
|
assert label == "Alle 7 Tage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekday_label_lists_days():
|
||||||
|
label = format_schedule_label(
|
||||||
|
{"schedule_kind": "weekdays", "weekday_mask": weekday_bit(0) | weekday_bit(2) | weekday_bit(6)}
|
||||||
|
)
|
||||||
|
assert label == "Mo, Mi, So"
|
||||||
|
assert "Wöchentlich" not in label
|
||||||
|
|
||||||
|
|
||||||
def test_weekday_mask_next_due_after_friday():
|
def test_weekday_mask_next_due_after_friday():
|
||||||
schedule = {
|
schedule = {
|
||||||
"schedule_kind": "weekdays",
|
"schedule_kind": "weekdays",
|
||||||
|
|
@ -28,6 +43,36 @@ def test_weekday_mask_next_due_after_friday():
|
||||||
assert nxt.date() == date(2026, 8, 10)
|
assert nxt.date() == date(2026, 8, 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_due_on_date_hides_future_weekday():
|
||||||
|
schedule = {
|
||||||
|
"schedule_kind": "weekdays",
|
||||||
|
"weekday_mask": weekday_bit(1),
|
||||||
|
"pause_until": None,
|
||||||
|
}
|
||||||
|
next_due = datetime(2026, 8, 18, tzinfo=timezone.utc)
|
||||||
|
assert is_due_on_date(schedule=schedule, next_due_at=next_due, on_date=date(2026, 8, 17)) is False
|
||||||
|
assert is_due_on_date(schedule=schedule, next_due_at=next_due, on_date=date(2026, 8, 18)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_due_on_date_keeps_overdue_weekday():
|
||||||
|
schedule = {
|
||||||
|
"schedule_kind": "weekdays",
|
||||||
|
"weekday_mask": weekday_bit(0),
|
||||||
|
"pause_until": None,
|
||||||
|
}
|
||||||
|
missed_monday = datetime(2026, 8, 17, tzinfo=timezone.utc)
|
||||||
|
assert (
|
||||||
|
is_due_on_date(
|
||||||
|
schedule=schedule, next_due_at=missed_monday, on_date=date(2026, 8, 18)
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekday_mask_for_monday():
|
||||||
|
assert weekday_mask_for_date(date(2026, 8, 17)) == weekday_bit(0)
|
||||||
|
|
||||||
|
|
||||||
def test_pause_until_pushes_initial_due():
|
def test_pause_until_pushes_initial_due():
|
||||||
schedule = {
|
schedule = {
|
||||||
"schedule_kind": "interval",
|
"schedule_kind": "interval",
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) |
|
| Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) |
|
||||||
| Decision | ✓ | |
|
| Decision | ✓ | |
|
||||||
| Review | ✓ | `roadmap_item_id` |
|
| Review | ✓ | `roadmap_item_id` |
|
||||||
| RecurringElement | ✓ | |
|
| RecurringElement | ✓ | Cadence-Brücke für AP+Schedule; Default Wochentage (nicht Intervall +7) |
|
||||||
| Task (unter Action) | ◐ | AP1.5d: `parent_task_id`, Baum-API, Outline; Roll-up-UI ◐ |
|
| Task (unter Action) | ◐ | AP1.5d: `parent_task_id`, Baum-API, Outline; Roll-up-UI ◐ |
|
||||||
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
|
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
|
||||||
| RoadmapItem | ◐ | CRUD + Detail; Verify AP1.4b |
|
| RoadmapItem | ◐ | CRUD + Detail; Verify AP1.4b |
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ Cadence im Detail am RecurringElement.
|
||||||
|
|
||||||
| Bereich | Spec | Code/Truth | Lücke |
|
| Bereich | Spec | Code/Truth | Lücke |
|
||||||
|---------|------|------------|-------|
|
|---------|------|------------|-------|
|
||||||
| Progressionsmodell | PO-Lock | linear + flat recurring | Multi-Strang + Gate-Activity-Set |
|
| Progressionsmodell | PO-Lock | Gate-Activity-Set: AP + Wochentags-Schedule (Recurring = Cadence-Brücke); Default-Kit weiter linear | Multi-Strang + Join-IA |
|
||||||
| Horizon multi-active | PO-Lock §3 | single-active (widerrufen) | AP-A1-PM-3 |
|
| Horizon multi-active | PO-Lock §3 | single-active (widerrufen) | AP-A1-PM-3 |
|
||||||
| Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` |
|
| Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` |
|
||||||
| Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild |
|
| Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild |
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,8 @@ export function GateActivitySetSection({
|
||||||
<div>
|
<div>
|
||||||
<h3>{routineLabel}</h3>
|
<h3>{routineLabel}</h3>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Übungen gehören zu diesem Zielzustand — sie erscheinen in Today, wenn das Gate{' '}
|
Übungen sind Arbeitspakete mit Wochentagsplan an diesem Zielzustand.
|
||||||
|
Sie erscheinen in Today nur an den gewählten Tagen, solange das Gate{' '}
|
||||||
<strong>aktiv</strong> ist.
|
<strong>aktiv</strong> ist.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,7 +148,7 @@ export function GateActivitySetSection({
|
||||||
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
||||||
Ausführen → Today
|
Ausführen → Today
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
— fällige Übungen dieser Routine abhaken.
|
— fällige Übungen dieser Stufe abhaken.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -182,13 +183,23 @@ export function GateActivitySetSection({
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Übungen werden geladen…</p>
|
<p className="muted">Übungen werden geladen…</p>
|
||||||
) : practices.length === 0 ? (
|
) : practices.length === 0 ? (
|
||||||
<EmptyState message="Noch keine Übungen — mindestens eine Übung für diese Routine anlegen." />
|
<EmptyState message="Noch keine Übungen — mindestens ein Arbeitspaket mit Wochentagen anlegen." />
|
||||||
) : (
|
) : (
|
||||||
<ul className="item-list gate-activity-set__list">
|
<ul className="item-list gate-activity-set__list">
|
||||||
{practices.map((practice) => (
|
{practices.map((practice) => (
|
||||||
<li key={practice.id} className="list-item card-list-item">
|
<li key={practice.id} className="list-item card-list-item">
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<strong>{practice.title}</strong>
|
<strong>{practice.title}</strong>
|
||||||
|
{practice.action_id && initiativeId && (
|
||||||
|
<p className="muted list-item-sub">
|
||||||
|
<Link
|
||||||
|
to={scopedPath(`/actions/${practice.action_id}`, { initiativeId })}
|
||||||
|
className="link-inline"
|
||||||
|
>
|
||||||
|
Arbeitspaket öffnen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{practice.description && (
|
{practice.description && (
|
||||||
<p className="muted list-item-sub">{practice.description}</p>
|
<p className="muted list-item-sub">{practice.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
detectIntervalPreset,
|
detectIntervalPreset,
|
||||||
scheduleFromApi,
|
scheduleFromApi,
|
||||||
toggleWeekdayMask,
|
toggleWeekdayMask,
|
||||||
|
weekdayBitForDate,
|
||||||
} from '../utils/scheduleUtils.js'
|
} from '../utils/scheduleUtils.js'
|
||||||
|
|
||||||
export function ScheduleEditor({
|
export function ScheduleEditor({
|
||||||
|
|
@ -28,7 +29,7 @@ export function ScheduleEditor({
|
||||||
onChange({
|
onChange({
|
||||||
...current,
|
...current,
|
||||||
schedule_kind: 'weekdays',
|
schedule_kind: 'weekdays',
|
||||||
weekday_mask: current.weekday_mask || WEEKDAY_OPTIONS[0].bit,
|
weekday_mask: current.weekday_mask || weekdayBitForDate(),
|
||||||
})
|
})
|
||||||
setPreset('weekdays')
|
setPreset('weekdays')
|
||||||
return
|
return
|
||||||
|
|
@ -52,6 +53,13 @@ export function ScheduleEditor({
|
||||||
<legend className="schedule-editor__legend">Rhythmus</legend>
|
<legend className="schedule-editor__legend">Rhythmus</legend>
|
||||||
|
|
||||||
<div className="schedule-editor__modes" role="group" aria-label="Rhythmus-Art">
|
<div className="schedule-editor__modes" role="group" aria-label="Rhythmus-Art">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${preset === 'weekdays' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setKind('weekdays')}
|
||||||
|
>
|
||||||
|
Wochentage
|
||||||
|
</button>
|
||||||
{INTERVAL_PRESETS.filter((p) => p.id !== 'custom').map((p) => (
|
{INTERVAL_PRESETS.filter((p) => p.id !== 'custom').map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|
@ -62,13 +70,6 @@ export function ScheduleEditor({
|
||||||
{p.label}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-sm ${preset === 'weekdays' ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
onClick={() => setKind('weekdays')}
|
|
||||||
>
|
|
||||||
Wochentage
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn btn-sm ${preset === 'custom' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn btn-sm ${preset === 'custom' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
|
@ -95,7 +96,8 @@ export function ScheduleEditor({
|
||||||
{current.schedule_kind === 'weekdays' && (
|
{current.schedule_kind === 'weekdays' && (
|
||||||
<div className="schedule-editor__weekdays">
|
<div className="schedule-editor__weekdays">
|
||||||
<p className="muted schedule-editor__hint">
|
<p className="muted schedule-editor__hint">
|
||||||
An welchen Tagen? {weekdaySummary ? `(${weekdaySummary})` : ''}
|
Immer an diesen Tagen — nach Erledigen bleibt der Wochentag.
|
||||||
|
{weekdaySummary ? ` (${weekdaySummary})` : ''}
|
||||||
</p>
|
</p>
|
||||||
<div className="schedule-editor__weekday-row">
|
<div className="schedule-editor__weekday-row">
|
||||||
{WEEKDAY_OPTIONS.map((day) => {
|
{WEEKDAY_OPTIONS.map((day) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
import { isPracticeDueToday } from '../utils/scheduleUtils.js'
|
||||||
|
|
||||||
function formatDue(iso) {
|
function formatDue(iso) {
|
||||||
if (!iso) return null
|
if (!iso) return null
|
||||||
|
|
@ -21,7 +22,10 @@ function activeWorkGates(roadmapItems = []) {
|
||||||
|
|
||||||
function exercisesForGate(recurringItems = [], gateId) {
|
function exercisesForGate(recurringItems = [], gateId) {
|
||||||
return (recurringItems || []).filter(
|
return (recurringItems || []).filter(
|
||||||
(item) => item.status === 'active' && item.roadmap_item_id === gateId,
|
(item) =>
|
||||||
|
item.status === 'active' &&
|
||||||
|
item.roadmap_item_id === gateId &&
|
||||||
|
isPracticeDueToday(item),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,19 +127,23 @@ export function WorkTodayPracticePanel({
|
||||||
<h2>Heutige Übungen</h2>
|
<h2>Heutige Übungen</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
{gates.length > 0
|
{gates.length > 0
|
||||||
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
|
? `${gates.length} aktive Gate(s) — nur heute fällige Übungen, optional Messwert.`
|
||||||
: 'Tages-Tracking für aktive Work-Gates.'}
|
: 'Tages-Tracking für aktive Work-Gates.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{todayExercises.length === 0 ? (
|
{todayExercises.length === 0 ? (
|
||||||
<EmptyState message="Keine Übungen auf aktiven Gates — Starter-Kit oder Join-Fortschritt prüfen." />
|
<EmptyState message={
|
||||||
|
gates.length === 0
|
||||||
|
? 'Keine Übungen auf aktiven Gates — Starter-Kit oder Join-Fortschritt prüfen.'
|
||||||
|
: 'Heute keine fälligen Übungen.'
|
||||||
|
} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{allDone && (
|
{allDone && (
|
||||||
<p className="work-today-practice__all-done muted">
|
<p className="work-today-practice__all-done muted">
|
||||||
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
Alle Übungen für heute erledigt — nächste Fälligkeit laut Rhythmus.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{grouped.map(({ gate, exercises }) =>
|
{grouped.map(({ gate, exercises }) =>
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,20 @@ export const WEEKDAY_OPTIONS = [
|
||||||
export const INTERVAL_PRESETS = [
|
export const INTERVAL_PRESETS = [
|
||||||
{ id: 'daily', label: 'Täglich', intervalDays: 1 },
|
{ id: 'daily', label: 'Täglich', intervalDays: 1 },
|
||||||
{ id: 'every2', label: 'Alle 2 Tage', intervalDays: 2 },
|
{ id: 'every2', label: 'Alle 2 Tage', intervalDays: 2 },
|
||||||
{ id: 'weekly', label: 'Wöchentlich', intervalDays: 7 },
|
|
||||||
{ id: 'custom', label: 'Individuell…', intervalDays: null },
|
{ id: 'custom', label: 'Individuell…', intervalDays: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function defaultScheduleValue() {
|
/** Monday=0 … Sunday=6, matching backend weekday_bit. */
|
||||||
|
export function weekdayBitForDate(date = new Date()) {
|
||||||
|
const mondayFirst = (date.getDay() + 6) % 7
|
||||||
|
return 1 << mondayFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultScheduleValue(date = new Date()) {
|
||||||
return {
|
return {
|
||||||
schedule_kind: 'interval',
|
schedule_kind: 'weekdays',
|
||||||
interval_days: 1,
|
interval_days: 1,
|
||||||
weekday_mask: 0,
|
weekday_mask: weekdayBitForDate(date),
|
||||||
pause_until: '',
|
pause_until: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +50,7 @@ export function formatScheduleLabel(schedule) {
|
||||||
return schedule.pause_until ? `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})` : base
|
return schedule.pause_until ? `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})` : base
|
||||||
}
|
}
|
||||||
const days = schedule.interval_days || 1
|
const days = schedule.interval_days || 1
|
||||||
let base = days === 1 ? 'Täglich' : days === 7 ? 'Wöchentlich' : `Alle ${days} Tage`
|
let base = days === 1 ? 'Täglich' : `Alle ${days} Tage`
|
||||||
if (schedule.pause_until) {
|
if (schedule.pause_until) {
|
||||||
base = `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})`
|
base = `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})`
|
||||||
}
|
}
|
||||||
|
|
@ -79,3 +84,54 @@ export function detectIntervalPreset(intervalDays) {
|
||||||
)
|
)
|
||||||
return match ? match.id : 'custom'
|
return match ? match.id : 'custom'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfLocalDay(date) {
|
||||||
|
const copy = new Date(date)
|
||||||
|
copy.setHours(0, 0, 0, 0)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDueDate(value) {
|
||||||
|
if (!value) return null
|
||||||
|
const parsed = new Date(value)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-routine weekday stickiness: due today if next_due ≤ today
|
||||||
|
* (includes overdue). Future weekdays stay hidden.
|
||||||
|
*/
|
||||||
|
export function isDueOnDate({ schedule, nextDueAt, onDate = new Date() } = {}) {
|
||||||
|
const day = startOfLocalDay(onDate)
|
||||||
|
const pauseUntil = schedule?.pause_until
|
||||||
|
if (pauseUntil) {
|
||||||
|
const pause = startOfLocalDay(new Date(pauseUntil))
|
||||||
|
if (!Number.isNaN(pause.getTime()) && day.getTime() <= pause.getTime()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const due = parseDueDate(nextDueAt)
|
||||||
|
if (due && startOfLocalDay(due).getTime() > day.getTime()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((schedule?.schedule_kind || 'interval') === 'weekdays') {
|
||||||
|
const mask = Number(schedule?.weekday_mask) || 0
|
||||||
|
if (due) return true
|
||||||
|
return Boolean(mask & weekdayBitForDate(day))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPracticeDueToday(practice, now = new Date()) {
|
||||||
|
if (!practice || practice.status !== 'active') return false
|
||||||
|
if (practice.today_completed) return true
|
||||||
|
const nextDueAt = practice.next_due_at || practice.open_cadence_instance?.due_at
|
||||||
|
return isDueOnDate({
|
||||||
|
schedule: practice.schedule,
|
||||||
|
nextDueAt,
|
||||||
|
onDate: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
75
frontend/src/utils/scheduleUtils.test.js
Normal file
75
frontend/src/utils/scheduleUtils.test.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
defaultScheduleValue,
|
||||||
|
formatScheduleLabel,
|
||||||
|
isDueOnDate,
|
||||||
|
isPracticeDueToday,
|
||||||
|
weekdayBitForDate,
|
||||||
|
} from './scheduleUtils.js'
|
||||||
|
|
||||||
|
describe('scheduleUtils weekday foundation', () => {
|
||||||
|
it('defaults to weekdays on today', () => {
|
||||||
|
const monday = new Date('2026-08-17T12:00:00')
|
||||||
|
const value = defaultScheduleValue(monday)
|
||||||
|
expect(value.schedule_kind).toBe('weekdays')
|
||||||
|
expect(value.weekday_mask).toBe(weekdayBitForDate(monday))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call interval 7 weekly', () => {
|
||||||
|
expect(formatScheduleLabel({ schedule_kind: 'interval', interval_days: 7 })).toBe('Alle 7 Tage')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels weekday masks as days, not weekly', () => {
|
||||||
|
expect(
|
||||||
|
formatScheduleLabel({ schedule_kind: 'weekdays', weekday_mask: 1 | 4 | 64 }),
|
||||||
|
).toBe('Mo, Mi, So')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides future weekday occurrences', () => {
|
||||||
|
const monday = new Date('2026-08-17T12:00:00')
|
||||||
|
const tuesdayDue = '2026-08-18T12:00:00'
|
||||||
|
const due = isDueOnDate({
|
||||||
|
schedule: { schedule_kind: 'weekdays', weekday_mask: 1 | 4 | 64 },
|
||||||
|
nextDueAt: tuesdayDue,
|
||||||
|
onDate: monday,
|
||||||
|
})
|
||||||
|
expect(due).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows overdue weekday series on a later day', () => {
|
||||||
|
const tuesday = new Date('2026-08-18T12:00:00')
|
||||||
|
const mondayDue = '2026-08-17T12:00:00'
|
||||||
|
expect(
|
||||||
|
isDueOnDate({
|
||||||
|
schedule: { schedule_kind: 'weekdays', weekday_mask: 1 },
|
||||||
|
nextDueAt: mondayDue,
|
||||||
|
onDate: tuesday,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters Today to due or completed practices', () => {
|
||||||
|
const monday = new Date('2026-08-17T12:00:00')
|
||||||
|
const due = {
|
||||||
|
status: 'active',
|
||||||
|
today_completed: false,
|
||||||
|
next_due_at: '2026-08-17T12:00:00',
|
||||||
|
schedule: { schedule_kind: 'weekdays', weekday_mask: 1 },
|
||||||
|
}
|
||||||
|
const later = {
|
||||||
|
status: 'active',
|
||||||
|
today_completed: false,
|
||||||
|
next_due_at: '2026-08-18T12:00:00',
|
||||||
|
schedule: { schedule_kind: 'weekdays', weekday_mask: 2 },
|
||||||
|
}
|
||||||
|
const done = {
|
||||||
|
status: 'active',
|
||||||
|
today_completed: true,
|
||||||
|
next_due_at: '2026-08-18T12:00:00',
|
||||||
|
schedule: { schedule_kind: 'weekdays', weekday_mask: 2 },
|
||||||
|
}
|
||||||
|
expect(isPracticeDueToday(due, monday)).toBe(true)
|
||||||
|
expect(isPracticeDueToday(later, monday)).toBe(false)
|
||||||
|
expect(isPracticeDueToday(done, monday)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user