From 05ab768a3be074ce91d587bd5e41b153524d30a6 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sat, 5 Sep 2026 20:17:11 +0800 Subject: [PATCH] fix: support yearly recurrence in calendar --- backend/mvp.py | 11 ++++++++--- tests/test_mvp_backend.py | 8 ++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/mvp.py b/backend/mvp.py index 8bd2032..12420cb 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -64,8 +64,8 @@ def parse_rrule(value: str) -> dict[str, str]: raise HTTPException(422, "无效的 RRULE") key, val = part.split("=", 1) parts[key] = val - if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY"}: - raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY") + if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}: + raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY、YEARLY") try: if "INTERVAL" in parts and int(parts["INTERVAL"]) < 1: raise ValueError @@ -103,10 +103,15 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut elif parts["FREQ"] == "WEEKLY": days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")} include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0 - else: + elif parts["FREQ"] == "MONTHLY": month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")} include = month_delta % interval == 0 and cursor.day in month_days + else: + years = cursor.year - starts.year + months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")} + month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")} + include = years % interval == 0 and cursor.month in months and cursor.day in month_days if include and cursor >= starts: emitted += 1 if start <= cursor <= end: diff --git a/tests/test_mvp_backend.py b/tests/test_mvp_backend.py index b93fa91..0f8bbff 100644 --- a/tests/test_mvp_backend.py +++ b/tests/test_mvp_backend.py @@ -31,10 +31,18 @@ def test_recurring_calendar_exceptions_and_scopes(client): ) assert recurrence.status_code == 201 recurrence_id = recurrence.json()["id"] + yearly = client.post( + "/api/v1/tasks", + json={"title": "年度任务", "list_id": inbox["id"], "due_at": "2026-09-06T09:00:00Z"}, + ).json() + assert client.post( + "/api/v1/recurrences", json={"task_id": yearly["id"], "rrule": "FREQ=YEARLY;INTERVAL=1;BYMONTH=9;BYMONTHDAY=6"} + ).status_code == 201 calendar = client.get( "/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"} ).json() assert any(row.get("id") == normal_task["id"] for row in calendar) + assert any(row["title"] == "年度任务" for row in calendar) assert not any(row["title"] == "月外普通任务" for row in calendar) occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id] assert len(occurrences) == 5