[verified] feat: support monthly completion recurrence
ci / gitleaks (push) Successful in 23s
ci / docker (push) Successful in 7m16s

This commit is contained in:
2026-09-20 21:04:31 +08:00
parent e6d720b85c
commit 3155155f53
13 changed files with 193 additions and 39 deletions
+5
View File
@@ -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
+4 -1
View File
@@ -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)
+1
View File
@@ -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)
+30 -4
View File
@@ -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,
))
+13 -3
View File
@@ -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
+16 -5
View File
@@ -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
+14 -8
View File
@@ -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<boolean | null>(null)
@@ -158,9 +158,11 @@ const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const composeAfterCompletionDays = ref('1')
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const composeRepeatError = ref('')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedAfterCompletionDays = ref('1')
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(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(() => {
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
</div>
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数">重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
</section>
@@ -1788,7 +1794,7 @@ onUnmounted(() => {
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
</div>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数">重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选支持 Markdown"/></label>
+8 -6
View File
@@ -75,12 +75,14 @@ describe('task utilities', () => {
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
})
it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
it('builds and parses completion-trigger intervals in days or months', () => {
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' })
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '2', afterCompletionUnit: 'months' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 2, after_completion_unit: 'months' })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14, afterCompletionUnit: 'days' })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 3, after_completion_unit: 'months' })).toEqual({ option: 'after_completion', afterCompletionDays: 3, afterCompletionUnit: 'months' })
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数')
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数')
})
it('builds scheduled recurrence payloads separately from completion triggers', () => {
+8 -7
View File
@@ -172,15 +172,16 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
}
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
export type AfterCompletionUnit = 'days' | 'months'
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; repeatConfig?: TaskRepeatConfig }) {
if (option === 'none') return {}
if (option === 'after_completion') {
const raw = String(values.afterCompletionDays).trim()
const days = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
return { trigger_mode: 'after_completion' as const, after_completion_days: days }
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数')
return { trigger_mode: 'after_completion' as const, after_completion_days: days, after_completion_unit: values.afterCompletionUnit ?? 'days' }
}
const rrule = option === 'custom'
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
@@ -189,13 +190,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
}
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
if (recurrence.trigger_mode === 'after_completion') {
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
}
const parsed = parseTaskRrule(recurrence.rrule ?? '')
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
}
export function defaultTaskDueAt(now = new Date()) {
+6 -3
View File
@@ -1370,10 +1370,13 @@ describe('unified floating add interaction', () => {
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
expect(app).toContain('v-model="composeAfterCompletionUnit"')
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
expect(app).toContain('v-model="selectedAfterCompletionUnit"')
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
expect(app).toContain('月末会自动取目标月最后一天')
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
expect(createBlock).toContain("api('/tasks',")
@@ -1388,7 +1391,7 @@ describe('unified floating add interaction', () => {
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)')
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
expect(saveBlock).toContain("selectedRepeatError.value = ''")
@@ -0,0 +1,29 @@
"""add after_completion unit
Revision ID: 0021_after_completion_unit
Revises: 0020_calendar_subscriptions
"""
import sqlalchemy as sa
from alembic import op
revision = "0021_after_completion_unit"
down_revision = "0020_calendar_subscriptions"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("recurrence_templates") as batch_op:
batch_op.add_column(
sa.Column("after_completion_unit", sa.String(length=8), nullable=True)
)
op.execute(
"UPDATE recurrence_templates SET after_completion_unit = 'days' "
"WHERE trigger_mode = 'after_completion'"
)
def downgrade() -> None:
with op.batch_alter_table("recurrence_templates") as batch_op:
batch_op.drop_column("after_completion_unit")
+29 -2
View File
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True,
)
gap_due = recurrence_service._after_completion_due(
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
)
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
due_has_time=True,
)
fold_due = recurrence_service._after_completion_due(
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
)
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
inbox = boot(client)
task = create_after_completion_task(
client,
inbox,
due_at="2026-01-31T01:30:00Z",
after_completion_days=1,
after_completion_unit="months",
).json()
monkeypatch.setattr(
"backend.recurrence_service.utcnow",
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
)
completed = client.patch(
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
)
assert completed.status_code == 200
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
assert recurrence["after_completion_unit"] == "months"
def test_atomic_task_create_and_read_after_completion_recurrence(client):
inbox = boot(client)
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
"ends_at": None,
"trigger_mode": "after_completion",
"after_completion_days": 2,
"after_completion_unit": "days",
"last_completed_at": None,
}
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
]
for payload in invalid_payloads:
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
+30
View File
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
assert asyncio.run(counts()) == (1, 1)
def test_legacy_backup_without_completion_unit_is_idempotent(client):
inbox = boot(client)
client.post(
"/api/v1/tasks",
json={
"title": "旧版完成后重复",
"list_id": inbox["id"],
"due_at": "2026-03-08T01:30:00Z",
"trigger_mode": "after_completion",
"after_completion_days": 2,
},
)
content = client.get("/api/v1/backup/export.zip").content
recurrences = _archive_rows(content, "recurrences")
for recurrence in recurrences:
recurrence.pop("after_completion_unit", None)
legacy_content = _replace_entities(content, {"recurrences": recurrences})
first_token = _preflight(client, legacy_content).json()["preflight_token"]
assert client.post(
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
).status_code == 200
second_token = _preflight(client, legacy_content).json()["preflight_token"]
second = client.post(
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
)
assert second.status_code == 200
assert second.json()["already_imported"] is True
def test_invalid_zip_variants_are_rejected_before_any_write(client):
inbox = boot(client)
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])