From 3155155f537692edecc5734c4231e31b7082e58e Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sun, 20 Sep 2026 21:04:31 +0800 Subject: [PATCH] [verified] feat: support monthly completion recurrence --- backend/backup/service.py | 5 +++ backend/main.py | 5 ++- backend/models.py | 1 + backend/mvp.py | 34 ++++++++++++++++--- backend/recurrence_service.py | 16 +++++++-- backend/schemas.py | 21 +++++++++--- frontend/src/App.vue | 22 +++++++----- frontend/src/lib/task-utils.test.ts | 14 ++++---- frontend/src/lib/task-utils.ts | 15 ++++---- frontend/src/style.test.ts | 9 +++-- .../versions/0017_after_completion_unit.py | 29 ++++++++++++++++ tests/test_after_completion_recurrence.py | 31 +++++++++++++++-- tests/test_backup_v2.py | 30 ++++++++++++++++ 13 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 migrations/versions/0017_after_completion_unit.py diff --git a/backend/backup/service.py b/backend/backup/service.py index 2dd0718..14ec57c 100644 --- a/backend/backup/service.py +++ b/backend/backup/service.py @@ -354,6 +354,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None: "rrule": row.get("rrule"), "trigger_mode": row.get("trigger_mode", "scheduled"), "after_completion_days": row.get("after_completion_days"), + "after_completion_unit": row.get("after_completion_unit") + if row.get("trigger_mode", "scheduled") == "after_completion" + else None, }) starts_at = datetime.fromisoformat(row["starts_at"]) ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None @@ -523,6 +526,8 @@ def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID elif isinstance(effective_type, Date) and isinstance(value, str): value = date.fromisoformat(value) values[name] = value + if model is RecurrenceTemplate and "after_completion_unit" not in raw: + values["after_completion_unit"] = "days" if raw.get("trigger_mode") == "after_completion" else None return values diff --git a/backend/main.py b/backend/main.py index 5da7cca..98d7102 100644 --- a/backend/main.py +++ b/backend/main.py @@ -999,7 +999,7 @@ async def create_task( from .mvp import parse_rrule if payload.rrule: parse_rrule(payload.rrule) - data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"}) + data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"}) parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None) max_position = await db.scalar(select(func.max(Task.position)).where( Task.user_id == user.id, @@ -1019,6 +1019,9 @@ async def create_task( starts_at=task.due_at, trigger_mode=payload.trigger_mode or "scheduled", after_completion_days=payload.after_completion_days, + after_completion_unit=(payload.after_completion_unit or "days") + if payload.trigger_mode == "after_completion" + else None, ) ) audit(db, user.id, "create", "task", task.id) diff --git a/backend/models.py b/backend/models.py index 67a4d33..6e3dbbf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -154,6 +154,7 @@ class RecurrenceTemplate(Base): ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled") after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True) last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) diff --git a/backend/mvp.py b/backend/mvp.py index db8ccf4..173c4bc 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel): rrule: str | None = Field(default=None, min_length=5, max_length=1000) trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$") after_completion_days: int | None = Field(default=None, ge=1, le=3650) + after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$") @model_validator(mode="after") def validate_mode(self): if self.trigger_mode == "scheduled" and ( - self.rrule is None or self.after_completion_days is not None + self.rrule is None + or self.after_completion_days is not None + or self.after_completion_unit is not None ): raise ValueError("scheduled recurrence requires rrule and no completion interval") if self.trigger_mode == "after_completion" and ( self.after_completion_days is None or self.rrule is not None ): raise ValueError("after_completion requires days and no rrule") + if self.trigger_mode == "after_completion" and self.after_completion_unit is None: + self.after_completion_unit = "days" return self @@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel): rrule: str | None = None trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$") after_completion_days: int | None = Field(default=None, ge=1, le=3650) + after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$") class OccurrenceComplete(BaseModel): @@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user), "ends_at": row.ends_at, "trigger_mode": row.trigger_mode, "after_completion_days": row.after_completion_days, + "after_completion_unit": row.after_completion_unit, "last_completed_at": row.last_completed_at, } @@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr starts_at=task.due_at, trigger_mode=payload.trigger_mode, after_completion_days=payload.after_completion_days, + after_completion_unit=(payload.after_completion_unit or "days") + if payload.trigger_mode == "after_completion" + else None, ) db.add(row) await db.commit(); await db.refresh(row) @@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr "ends_at": row.ends_at, "trigger_mode": row.trigger_mode, "after_completion_days": row.after_completion_days, + "after_completion_unit": row.after_completion_unit, "last_completed_at": row.last_completed_at, } @@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: if "after_completion_days" in payload.model_fields_set else template.after_completion_days ) + requested_unit = ( + payload.after_completion_unit + if "after_completion_unit" in payload.model_fields_set + else template.after_completion_unit + ) requested_rrule = payload.rrule if payload.rrule is not None else template.rrule if requested_mode == "after_completion": if requested_days is None: raise HTTPException(422, "完成后重复需要间隔天数") template.trigger_mode = requested_mode template.after_completion_days = requested_days + template.after_completion_unit = requested_unit or "days" template.rrule = None else: if requested_rrule is None: @@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: parse_rrule(requested_rrule) template.trigger_mode = requested_mode template.after_completion_days = None + template.after_completion_unit = None template.rrule = requested_rrule.upper() if payload.title is not None: task.title = payload.title @@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: "ends_at": template.ends_at, "trigger_mode": template.trigger_mode, "after_completion_days": template.after_completion_days, + "after_completion_unit": template.after_completion_unit, "last_completed_at": template.last_completed_at, } @@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo "folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders], "lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists], "tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks], - "recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences], + "recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "last_completed_at"]) for x in recurrences], "habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits], "countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns], "memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos], @@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg rrule = raw.get("rrule") if trigger_mode not in {"scheduled", "after_completion"}: raise HTTPException(422, "无效的重复触发模式") + unit = raw.get("after_completion_unit") + if unit is not None and unit not in {"days", "months"}: + raise HTTPException(422, "无效的完成后重复单位") if trigger_mode == "after_completion": if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None: raise HTTPException(422, "无效的完成后重复备份") - elif not isinstance(rrule, str): - raise HTTPException(422, "定期重复缺少 RRULE") + unit = unit or "days" + else: + if not isinstance(rrule, str) or days is not None or unit is not None: + raise HTTPException(422, "定期重复备份包含无效字段") + unit = None db.add(RecurrenceTemplate( user_id=user.id, task_id=task_id, @@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None, trigger_mode=trigger_mode, after_completion_days=days, + after_completion_unit=unit, last_completed_at=datetime.fromisoformat(raw["last_completed_at"]) if raw.get("last_completed_at") else None, )) diff --git a/backend/recurrence_service.py b/backend/recurrence_service.py index d806e0d..7a0537c 100644 --- a/backend/recurrence_service.py +++ b/backend/recurrence_service.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta from uuid import UUID from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from dateutil.relativedelta import relativedelta from fastapi import HTTPException from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo: raise HTTPException(422, "用户时区无效") from exc -def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime: +def _after_completion_due( + task: Task, completed_at: datetime, value: int, unit: str | None, user: User +) -> datetime: zone = _user_zone(user) completed_local = completed_at.astimezone(zone) - target_date = completed_local.date() + timedelta(days=days) + if unit == "months": + target_date = completed_local.date() + relativedelta(months=value) + else: + target_date = completed_local.date() + timedelta(days=value) if task.due_has_time: due_local = task.due_at.astimezone(zone) wall_time = due_local.timetz().replace(tzinfo=None) @@ -67,7 +73,11 @@ async def apply_task_changes( if recurrence.trigger_mode == "after_completion": completed_at = utcnow() next_due = _after_completion_due( - task, completed_at, recurrence.after_completion_days, user + task, + completed_at, + recurrence.after_completion_days, + recurrence.after_completion_unit, + user, ) changes["completed"] = False changes["due_at"] = next_due diff --git a/backend/schemas.py b/backend/schemas.py index e253b84..b7038ed 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -125,6 +125,7 @@ class TaskCreate(BaseModel): rrule: str | None = Field(default=None, min_length=5, max_length=1000) trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$") after_completion_days: int | None = Field(default=None, ge=1, le=3650) + after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$") @field_validator("title") @classmethod @@ -136,7 +137,12 @@ class TaskCreate(BaseModel): @model_validator(mode="after") def validate_recurrence(self): - has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None + has_recurrence = ( + self.rrule is not None + or self.trigger_mode is not None + or self.after_completion_days is not None + or self.after_completion_unit is not None + ) if has_recurrence and self.due_at is None: raise ValueError("recurrence requires due_at") if has_recurrence and self.parent_id is not None: @@ -144,10 +150,15 @@ class TaskCreate(BaseModel): if self.trigger_mode == "after_completion": if self.after_completion_days is None or self.rrule is not None: raise ValueError("after_completion requires days and no rrule") - elif self.trigger_mode == "scheduled" and self.rrule is None: - raise ValueError("scheduled recurrence requires rrule") - elif self.trigger_mode is None and self.after_completion_days is not None: - raise ValueError("after_completion_days requires after_completion mode") + elif self.trigger_mode == "scheduled": + if ( + self.rrule is None + or self.after_completion_days is not None + or self.after_completion_unit is not None + ): + raise ValueError("scheduled recurrence requires rrule and no completion interval") + elif self.after_completion_days is not None or self.after_completion_unit is not None: + raise ValueError("completion interval requires after_completion mode") return self diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b8babdb..eb78a16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,7 @@ import { Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Settings, Trash2, X, Repeat2, StickyNote, } from 'lucide-vue-next' -import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' +import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' @@ -32,7 +32,7 @@ type FolderItem = { id: string; name: string } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] } type RepeatOption = TaskRepeatOption -type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null } +type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null } type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings' const initialized = ref(null) @@ -158,9 +158,11 @@ const composePriority = ref(0) const composeDescription = ref('') const composeRepeat = ref('none') const composeAfterCompletionDays = ref('1') +const composeAfterCompletionUnit = ref('days') const composeRepeatError = ref('') const selectedTaskRepeat = ref('none') const selectedAfterCompletionDays = ref('1') +const selectedAfterCompletionUnit = ref('days') const selectedRepeatError = ref('') const selectedTaskRecurrence = ref(null) const recurrenceLoading = ref(false) @@ -205,6 +207,7 @@ function openTaskCompose() { composeDescription.value = '' composeRepeat.value = 'none' composeAfterCompletionDays.value = '1' + composeAfterCompletionUnit.value = 'days' composeRepeatError.value = '' composeRepeatConfig.value = defaultRepeatConfig() composeCalendarOpen.value = false @@ -243,13 +246,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) { else if (activeView.value === 'memos') void memoPanel.value?.createMemo() else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose() } -async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) { +async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) { if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间') if (value === 'none') { if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' }) return null } - const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config }) + const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config }) if (recurrence) { return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence } @@ -263,6 +266,7 @@ async function loadTaskRecurrence(task: Task) { selectedTaskRecurrence.value = null selectedTaskRepeat.value = 'none' selectedAfterCompletionDays.value = '1' + selectedAfterCompletionUnit.value = 'days' selectedRepeatError.value = '' try { const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null @@ -271,6 +275,7 @@ async function loadTaskRecurrence(task: Task) { const parsed = parseTaskRecurrence(recurrence) selectedTaskRepeat.value = parsed.option selectedAfterCompletionDays.value = String(parsed.afterCompletionDays) + selectedAfterCompletionUnit.value = parsed.afterCompletionUnit selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig() } catch (reason) { if (selectionIsCurrent()) fail(reason) @@ -293,7 +298,7 @@ async function submitTaskCompose() { const targetListId = composeListId.value creatingTask.value = true try { - const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value }) + const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value }) const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : '' if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间') await taskMutationReconciler.run( @@ -1093,6 +1098,7 @@ async function saveSelectedTaskChanges() { const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none' const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig const afterCompletionDays = selectedAfterCompletionDays.value + const afterCompletionUnit = selectedAfterCompletionUnit.value const recurrence = selectedTaskRecurrence.value selectedRepeatError.value = '' try { @@ -1102,7 +1108,7 @@ async function saveSelectedTaskChanges() { selectedTaskRecurrence.value = null selectedTaskRepeat.value = 'none' } else { - const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence) + const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence) if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return selectedTaskRecurrence.value = updatedRecurrence } @@ -1749,7 +1755,7 @@ onUnmounted(() => {
时间
-
完成后 天重复
每次完成后,将截止时间顺延对应天数;首版永不结束
+
完成后 重复
每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天
每隔
{{selectedRepeatError}} @@ -1788,7 +1794,7 @@ onUnmounted(() => { -
完成后 天重复
每次完成后,将截止时间顺延对应天数;首版永不结束
+
完成后 重复
每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天
{{composeRepeatError}}
每隔