From a05bd34af37e7c7158e580dbf1430f0a3c9736c7 Mon Sep 17 00:00:00 2001
From: Lars
Date: Mon, 17 Aug 2026 18:06:41 +0200
Subject: [PATCH] =?UTF-8?q?feat(A1):=20=C3=9Cbungen=20als=20AP=20mit=20Woc?=
=?UTF-8?q?hentagsplan?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
backend/routers/roadmap.py | 18 ++---
backend/services/maturity_practice.py | 33 ++++----
backend/services/recurring.py | 11 +++
backend/services/schedule.py | 36 ++++++++-
backend/services/scheduled_work.py | 47 ++++++++++++
backend/tests/test_a1_gate_practices.py | 57 +++++++++++++-
backend/tests/test_ap22a_starter_kit.py | 6 +-
backend/tests/test_ap22c_maturity_e2e.py | 2 +-
backend/tests/test_schedule_service.py | 45 +++++++++++
.../Kairo_Implementation_Truth_Table_v0.1.md | 2 +-
.../SPEC_A1_maturity_journey_v0.1.md | 2 +-
.../src/components/GateActivitySetSection.jsx | 17 ++++-
frontend/src/components/ScheduleEditor.jsx | 20 ++---
.../src/components/WorkTodayPracticePanel.jsx | 16 +++-
frontend/src/utils/scheduleUtils.js | 66 ++++++++++++++--
frontend/src/utils/scheduleUtils.test.js | 75 +++++++++++++++++++
16 files changed, 396 insertions(+), 57 deletions(-)
create mode 100644 frontend/src/utils/scheduleUtils.test.js
diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
index 4ff654e..12c66f2 100644
--- a/backend/routers/roadmap.py
+++ b/backend/routers/roadmap.py
@@ -354,34 +354,32 @@ def create_gate_practice(
body: GatePracticeCreateRequest,
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)
sched = body.schedule
try:
+ from services.scheduled_work import create_scheduled_action_at_gate
+
if sched:
- return recurring_service.create_recurring_element(
+ return create_scheduled_action_at_gate(
tenant_id=ctx.tenant_id,
initiative_id=str(item["initiative_id"]),
+ gate_id=item_id,
title=body.title,
description=body.description,
- status=body.status,
+ user_id=ctx.user_id,
schedule_kind=sched.schedule_kind,
interval_days=sched.interval_days,
weekday_mask=sched.weekday_mask,
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,
initiative_id=str(item["initiative_id"]),
+ gate_id=item_id,
title=body.title,
description=body.description,
- status=body.status,
- interval_days=body.interval_days,
- roadmap_item_id=item_id,
user_id=ctx.user_id,
+ schedule_kind="weekdays",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/services/maturity_practice.py b/backend/services/maturity_practice.py
index d130a26..8454270 100644
--- a/backend/services/maturity_practice.py
+++ b/backend/services/maturity_practice.py
@@ -6,7 +6,6 @@ from datetime import datetime, timezone
from typing import Any, Optional
from services.recurring import (
- create_recurring_element,
list_recurring_for_initiative,
update_recurring_element,
)
@@ -14,28 +13,28 @@ from services.recurring import (
# 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"),
+ ("Vorbeuge-Dehnung", "3×30 Sek. — Hüfte und Beinrückseite"),
+ ("Hüftöffner (Schmetterling)", "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"),
+ ("Seitlicher Spagat — links", "Halten, optional Messwert (Grad)"),
+ ("Seitlicher Spagat — rechts", "Halten, optional Messwert (Grad)"),
+ ("Aktive Vorbeuge", "3× kurz halten"),
],
"Stufe 3 — Ziel": [
- ("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"),
+ ("Voller Spagat", "Zielposition — Messwert dokumentieren"),
],
"Dehnung — Einstieg": [
- ("Vorbeuge halten", "Täglich 3×30 Sek."),
+ ("Vorbeuge halten", "3×30 Sek."),
],
"Hüftrotation — Einstieg": [
- ("Hüftkreisen im Stand", "Täglich 10 Wiederholungen je Richtung"),
+ ("Hüftkreisen im Stand", "10 Wiederholungen je Richtung"),
],
"Dehnung — nach Join": [
- ("Enddehnung Mawashi-Vorbereitung", "Täglich 2×45 Sek."),
+ ("Enddehnung Mawashi-Vorbereitung", "2×45 Sek."),
],
"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 = [
(
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]] = []
now = datetime.now(timezone.utc)
+ from services.scheduled_work import create_scheduled_action_at_gate
+
for title, description in defs:
if title in existing_by_title:
item = existing_by_title[title]
@@ -205,16 +206,14 @@ def seed_practices_for_stage(
created.append(item)
continue
- row = create_recurring_element(
+ row = create_scheduled_action_at_gate(
tenant_id=tenant_id,
initiative_id=initiative_id,
+ gate_id=stage_id,
title=title,
description=description,
- status="active",
- interval_days=1,
- next_due_at=now,
- roadmap_item_id=stage_id,
user_id=user_id,
+ schedule_kind="weekdays",
)
created.append(row)
diff --git a/backend/services/recurring.py b/backend/services/recurring.py
index 39d087d..d63a2ab 100644
--- a/backend/services/recurring.py
+++ b/backend/services/recurring.py
@@ -350,6 +350,17 @@ def delete_recurring_element(
recurring_id: str,
user_id: Optional[str] = None,
) -> 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()
try:
with conn.cursor() as cur:
diff --git a/backend/services/schedule.py b/backend/services/schedule.py
index 499ef4e..e381237 100644
--- a/backend/services/schedule.py
+++ b/backend/services/schedule.py
@@ -24,6 +24,10 @@ def weekday_bit(weekday: int) -> int:
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]:
result = dict(row)
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)
if days == 1:
label = "Täglich"
- elif days == 7:
- label = "Wöchentlich"
else:
label = f"Alle {days} Tage"
pause = schedule.get("pause_until")
@@ -154,6 +156,36 @@ def next_due_after_completion(
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(
*,
tenant_id: str,
diff --git a/backend/services/scheduled_work.py b/backend/services/scheduled_work.py
index a08826d..2a33230 100644
--- a/backend/services/scheduled_work.py
+++ b/backend/services/scheduled_work.py
@@ -260,3 +260,50 @@ def clear_action_schedule(
details={"action_id": action_id},
)
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
+
diff --git a/backend/tests/test_a1_gate_practices.py b/backend/tests/test_a1_gate_practices.py
index c46086a..1ae4c65 100644
--- a/backend/tests/test_a1_gate_practices.py
+++ b/backend/tests/test_a1_gate_practices.py
@@ -43,14 +43,15 @@ def test_gate_practice_create_and_list(client):
json={
"title": "Vorbeuge halten",
"description": "3×30 Sek.",
- "interval_days": 1,
},
headers=_auth(token),
)
assert created_practice.status_code == 201
body = created_practice.json()
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(
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 len(listed.json()) == 1
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):
diff --git a/backend/tests/test_ap22a_starter_kit.py b/backend/tests/test_ap22a_starter_kit.py
index bd06d8c..ef227db 100644
--- a/backend/tests/test_ap22a_starter_kit.py
+++ b/backend/tests/test_ap22a_starter_kit.py
@@ -51,8 +51,10 @@ def test_create_initiative_a1_applies_starter_kit(client):
headers=_auth(token),
)
assert recurring.status_code == 200
- assert any(r["title"] == "Vorbeuge-Dehnung" for r in recurring.json())
- assert any(r["roadmap_item_id"] for r in recurring.json())
+ stretch = next(r for r in recurring.json() if r["title"] == "Vorbeuge-Dehnung")
+ 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):
diff --git a/backend/tests/test_ap22c_maturity_e2e.py b/backend/tests/test_ap22c_maturity_e2e.py
index 518414d..2fc7a41 100644
--- a/backend/tests/test_ap22c_maturity_e2e.py
+++ b/backend/tests/test_ap22c_maturity_e2e.py
@@ -23,7 +23,7 @@ def _create_spagat(client, token):
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")
token = _login(client, user)
initiative_id = _create_spagat(client, token)
diff --git a/backend/tests/test_schedule_service.py b/backend/tests/test_schedule_service.py
index d5c6da3..4e647b1 100644
--- a/backend/tests/test_schedule_service.py
+++ b/backend/tests/test_schedule_service.py
@@ -7,8 +7,10 @@ from datetime import date, datetime, timezone
from services.schedule import (
format_schedule_label,
initial_due_at,
+ is_due_on_date,
next_due_after_completion,
weekday_bit,
+ weekday_mask_for_date,
)
@@ -17,6 +19,19 @@ def test_interval_daily_label():
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():
schedule = {
"schedule_kind": "weekdays",
@@ -28,6 +43,36 @@ def test_weekday_mask_next_due_after_friday():
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():
schedule = {
"schedule_kind": "interval",
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index ecd75ad..c47ec8a 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -57,7 +57,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) |
| Decision | ✓ | |
| 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 ◐ |
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
| RoadmapItem | ◐ | CRUD + Detail; Verify AP1.4b |
diff --git a/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md b/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md
index b56700a..7f61b70 100644
--- a/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md
+++ b/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md
@@ -242,7 +242,7 @@ Cadence im Detail am RecurringElement.
| 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 |
| Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` |
| Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild |
diff --git a/frontend/src/components/GateActivitySetSection.jsx b/frontend/src/components/GateActivitySetSection.jsx
index d1b5504..eda3295 100644
--- a/frontend/src/components/GateActivitySetSection.jsx
+++ b/frontend/src/components/GateActivitySetSection.jsx
@@ -117,7 +117,8 @@ export function GateActivitySetSection({
{routineLabel}
- Ü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{' '}
aktiv ist.
@@ -147,7 +148,7 @@ export function GateActivitySetSection({
Ausführen → Today
{' '}
- — fällige Übungen dieser Routine abhaken.
+ — fällige Übungen dieser Stufe abhaken.
)}
@@ -182,13 +183,23 @@ export function GateActivitySetSection({
{loading ? (
Übungen werden geladen…
) : practices.length === 0 ? (
-
+
) : (
{practices.map((practice) => (
{practice.title}
+ {practice.action_id && initiativeId && (
+
+
+ Arbeitspaket öffnen
+
+
+ )}
{practice.description && (
{practice.description}
)}
diff --git a/frontend/src/components/ScheduleEditor.jsx b/frontend/src/components/ScheduleEditor.jsx
index ebdaa90..1bdd5ed 100644
--- a/frontend/src/components/ScheduleEditor.jsx
+++ b/frontend/src/components/ScheduleEditor.jsx
@@ -6,6 +6,7 @@ import {
detectIntervalPreset,
scheduleFromApi,
toggleWeekdayMask,
+ weekdayBitForDate,
} from '../utils/scheduleUtils.js'
export function ScheduleEditor({
@@ -28,7 +29,7 @@ export function ScheduleEditor({
onChange({
...current,
schedule_kind: 'weekdays',
- weekday_mask: current.weekday_mask || WEEKDAY_OPTIONS[0].bit,
+ weekday_mask: current.weekday_mask || weekdayBitForDate(),
})
setPreset('weekdays')
return
@@ -52,6 +53,13 @@ export function ScheduleEditor({
Rhythmus
+
setKind('weekdays')}
+ >
+ Wochentage
+
{INTERVAL_PRESETS.filter((p) => p.id !== 'custom').map((p) => (
))}
- setKind('weekdays')}
- >
- Wochentage
-
- An welchen Tagen? {weekdaySummary ? `(${weekdaySummary})` : ''}
+ Immer an diesen Tagen — nach Erledigen bleibt der Wochentag.
+ {weekdaySummary ? ` (${weekdaySummary})` : ''}
{WEEKDAY_OPTIONS.map((day) => {
diff --git a/frontend/src/components/WorkTodayPracticePanel.jsx b/frontend/src/components/WorkTodayPracticePanel.jsx
index b799bd5..2a9132a 100644
--- a/frontend/src/components/WorkTodayPracticePanel.jsx
+++ b/frontend/src/components/WorkTodayPracticePanel.jsx
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { completeRecurringPractice } from '../api/recurring.js'
import { EmptyState } from './EmptyState.jsx'
import { scopedPath } from '../utils/routes.js'
+import { isPracticeDueToday } from '../utils/scheduleUtils.js'
function formatDue(iso) {
if (!iso) return null
@@ -21,7 +22,10 @@ function activeWorkGates(roadmapItems = []) {
function exercisesForGate(recurringItems = [], gateId) {
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({
Heutige Übungen
{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.'}
{todayExercises.length === 0 ? (
-
+
) : (
<>
{allDone && (
- Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
+ Alle Übungen für heute erledigt — nächste Fälligkeit laut Rhythmus.
)}
{grouped.map(({ gate, exercises }) =>
diff --git a/frontend/src/utils/scheduleUtils.js b/frontend/src/utils/scheduleUtils.js
index 2182f71..eec5a92 100644
--- a/frontend/src/utils/scheduleUtils.js
+++ b/frontend/src/utils/scheduleUtils.js
@@ -11,15 +11,20 @@ export const WEEKDAY_OPTIONS = [
export const INTERVAL_PRESETS = [
{ id: 'daily', label: 'Täglich', intervalDays: 1 },
{ id: 'every2', label: 'Alle 2 Tage', intervalDays: 2 },
- { id: 'weekly', label: 'Wöchentlich', intervalDays: 7 },
{ 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 {
- schedule_kind: 'interval',
+ schedule_kind: 'weekdays',
interval_days: 1,
- weekday_mask: 0,
+ weekday_mask: weekdayBitForDate(date),
pause_until: '',
}
}
@@ -45,7 +50,7 @@ export function formatScheduleLabel(schedule) {
return schedule.pause_until ? `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})` : base
}
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) {
base = `${base} (Pause bis ${schedule.pause_until.slice(0, 10)})`
}
@@ -79,3 +84,54 @@ export function detectIntervalPreset(intervalDays) {
)
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,
+ })
+}
diff --git a/frontend/src/utils/scheduleUtils.test.js b/frontend/src/utils/scheduleUtils.test.js
new file mode 100644
index 0000000..24e1a9b
--- /dev/null
+++ b/frontend/src/utils/scheduleUtils.test.js
@@ -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)
+ })
+})