feat: add custom task recurrence options
ci / docker (push) Successful in 5m27s

This commit is contained in:
2026-09-08 08:53:02 +08:00
parent 2eca978820
commit 0dd67f2824
5 changed files with 35 additions and 3 deletions
+8 -1
View File
@@ -468,7 +468,12 @@ async def create_task(
) )
if parent is None: if parent is None:
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务") raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
data = payload.model_dump() if payload.rrule:
if not payload.due_at:
raise HTTPException(status_code=422, detail="重复任务需要截止时间")
from .mvp import parse_rrule
parse_rrule(payload.rrule)
data = payload.model_dump(exclude={"rrule"})
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None) 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( max_position = await db.scalar(select(func.max(Task.position)).where(
Task.user_id == user.id, Task.user_id == user.id,
@@ -479,6 +484,8 @@ async def create_task(
task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data) task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data)
db.add(task) db.add(task)
await db.flush() await db.flush()
if payload.rrule:
db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at))
audit(db, user.id, "create", "task", task.id) audit(db, user.id, "create", "task", task.id)
await db.commit() await db.commit()
await db.refresh(task) await db.refresh(task)
+1
View File
@@ -77,6 +77,7 @@ class TaskCreate(BaseModel):
priority: int = Field(default=0, ge=0, le=3) priority: int = Field(default=0, ge=0, le=3)
due_at: datetime | None = None due_at: datetime | None = None
parent_id: UUID | None = None parent_id: UUID | None = None
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
+2 -1
View File
@@ -145,14 +145,15 @@ async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim() const taskTitle = composeTitle.value.trim()
if (!taskTitle || !composeListId.value) return if (!taskTitle || !composeListId.value) return
try { try {
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
title: taskTitle, title: taskTitle,
list_id: composeListId.value, list_id: composeListId.value,
due_at: fromDateTimeLocal(composeDueAt.value), due_at: fromDateTimeLocal(composeDueAt.value),
priority: composePriority.value, priority: composePriority.value,
description: composeDescription.value, description: composeDescription.value,
rrule,
}) }) }) })
if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value, composeRepeatConfig.value) }) })
if (isTaskView(activeView.value)) { if (isTaskView(activeView.value)) {
tasks.value.push(task) tasks.value.push(task)
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value) totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
+1 -1
View File
@@ -177,7 +177,7 @@ describe('unified floating add interaction', () => {
expect(app).toContain('const selectedTaskRepeat = ref') expect(app).toContain('const selectedTaskRepeat = ref')
expect(app).toContain('重复<select v-model="composeRepeat"') expect(app).toContain('重复<select v-model="composeRepeat"')
expect(app).toContain('重复<select v-model="selectedTaskRepeat"') expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
expect(app).toContain("api('/recurrences'") expect(app).toContain('rrule,')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)") expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
expect(app).toContain('<option value="custom">自定义…</option>') expect(app).toContain('<option value="custom">自定义…</option>')
expect(app).toContain('class="repeat-custom-fields"') expect(app).toContain('class="repeat-custom-fields"')
+23
View File
@@ -29,6 +29,29 @@ def test_bootstrap_returns_navigation_and_current_user(client):
def test_creating_task_with_recurrence_is_atomic(client):
inbox = boot(client)
created = client.post(
"/api/v1/tasks",
json={
"title": "隔周复盘",
"list_id": inbox["id"],
"due_at": "2026-09-07T09:00:00Z",
"rrule": "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR",
},
)
assert created.status_code == 201
recurrence = client.get(f"/api/v1/tasks/{created.json()['id']}/recurrence").json()
assert recurrence["rrule"] == "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR"
rejected = client.post(
"/api/v1/tasks",
json={"title": "缺少日期", "list_id": inbox["id"], "rrule": "FREQ=DAILY"},
)
assert rejected.status_code == 422
assert client.get("/api/v1/tasks", params={"q": "缺少日期"}).json()["items"] == []
def test_custom_recurrence_rejects_invalid_weekdays_month_days_and_until(client): def test_custom_recurrence_rejects_invalid_weekdays_month_days_and_until(client):
inbox = boot(client) inbox = boot(client)
task = client.post( task = client.post(