From a24257aeed4e954f1f09ea747845bad55effaaad Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:52:18 -0400 Subject: [PATCH] feat: add recurrence service (validate, calculate_next_due, spawn_recurring_tasks) --- src/fabledassistant/services/recurrence.py | 155 +++++++++++++++++++++ tests/test_recurrence.py | 4 +- 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/fabledassistant/services/recurrence.py diff --git a/src/fabledassistant/services/recurrence.py b/src/fabledassistant/services/recurrence.py new file mode 100644 index 0000000..b196df2 --- /dev/null +++ b/src/fabledassistant/services/recurrence.py @@ -0,0 +1,155 @@ +"""Recurring task rule validation, date calculation, and scheduled spawning.""" +import asyncio +import calendar +import logging +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import and_, select + +from fabledassistant.models import async_session +from fabledassistant.models.note import Note, TaskStatus + +logger = logging.getLogger(__name__) + +_VALID_UNITS = {"day", "week", "month", "year"} + + +def validate_recurrence_rule(rule: dict) -> None: + """Raise ValueError if rule is structurally invalid.""" + if not isinstance(rule, dict): + raise ValueError("recurrence_rule must be an object") + rule_type = rule.get("type") + if rule_type not in ("interval", "calendar"): + raise ValueError("recurrence_rule.type must be 'interval' or 'calendar'") + unit = rule.get("unit") + if unit not in _VALID_UNITS: + raise ValueError(f"recurrence_rule.unit must be one of {_VALID_UNITS}") + if rule_type == "interval": + every = rule.get("every") + if not isinstance(every, int) or every < 1: + raise ValueError("recurrence_rule.every must be a positive integer") + elif rule_type == "calendar": + day = rule.get("day_of_month") + if not isinstance(day, int) or not 1 <= day <= 31: + raise ValueError("recurrence_rule.day_of_month must be an integer between 1 and 31") + if unit == "year": + month = rule.get("month") + if not isinstance(month, int) or not 1 <= month <= 12: + raise ValueError("recurrence_rule.month must be an integer between 1 and 12") + elif unit != "month": + raise ValueError("calendar recurrence only supports unit 'month' or 'year'") + + +def _add_months(d: date, months: int) -> date: + month = d.month - 1 + months + year = d.year + month // 12 + month = month % 12 + 1 + day = min(d.day, calendar.monthrange(year, month)[1]) + return d.replace(year=year, month=month, day=day) + + +def _next_day_of_month(from_date: date, day: int) -> date: + """Return the next occurrence of day-of-month strictly after from_date.""" + year, month = from_date.year, from_date.month + max_day = calendar.monthrange(year, month)[1] + clamped = min(day, max_day) + if clamped > from_date.day: + return from_date.replace(day=clamped) + # Advance one month + if month == 12: + year, month = year + 1, 1 + else: + month += 1 + return date(year, month, min(day, calendar.monthrange(year, month)[1])) + + +def _next_annual_date(from_date: date, month: int, day: int) -> date: + """Return the next occurrence of month/day after from_date.""" + max_day = calendar.monthrange(from_date.year, month)[1] + candidate = date(from_date.year, month, min(day, max_day)) + if candidate > from_date: + return candidate + return date( + from_date.year + 1, + month, + min(day, calendar.monthrange(from_date.year + 1, month)[1]), + ) + + +def calculate_next_due(rule: dict, from_date: date) -> date: + """Return the next due date after from_date according to rule.""" + rule_type = rule["type"] + unit = rule["unit"] + if rule_type == "interval": + every = rule["every"] + if unit == "day": + return from_date + timedelta(days=every) + if unit == "week": + return from_date + timedelta(weeks=every) + if unit == "month": + return _add_months(from_date, every) + if unit == "year": + return _add_months(from_date, every * 12) + elif rule_type == "calendar": + if unit == "month": + return _next_day_of_month(from_date, rule["day_of_month"]) + if unit == "year": + return _next_annual_date(from_date, rule["month"], rule["day_of_month"]) + raise ValueError(f"Unhandled recurrence rule: {rule!r}") + + +async def spawn_recurring_tasks() -> int: + """Create child tasks for all recurring tasks whose spawn date has arrived. + + Returns the number of tasks spawned. + """ + from fabledassistant.services.embeddings import upsert_note_embedding + from fabledassistant.services.notes import create_note + + now = datetime.now(timezone.utc) + spawned = 0 + + async with async_session() as session: + result = await session.execute( + select(Note).where( + and_( + Note.recurrence_rule.isnot(None), + Note.recurrence_next_spawn_at <= now, + ) + ) + ) + tasks = result.scalars().all() + + for task in tasks: + base_date = task.due_date or now.date() + next_due = calculate_next_due(task.recurrence_rule, base_date) + try: + child = await create_note( + task.user_id, + title=task.title, + body=task.body or "", + status=TaskStatus.todo.value, + priority=task.priority or "none", + due_date=next_due, + tags=list(task.tags or []), + project_id=task.project_id, + milestone_id=task.milestone_id, + recurrence_rule=task.recurrence_rule, + ) + text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "") + if text: + asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text)) + except Exception: + logger.exception("Failed to spawn recurring task %d", task.id) + continue + + async with async_session() as session: + parent = await session.get(Note, task.id) + if parent: + parent.recurrence_next_spawn_at = None + await session.commit() + + spawned += 1 + logger.info("Spawned recurring task %d → child due %s", task.id, next_due) + + return spawned diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py index 0916085..41ca059 100644 --- a/tests/test_recurrence.py +++ b/tests/test_recurrence.py @@ -250,11 +250,11 @@ async def test_spawn_recurring_tasks_creates_child(): with ( patch("fabledassistant.services.recurrence.async_session", return_value=mock_session), patch( - "fabledassistant.services.recurrence.create_note", + "fabledassistant.services.notes.create_note", new_callable=AsyncMock, return_value=mock_child, ) as mock_create, - patch("fabledassistant.services.recurrence.upsert_note_embedding"), + patch("fabledassistant.services.embeddings.upsert_note_embedding"), ): from fabledassistant.services.recurrence import spawn_recurring_tasks count = await spawn_recurring_tasks()