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
    + {INTERVAL_PRESETS.filter((p) => p.id !== 'custom').map((p) => (
    {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) + }) +})