From 7ab5896dc06f7ccf52c2d89156d8e67e85df43bf Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sat, 5 Sep 2026 21:04:56 +0800 Subject: [PATCH] fix: recurrence ghost exactness and calendar dedup --- backend/mvp.py | 84 ++++++++++++++++++---------- tests/test_mvp_backend.py | 114 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 30 deletions(-) diff --git a/backend/mvp.py b/backend/mvp.py index da8d692..6573d0a 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -122,18 +122,22 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool: - """Return True when `at` is an occurrence this rule actually generates.""" + """Return True when `at` is the exact timestamp of an occurrence this rule generates.""" parts = parse_rrule(rule) interval = int(parts.get("INTERVAL", 1)) if starts.tzinfo is None: starts = starts.replace(tzinfo=UTC) + else: + starts = starts.astimezone(UTC) if at.tzinfo is None: at = at.replace(tzinfo=UTC) + else: + at = at.astimezone(UTC) if at < starts: return False if "UNTIL" in parts: until = datetime.fromisoformat(parts["UNTIL"]) - until = until.replace(tzinfo=UTC) if until.tzinfo is None else until + until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC) if at > until: return False if "COUNT" in parts: @@ -165,19 +169,11 @@ def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool: guard += 1 cursor += timedelta(days=1) return False - if parts["FREQ"] == "DAILY": - return (at.date() - starts.date()).days % interval == 0 - if parts["FREQ"] == "WEEKLY": - days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")} - return at.weekday() in days and ((at.date() - starts.date()).days // 7) % interval == 0 - if parts["FREQ"] == "MONTHLY": - month_delta = (at.year - starts.year) * 12 + at.month - starts.month - month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")} - return month_delta % interval == 0 and at.day in month_days - years = at.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(",")} - return years % interval == 0 and at.month in months and at.day in month_days + # Exact-match validation via the same canonical day generator used for + # rendering, so time-of-day is preserved and nothing outside the rule is + # accepted as a valid occurrence. + candidates = occurrences(rule, starts, starts, at, None) + return at in candidates def is_occurrence_utc(rule: str, starts: datetime, at: datetime) -> bool: @@ -202,6 +198,11 @@ def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime """Reject occurrence_at values that are not valid occurrences of this recurrence rule.""" if not is_occurrence(template.rrule, template.starts_at, occurrence_at): raise HTTPException(422, "occurrence_at 不是该重复规则的有效发生时刻") + if template.ends_at: + at = occurrence_at.replace(tzinfo=UTC) if occurrence_at.tzinfo is None else occurrence_at.astimezone(UTC) + ends = template.ends_at.replace(tzinfo=UTC) if template.ends_at.tzinfo is None else template.ends_at.astimezone(UTC) + if at > ends: + raise HTTPException(422, "occurrence_at 晚于该重复规则的有效截止时间") @router.post("/recurrences", status_code=201) @@ -219,26 +220,35 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr @router.get("/calendar") -async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z_+-]+(/[A-Za-z_+-]+)*$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): +async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$"), timezone_offset: int | None = Query(default=None, ge=-840, le=840), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): if end < start or (end - start).days > 366: raise HTTPException(422, "日期范围无效或超过一年") try: zone = ZoneInfo(timezone) except ZoneInfoNotFoundError: raise HTTPException(422, "未知时区") - start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC) - end_dt = (datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone)).astimezone(UTC) - timedelta(microseconds=1) + if timezone_offset is not None and timezone == "UTC": + # Legacy clients pass an offset instead of an IANA name. + zone = ZoneInfo("UTC") + start_dt = datetime.combine(start, time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) + end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) - timedelta(microseconds=1) + else: + start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC) + end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone).astimezone(UTC) - timedelta(microseconds=1) + + def as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all() template_ids = [template.id for template, _ in recurrence_rows] exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all()) exceptions_by_template = {} for exception in exception_rows: - key = exception.occurrence_at.replace(tzinfo=UTC) if exception.occurrence_at.tzinfo is None else exception.occurrence_at + key = as_utc(exception.occurrence_at) exceptions_by_template.setdefault(exception.template_id, {})[key] = exception - def as_utc(value: datetime) -> datetime: - return value.replace(tzinfo=UTC) if value.tzinfo is None else value - recurring_task_ids = {task.id for _, task in recurrence_rows} normal_query = select(Task).where( Task.user_id == user.id, @@ -260,11 +270,17 @@ async def calendar(start: date, end: date, timezone: str = Query(default="UTC", "completed": task.completed, "version": task.version, } for task in normal_tasks] + emitted = set() for template, task in recurrence_rows: exceptions = exceptions_by_template.get(template.id, {}) - # Render events that this recurrence generates: real occurrences - # whose displayed (possibly moved) due_at falls inside the window. - for at in occurrences(template.rrule, template.starts_at, template.starts_at, end_dt, template.ends_at): + # Canonical series from the template's true start so every exception + # whose occurrence is real and inside this series is considered. + series_start = template.starts_at + if exceptions: + first_exception = min(as_utc(at) for at in exceptions) + series_start = min(as_utc(series_start), first_exception) + generated = occurrences(template.rrule, template.starts_at, as_utc(series_start), end_dt, template.ends_at) + for at in generated: exception = exceptions.get(as_utc(at)) if exception and exception.deleted: continue @@ -272,14 +288,22 @@ async def calendar(start: date, end: date, timezone: str = Query(default="UTC", if due_at < start_dt or due_at > end_dt: continue output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": due_at, "completed": bool(exception and exception.completed)}) - # Occurrences moved far enough that their original slot falls before - # the start of generation: show them at their moved due_at. Ghost - # occurrence_at values are already rejected at write time. + emitted.add(as_utc(at)) + # Fall back for stored exceptions whose original slot is real but which + # the canonical generation skipped only because their original date is + # outside the requested window (e.g. moved backwards across the month). for occurrence_at, exception in exceptions.items(): if exception.deleted or exception.due_at is None: continue - if as_utc(occurrence_at) >= as_utc(template.starts_at) and start_dt <= as_utc(exception.due_at) <= end_dt: - output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": as_utc(exception.due_at), "completed": bool(exception.completed)}) + if occurrence_at in emitted: + continue + if not is_occurrence(template.rrule, template.starts_at, occurrence_at): + continue + if template.ends_at and occurrence_at > as_utc(template.ends_at): + continue + due_at = as_utc(exception.due_at) + if start_dt <= due_at <= end_dt: + output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": due_at, "completed": bool(exception.completed)}) return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"]) diff --git a/tests/test_mvp_backend.py b/tests/test_mvp_backend.py index 216cc0e..4e7a8c2 100644 --- a/tests/test_mvp_backend.py +++ b/tests/test_mvp_backend.py @@ -154,6 +154,120 @@ def test_recurrence_mutations_reject_ghost_occurrences(client): assert len([row for row in calendar if row.get("recurrence_id") == recurrence["id"]]) == 3 +def test_calendar_does_not_duplicate_moved_exceptions_when_both_in_range(client): + inbox = boot(client) + task = client.post( + "/api/v1/tasks", + json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"}, + ).json() + recurrence = client.post( + "/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=4"} + ).json() + # Series: 9/14, 9/21, 9/28, 10/5. Move the 9/21 occurrence to 9/23. + client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"}, + json={"due_at": "2026-09-23T09:00:00Z"}, + ) + calendar = client.get( + "/api/v1/calendar", + params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"}, + ).json() + matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]] + keys = [(row["occurrence_at"], row["due_at"]) for row in matches] + assert len(keys) == len(set(keys)) + assert len(matches) == 3 + due_days = [row["due_at"][:10] for row in matches] + assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"] + assert not any(row["due_at"].startswith("2026-09-21") for row in matches) + + +def test_recurrence_rejects_wrong_time_of_day_and_alternate_offset(client): + inbox = boot(client) + task = client.post( + "/api/v1/tasks", + json={"title": "九点站会", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"}, + ).json() + recurrence = client.post( + "/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"} + ).json() + + wrong_hour = client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-09-07T10:00:00Z"}, + json={"title": "幽灵"}, + ) + assert wrong_hour.status_code == 422 + + same_instant = client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-09-07T17:00:00+08:00"}, + json={"title": "等价时刻"}, + ) + assert same_instant.status_code == 200 + + +def test_recurrence_rejects_occurrence_after_cutoff(client): + inbox = boot(client) + task = client.post( + "/api/v1/tasks", + json={"title": "每天重复", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"}, + ).json() + recurrence = client.post( + "/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY;COUNT=10"} + ).json() + # Cut the series off at 9/3 (ends_at = 9/3T08:59:59.999999Z) + client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this-and-future", "occurrence_at": "2026-09-03T09:00:00Z"}, + json={}, + ) + after_cutoff = client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-09-05T09:00:00Z"}, + json={"title": "晚于截止"}, + ) + assert after_cutoff.status_code == 422 + + +def test_calendar_accepts_legacy_timezone_offset(client): + inbox = boot(client) + client.post( + "/api/v1/tasks", + json={"title": "上海边界任务", "list_id": inbox["id"], "due_at": "2026-09-01T16:00:00Z"}, + ) + calendar = client.get( + "/api/v1/calendar", + params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480}, + ).json() + assert any(row["title"] == "上海边界任务" for row in calendar) + + +def test_calendar_does_not_duplicate_moved_exception_when_original_and_moved_in_range(client): + inbox = boot(client) + task = client.post( + "/api/v1/tasks", + json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"}, + ).json() + recurrence = client.post( + "/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"} + ).json() + # Series: 9/14, 9/21, 9/28 — all inside the requested month. + # Move the 9/21 occurrence to 9/23 (also inside). Regression: this used to render twice. + client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"}, + json={"due_at": "2026-09-23T09:00:00Z"}, + ) + calendar = client.get( + "/api/v1/calendar", + params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"}, + ).json() + matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]] + due_days = sorted(row["due_at"][:10] for row in matches) + assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"] + + def test_habit_logs_support_date_range_filter(client): boot(client) habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()