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

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:
Lars 2026-08-17 18:06:41 +02:00
parent ea0aa18b8d
commit a05bd34af3
16 changed files with 396 additions and 57 deletions

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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",

View File

@ -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 |

View File

@ -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 |

View File

@ -117,7 +117,8 @@ export function GateActivitySetSection({
<div>
<h3>{routineLabel}</h3>
<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.
</p>
</div>
@ -147,7 +148,7 @@ export function GateActivitySetSection({
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
Ausführen Today
</Link>{' '}
fällige Übungen dieser Routine abhaken.
fällige Übungen dieser Stufe abhaken.
</p>
)}
@ -182,13 +183,23 @@ export function GateActivitySetSection({
{loading ? (
<p className="muted">Übungen werden geladen</p>
) : 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">
{practices.map((practice) => (
<li key={practice.id} className="list-item card-list-item">
<div className="list-item-main">
<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 && (
<p className="muted list-item-sub">{practice.description}</p>
)}

View File

@ -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({
<legend className="schedule-editor__legend">Rhythmus</legend>
<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) => (
<button
key={p.id}
@ -62,13 +70,6 @@ export function ScheduleEditor({
{p.label}
</button>
))}
<button
type="button"
className={`btn btn-sm ${preset === 'weekdays' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setKind('weekdays')}
>
Wochentage
</button>
<button
type="button"
className={`btn btn-sm ${preset === 'custom' ? 'btn-primary' : 'btn-secondary'}`}
@ -95,7 +96,8 @@ export function ScheduleEditor({
{current.schedule_kind === 'weekdays' && (
<div className="schedule-editor__weekdays">
<p className="muted schedule-editor__hint">
An welchen Tagen? {weekdaySummary ? `(${weekdaySummary})` : ''}
Immer an diesen Tagen nach Erledigen bleibt der Wochentag.
{weekdaySummary ? ` (${weekdaySummary})` : ''}
</p>
<div className="schedule-editor__weekday-row">
{WEEKDAY_OPTIONS.map((day) => {

View File

@ -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({
<h2>Heutige Übungen</h2>
<p className="section-lead muted">
{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.'}
</p>
</div>
</div>
{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 && (
<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>
)}
{grouped.map(({ gate, exercises }) =>

View File

@ -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,
})
}

View 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)
})
})