diff --git a/README.md b/README.md index fdb0236..2ce3a1f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - 首次初始化管理员 - 用户名密码登录,Cookie Session - 文件夹、清单、任务基础 CRUD +- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年重复 - 收集箱系统清单 - 习惯打卡与倒数纪念日 - 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复 diff --git a/backend/main.py b/backend/main.py index c326068..8360b78 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,7 @@ import json import time from collections import defaultdict, deque from contextlib import asynccontextmanager -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from uuid import UUID @@ -26,8 +26,8 @@ from .auth import ( verify_password, ) from .db import create_schema, get_db -from .models import AppState, Folder, Session, Task, TaskList, User, utcnow -from .mvp import audit +from .models import AppState, Folder, RecurrenceTemplate, Session, Task, TaskList, User, utcnow +from .mvp import audit, occurrences from .mvp import router as mvp_router from .schemas import ( BatchResult, @@ -624,16 +624,33 @@ async def update_task( user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): + task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))) + if task is None: + raise HTTPException(status_code=404, detail="任务不存在") data = payload.model_dump(exclude_unset=True) expected_version = data.pop("version") + recurrence = None + if data.get("completed") is True: + recurrence = await db.scalar(select(RecurrenceTemplate).where( + RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id + )) + if recurrence: + next_items = occurrences( + recurrence.rrule, + recurrence.starts_at, + recurrence.starts_at + timedelta(microseconds=1), + recurrence.starts_at + timedelta(days=3660), + recurrence.ends_at, + ) + if next_items: + data["completed"] = False + data["due_at"] = next_items[0] + recurrence.starts_at = next_items[0] if "list_id" in data: await _owned_list(db, user.id, data["list_id"]) - parent_id = await db.scalar( - select(Task.parent_id).where(Task.id == task_id, Task.user_id == user.id) - ) - if parent_id: + if task.parent_id: parent_list = await db.scalar( - select(Task.list_id).where(Task.id == parent_id, Task.user_id == user.id) + select(Task.list_id).where(Task.id == task.parent_id, Task.user_id == user.id) ) if parent_list != data["list_id"]: raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单") diff --git a/backend/mvp.py b/backend/mvp.py index bc53904..f5ffa95 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -186,6 +186,17 @@ def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime raise HTTPException(422, "occurrence_at 晚于该重复规则的有效截止时间") +@router.get("/tasks/{task_id}/recurrence") +async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + await owned_task(db, user.id, task_id) + row = await db.scalar(select(RecurrenceTemplate).where( + RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id + )) + if row is None: + return None + return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at, "ends_at": row.ends_at} + + @router.post("/recurrences", status_code=201) async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): task = await owned_task(db, user.id, payload.task_id) @@ -779,12 +790,14 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep def serialize(row, fields): return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields} folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()); countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all()) + recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all()) return { "version": 1, "exported_at": utcnow(), "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", "due_at", "external_id", "deleted_at"]) for x in tasks], + "recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_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], } @@ -852,6 +865,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg for task_id, old_parent_id in pending_tasks: if old_parent_id and old_parent_id in task_id_map: await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id])) + for raw in payload.get("recurrences", []): + task_id = task_id_map.get(raw.get("task_id")) + if not task_id: + continue + db.add(RecurrenceTemplate( + user_id=user.id, + task_id=task_id, + rrule=raw["rrule"], + starts_at=datetime.fromisoformat(raw["starts_at"]), + ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None, + )) for raw in payload.get("habits", []): row = Habit( user_id=user.id, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 47f0ec5..e84b448 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,6 +15,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue' 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; version: number; due_at: string | null; subtasks?: Task[] } +type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' +type Recurrence = { id: string; task_id: string; rrule: string } type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings' const initialized = ref(null) @@ -58,6 +60,10 @@ const composeListId = ref('') const composeDueAt = ref('') const composePriority = ref(0) const composeDescription = ref('') +const composeRepeat = ref('none') +const selectedTaskRepeat = ref('none') +const selectedTaskRecurrence = ref(null) +let recurrenceLoadToken = 0 const habitComposer = ref | null>(null) const countdownComposer = ref | null>(null) const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) @@ -72,6 +78,7 @@ function openTaskCompose() { composeDueAt.value = activeView.value === 'today' ? toDateTimeLocal(quickTaskFields('today', activeList.value, inboxId).due_at ?? null) : '' composePriority.value = 0 composeDescription.value = '' + composeRepeat.value = 'none' taskComposeOpen.value = true nextTick(() => document.querySelector('.task-compose-input')?.focus()) } @@ -83,6 +90,47 @@ function activateFloatingAdd(origin: { x: number; y: number }) { else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin) else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose() } +function repeatRrule(value: RepeatOption) { + return value === 'none' ? '' : `FREQ=${value.toUpperCase()}` +} +function repeatOption(rrule?: string): RepeatOption { + const value = /FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/.exec(rrule ?? '')?.[1]?.toLowerCase() + return (value as RepeatOption | undefined) ?? 'none' +} +async function saveRepeat(task: Task, value: RepeatOption) { + if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间') + if (value === 'none') { + if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' }) + selectedTaskRecurrence.value = null + selectedTaskRepeat.value = 'none' + return + } + const rrule = repeatRrule(value) + if (selectedTaskRecurrence.value) { + await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) }) + selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule } + } else { + selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) }) + } + selectedTaskRepeat.value = value +} +async function loadTaskRecurrence(task: Task) { + const token = ++recurrenceLoadToken + selectedTaskRecurrence.value = null + selectedTaskRepeat.value = 'none' + try { + const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null + if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return + selectedTaskRecurrence.value = recurrence + selectedTaskRepeat.value = repeatOption(recurrence?.rrule) + } catch (reason) { if (token === recurrenceLoadToken) fail(reason) } +} +async function updateSelectedTaskRepeat() { + if (!selectedTask.value) return + try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') } + catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) } +} + async function submitTaskCompose() { const taskTitle = composeTitle.value.trim() if (!taskTitle || !composeListId.value) return @@ -94,6 +142,7 @@ async function submitTaskCompose() { priority: composePriority.value, description: composeDescription.value, }) }) + if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value) }) }) if (isTaskView(activeView.value)) { tasks.value.push(task) totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value) @@ -485,7 +534,18 @@ async function saveTask() { if (!selectedTask.value?.title.trim()) return try { const task = selectedTask.value - await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id } as Partial) + const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at)) + const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial) + if (selectedTaskRecurrence.value) { + if (dueAt) { + await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) }) + } else { + await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' }) + selectedTaskRecurrence.value = null + selectedTaskRepeat.value = 'none' + } + } + if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated } toast('已保存') } catch (reason) { fail(reason) } } @@ -509,6 +569,7 @@ async function addSubtask() { function selectTask(task: Task) { selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] } markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true + void loadTaskRecurrence(task) } async function createFolder() { const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return @@ -642,6 +703,7 @@ onMounted(bootstrap)