diff --git a/backend/main.py b/backend/main.py index 8711a46..71ead09 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,11 +2,12 @@ import base64 import json import logging import os +import re import shutil import time from collections import defaultdict, deque from contextlib import asynccontextmanager -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path, PureWindowsPath from uuid import UUID, uuid4 @@ -14,7 +15,7 @@ from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response from fastapi.openapi.docs import get_swagger_ui_html from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from sqlalchemy import delete, exists, func, or_, select, update +from sqlalchemy import and_, case, delete, exists, func, or_, select, tuple_, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -817,16 +818,123 @@ async def purge_list( -def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str: - raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode() +def _task_ordering(): + """Portable total ordering: open, dated, due time, then manual/stable keys.""" + return ( + case((Task.completed.is_(False), 0), else_=1), + case((Task.due_at.is_(None), 1), else_=0), + Task.due_at.asc(), + Task.position.asc(), + Task.created_at.asc(), + Task.id.asc(), + ) + + +def _task_cursor_values(task: Task) -> list[object]: + return [ + 0 if not task.completed else 1, + 1 if task.due_at is None else 0, + task.due_at.isoformat() if task.due_at else None, + task.position, + task.created_at.isoformat(), + str(task.id), + ] + + +def _encode_task_cursor(task: Task) -> str: + raw = json.dumps({"v": 2, "keys": _task_cursor_values(task)}).encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") -def _decode_cursor(cursor: str) -> tuple[int, datetime, UUID]: +_CURSOR_MAX_POSITION = 2**63 - 1 +_CURSOR_DATETIME = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$" +) + + +def _cursor_rank(value: object) -> int: + if type(value) is not int or value not in (0, 1): + raise ValueError + return value + + +def _cursor_position(value: object) -> int: + if type(value) is not int or not 0 <= value <= _CURSOR_MAX_POSITION: + raise ValueError + return value + + +def _cursor_datetime(value: object) -> datetime: + if not isinstance(value, str) or not _CURSOR_DATETIME.fullmatch(value): + raise ValueError + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError + return parsed.astimezone(UTC) + + +def _decode_task_cursor(cursor: str) -> tuple[int, int, datetime | None, int, datetime, UUID]: try: raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) - position, timestamp, task_id = json.loads(raw) - return int(position), datetime.fromisoformat(timestamp), UUID(task_id) + payload = json.loads(raw) + if not isinstance(payload, dict) or type(payload.get("v")) is not int or payload["v"] != 2: + raise ValueError + keys = payload["keys"] + if not isinstance(keys, list) or len(keys) != 6: + raise ValueError + completed_rank, due_rank, due_at, position, created_at, task_id = keys + completed_rank = _cursor_rank(completed_rank) + due_rank = _cursor_rank(due_rank) + if (due_rank == 0) != (due_at is not None): + raise ValueError + if not isinstance(task_id, str): + raise TypeError + return ( + completed_rank, + due_rank, + _cursor_datetime(due_at) if due_at is not None else None, + _cursor_position(position), + _cursor_datetime(created_at), + UUID(task_id), + ) + except (ValueError, TypeError, KeyError, UnicodeError, json.JSONDecodeError, OverflowError) as exc: + raise HTTPException(status_code=422, detail="无效的游标") from exc + + +def _task_after_cursor(values: tuple[int, int, datetime | None, int, datetime, UUID]): + completed_rank, due_rank, due_at, position, created_at, task_id = values + completed_expr = case((Task.completed.is_(False), 0), else_=1) + due_expr = case((Task.due_at.is_(None), 1), else_=0) + prefix = [completed_expr == completed_rank, due_expr == due_rank] + alternatives = [completed_expr > completed_rank, and_(prefix[0], due_expr > due_rank)] + if due_rank == 0 and due_at is not None: + alternatives.append(and_(*prefix, Task.due_at > due_at)) + prefix.append(Task.due_at == due_at) + alternatives.extend( + [ + and_(*prefix, Task.position > position), + and_(*prefix, Task.position == position, Task.created_at > created_at), + and_( + *prefix, + Task.position == position, + Task.created_at == created_at, + Task.id > task_id, + ), + ] + ) + return or_(*alternatives) + + +def _encode_trash_cursor(created_at: datetime, task_id: UUID) -> str: + raw = json.dumps([created_at.isoformat(), str(task_id)]).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _decode_trash_cursor(cursor: str) -> tuple[datetime, UUID]: + try: + raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) + timestamp, task_id = json.loads(raw) + return datetime.fromisoformat(timestamp), UUID(task_id) except (ValueError, TypeError, json.JSONDecodeError) as exc: raise HTTPException(status_code=422, detail="无效的游标") from exc @@ -922,43 +1030,40 @@ async def list_tasks( or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match) ) total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 - ordering = (Task.position, Task.created_at, Task.id) + ordering = _task_ordering() if page is not None: size = page_size or limit items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size) if cursor: - position, created_at, task_id = _decode_cursor(cursor) - query = query.where( - or_( - Task.position > position, - (Task.position == position) & (Task.created_at > created_at), - (Task.position == position) & (Task.created_at == created_at) & (Task.id > task_id), - ) - ) + query = query.where(_task_after_cursor(_decode_task_cursor(cursor))) rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] - next_cursor = _encode_cursor(items[-1].position, items[-1].created_at, items[-1].id) if has_more else None + next_cursor = _encode_task_cursor(items[-1]) if has_more else None return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]: if not tasks: return [] - task_ids = [task.id for task in tasks] + allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks} subtasks = list( ( await db.scalars( select(Task) - .where(Task.parent_id.in_(task_ids), Task.deleted_at.is_(None)) - .order_by(Task.position, Task.created_at, Task.id) + .where( + tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes), + Task.deleted_at.is_(None), + ) + .order_by(*_task_ordering()) ) ).all() ) subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list) for subtask in subtasks: - subtasks_by_task[subtask.parent_id].append(subtask) + if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes: + subtasks_by_task[subtask.parent_id].append(subtask) return [ TaskDetailOut( **TaskOut.model_validate(task).model_dump(), @@ -985,9 +1090,16 @@ async def reorder_tasks( parent_scopes = {row.parent_id for row in rows} if len(parent_scopes) != 1: raise HTTPException(status_code=400, detail="只能调整同一层级任务的顺序") + list_scopes = {row.list_id for row in rows} + if len(list_scopes) != 1: + raise HTTPException(status_code=400, detail="只能调整同一清单内任务的顺序") + sort_tiers = {(row.completed, row.due_at) for row in rows} + if len(sort_tiers) != 1: + raise HTTPException(status_code=400, detail="只能调整相同完成状态和截止时间档的任务顺序") parent_id = next(iter(parent_scopes)) scope_query = select(Task).where( Task.user_id == user.id, + Task.list_id == next(iter(list_scopes)), Task.deleted_at.is_(None), Task.parent_id.is_(None) if parent_id is None else Task.parent_id == parent_id, ).order_by(Task.position, Task.created_at, Task.id) @@ -1150,14 +1262,14 @@ async def list_trash( items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size) if cursor: - created_at, task_id = _decode_cursor(cursor) + created_at, task_id = _decode_trash_cursor(cursor) query = query.where( or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) ) rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] - next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None + next_cursor = _encode_trash_cursor(items[-1].created_at, items[-1].id) if has_more else None return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) diff --git a/backend/models.py b/backend/models.py index 7298977..f096f12 100644 --- a/backend/models.py +++ b/backend/models.py @@ -15,11 +15,40 @@ from sqlalchemy import ( UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import TypeDecorator from uuid_utils import uuid7 from .db import Base +class UTCDateTime(TypeDecorator): + """Store absolute instants as UTC while preserving the existing SQL column types. + + SQLite's native datetime processor preserves offsets present in historical text; + those aware values are converted by instant. Legacy naive values are necessarily + interpreted as the UTC convention previously used by this application. + """ + + impl = DateTime(timezone=True) + cache_ok = True + + def process_bind_param(self, value: datetime | None, dialect): + if value is None: + return None + if value.tzinfo is None: + normalized = value.replace(tzinfo=UTC) + else: + normalized = value.astimezone(UTC) + return normalized.replace(tzinfo=None) if dialect.name == "sqlite" else normalized + + def process_result_value(self, value: datetime | None, dialect): + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + def new_id() -> UUID: return UUID(str(uuid7())) @@ -104,13 +133,13 @@ class Task(Base): description: Mapped[str] = mapped_column(Text, default="") priority: Mapped[int] = mapped_column(Integer, default=0) completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True) - due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + due_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) due_has_time: Mapped[bool] = mapped_column(Boolean, default=False) version: Mapped[int] = mapped_column(Integer, default=1) position: Mapped[int] = mapped_column(Integer, default=0) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) - deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow) + deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) external_id: Mapped[str | None] = mapped_column(String(255), nullable=True) @@ -120,9 +149,9 @@ class RecurrenceTemplate(Base): user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), unique=True) rrule: Mapped[str] = mapped_column(Text) - starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + starts_at: Mapped[datetime] = mapped_column(UTCDateTime()) + ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) class RecurrenceException(Base): @@ -130,9 +159,9 @@ class RecurrenceException(Base): __table_args__ = (UniqueConstraint("template_id", "occurrence_at", name="uq_recurrence_exception"),) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) template_id: Mapped[UUID] = mapped_column(ForeignKey("recurrence_templates.id", ondelete="CASCADE"), index=True) - occurrence_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + occurrence_at: Mapped[datetime] = mapped_column(UTCDateTime()) title: Mapped[str | None] = mapped_column(String(500), nullable=True) - due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + due_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) completed: Mapped[bool] = mapped_column(Boolean, default=False) deleted: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e3e62cf..f95c480 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,7 @@ import { Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, Settings, Trash2, X, Repeat2, } from 'lucide-vue-next' -import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' +import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse } from './lib/completion-motion' @@ -100,6 +100,7 @@ const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null) const taskSwipeOffsets = ref>({}) const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null) const taskReorderTarget = ref('') +const taskReorderBlocked = ref(false) const taskComposeOpen = ref(false) const composeTitle = ref('') const composeTitleError = ref('') @@ -622,6 +623,7 @@ function startTaskReorder(task: Task, event: PointerEvent) { if (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 } taskReorderTarget.value = task.id + taskReorderBlocked.value = false try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ } } function taskById(id: string) { @@ -636,16 +638,33 @@ function moveTaskReorder(task: Task, event: PointerEvent) { const row = document.elementsFromPoint(event.clientX, event.clientY) .map((element) => element.closest('[data-task-id]')) .find((element) => element && element !== handle.closest('[data-task-id]')) - if (row?.dataset.taskId) taskReorderTarget.value = row.dataset.taskId + const target = row?.dataset.taskId ? taskById(row.dataset.taskId) : undefined + if (target && target.list_id === task.list_id && (target.parent_id ?? null) === (task.parent_id ?? null) && isSameTaskSortTier(task, target)) { + taskReorderTarget.value = target.id + taskReorderBlocked.value = false + } else { + taskReorderTarget.value = '' + taskReorderBlocked.value = Boolean(target) + } } async function finishTaskReorder(task: Task, event: PointerEvent) { const drag = taskReorder.value const targetId = taskReorderTarget.value + const blocked = taskReorderBlocked.value taskReorder.value = null taskReorderTarget.value = '' + taskReorderBlocked.value = false + if (blocked) { + toast('只能调整相同完成状态和截止时间档的任务顺序') + return + } if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return const target = taskById(targetId) - if (!target || (target.parent_id ?? null) !== (task.parent_id ?? null)) return + if (!target || target.list_id !== task.list_id || (target.parent_id ?? null) !== (task.parent_id ?? null)) return + if (!isSameTaskSortTier(task, target)) { + toast('只能调整相同完成状态和截止时间档的任务顺序') + return + } const placement = event.clientY >= drag.startY ? 'after' : 'before' const previous = tasks.value const previousSelected = selectedTask.value @@ -657,12 +676,12 @@ async function finishTaskReorder(task: Task, event: PointerEvent) { if (reordered === parent.subtasks) return tasks.value = tasks.value.map((item) => item.id === parent.id ? { ...item, subtasks: reordered } : item) if (selectedTask.value?.id === parent.id) selectedTask.value = { ...selectedTask.value, subtasks: reordered } - ids = reordered.map((item) => item.id) + ids = reordered.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id) } else { const next = moveItemWithinScope(previous, task.id, targetId, placement) if (next === previous) return tasks.value = next - ids = next.map((item) => item.id) + ids = next.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id) } try { await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }) @@ -676,6 +695,7 @@ async function finishTaskReorder(task: Task, event: PointerEvent) { function cancelTaskReorder() { taskReorder.value = null taskReorderTarget.value = '' + taskReorderBlocked.value = false } function startTaskSwipe(task: Task, event: TouchEvent) { @@ -771,6 +791,15 @@ function selectTaskUnlessSwiped(task: Task, toggleChildren = false) { if (toggleChildren) toggleTaskChildren(task) selectTask(task) } +async function refreshTodayAfterTaskSave() { + if (activeView.value !== 'today') return + const request = beginLatestRequest('tasks') + await Promise.all([ + loadTasksPage(request), + loadOverdueTasks(request), + loadTodayTaskSummary(), + ]) +} async function saveTask() { if (!selectedTask.value) return const normalized = normalizeRequiredName(selectedTask.value.title) @@ -793,7 +822,7 @@ async function saveTask() { } } if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated } - if (activeView.value === 'today') void loadTodayTaskSummary() + await refreshTodayAfterTaskSave() toast('已保存') } catch (reason) { fail(reason) } } diff --git a/frontend/src/lib/task-utils.test.ts b/frontend/src/lib/task-utils.test.ts index 1fb6580..f3befa9 100644 --- a/frontend/src/lib/task-utils.test.ts +++ b/frontend/src/lib/task-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildTaskRrule, defaultTaskDueAt, filterTasks, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils' +import { buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils' type SearchTask = { id: string @@ -33,6 +33,25 @@ describe('task utilities', () => { expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }]) }) + it('classifies due edits for Today membership', () => { + const start = new Date('2026-09-10T00:00:00+08:00') + const end = new Date('2026-09-11T00:00:00+08:00') + expect(classifyTaskForToday({ completed: false, due_at: '2026-09-09T12:00:00+08:00' }, start, end)).toBe('overdue') + expect(classifyTaskForToday({ completed: false, due_at: '2026-09-10T12:00:00+08:00' }, start, end)).toBe('today') + expect(classifyTaskForToday({ completed: false, due_at: '2026-09-11T12:00:00+08:00' }, start, end)).toBe('outside') + expect(classifyTaskForToday({ completed: true, due_at: '2026-09-09T12:00:00+08:00' }, start, end)).toBe('outside') + }) + + it('only allows manual movement within the same completion and deadline tier', () => { + const noDueOpen = { completed: false, due_at: null } + expect(isSameTaskSortTier(noDueOpen, { completed: false, due_at: null })).toBe(true) + expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-10T08:00:00Z' })).toBe(true) + expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-10T16:00:00+08:00' })).toBe(true) + expect(isSameTaskSortTier(noDueOpen, { completed: false, due_at: '2026-09-10T08:00:00Z' })).toBe(false) + expect(isSameTaskSortTier(noDueOpen, { completed: true, due_at: null })).toBe(false) + expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-11T08:00:00Z' })).toBe(false) + }) + it('moves an item before or after another item without changing other scopes', () => { const rows = [ { id: 'a', parent_id: null }, diff --git a/frontend/src/lib/task-utils.ts b/frontend/src/lib/task-utils.ts index 1bb51ae..b238752 100644 --- a/frontend/src/lib/task-utils.ts +++ b/frontend/src/lib/task-utils.ts @@ -4,6 +4,7 @@ export type MinimalTask = { description?: string parent_id?: string | null completed?: boolean + due_at?: string | null list_name?: string subtasks?: MinimalTask[] } @@ -92,6 +93,30 @@ export function groupTaskTree(tasks: T[]) { })) } +export function classifyTaskForToday( + task: Pick, + start: Date, + end: Date, +): 'overdue' | 'today' | 'outside' { + if (task.completed || !task.due_at) return 'outside' + const due = Date.parse(task.due_at) + if (!Number.isFinite(due)) return 'outside' + if (due < start.valueOf()) return 'overdue' + return due < end.valueOf() ? 'today' : 'outside' +} + +export function isSameTaskSortTier( + source: Pick, + target: Pick, +) { + if (Boolean(source.completed) !== Boolean(target.completed)) return false + if (!source.due_at && !target.due_at) return true + if (!source.due_at || !target.due_at) return false + const sourceTime = Date.parse(source.due_at) + const targetTime = Date.parse(target.due_at) + return Number.isFinite(sourceTime) && Number.isFinite(targetTime) && sourceTime === targetTime +} + export function moveItemWithinScope(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') { if (sourceId === targetId) return items const source = items.find((item) => item.id === sourceId) diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 8652bec..1c535c6 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -629,6 +629,16 @@ describe('task and habit row decoration', () => { expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'") }) + it('reclassifies Today tasks after due edits without toggling page loading', () => { + const saveBlock = app.slice(app.indexOf('async function saveTask()'), app.indexOf('async function removeTask')) + const refreshBlock = app.slice(app.indexOf('async function refreshTodayAfterTaskSave()'), app.indexOf('async function saveTask()')) + expect(saveBlock).toContain('await refreshTodayAfterTaskSave()') + expect(refreshBlock).toContain('loadTasksPage(request)') + expect(refreshBlock).toContain('loadOverdueTasks(request)') + expect(refreshBlock).toContain('loadTodayTaskSummary()') + expect(refreshBlock).not.toContain('loading.value = true') + }) + it('shows unfinished overdue tasks as a separate list inside Today', () => { expect(app).toContain('const overdueTasks = ref([])') expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))") diff --git a/tests/test_app.py b/tests/test_app.py index a9dbfc2..0425bfe 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,10 @@ +import base64 +import json import os +from uuid import UUID from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker os.environ.setdefault("DODO_DATABASE_URL", "sqlite+aiosqlite:////tmp/dodo-health-test.db") os.environ.setdefault("DODO_AUTO_CREATE_SCHEMA", "true") @@ -115,7 +119,7 @@ def test_reorder_tasks_persists_top_level_and_subtask_order(client): assert [task["title"] for task in listed[1]["subtasks"]] == ["子任务 B", "子任务 A"] -def test_reorder_tasks_rejects_mixed_parent_scopes(client): +def test_reorder_tasks_rejects_mixed_parent_scopes_and_lists(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] parent = client.post("/api/v1/tasks", json={"title": "父任务", "list_id": inbox["id"]}).json() @@ -126,6 +130,11 @@ def test_reorder_tasks_rejects_mixed_parent_scopes(client): response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], child["id"]]}) assert response.status_code == 400 + other = client.post("/api/v1/lists", json={"name": "其他清单"}).json() + other_task = client.post("/api/v1/tasks", json={"title": "其他任务", "list_id": other["id"]}).json() + response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], other_task["id"]]}) + assert response.status_code == 400 + def test_task_update_rejects_null_title(client): client.post( @@ -395,6 +404,28 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client): assert "tags" not in listed[0] +def test_trash_cursor_paginates_more_than_fifty_items(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + task_ids = [ + client.post("/api/v1/tasks", json={"title": f"回收任务 {index}", "list_id": inbox["id"]}).json()["id"] + for index in range(52) + ] + for task_id in task_ids: + assert client.delete(f"/api/v1/tasks/{task_id}").status_code == 204 + + first = client.get("/api/v1/trash", params={"limit": 50}) + assert first.status_code == 200 + first_page = first.json() + assert len(first_page["items"]) == 50 + assert first_page["next_cursor"] + + second = client.get("/api/v1/trash", params={"limit": 50, "cursor": first_page["next_cursor"]}) + assert second.status_code == 200 + assert len(second.json()["items"]) == 2 + assert second.json()["next_cursor"] is None + + def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] @@ -453,16 +484,184 @@ def test_batch_supports_due_date_and_soft_delete_atomically(client): def test_cursor_pagination_is_stable_and_rejects_bad_cursor(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] - for i in range(5): - client.post("/api/v1/tasks", json={"title": f"任务{i}", "list_id": inbox["id"]}) - first = client.get("/api/v1/tasks", params={"limit": 2}).json() - second = client.get( - "/api/v1/tasks", params={"limit": 2, "cursor": first["next_cursor"]} - ).json() - third = client.get( - "/api/v1/tasks", params={"limit": 2, "cursor": second["next_cursor"]} - ).json() - ids = [row["id"] for page in (first, second, third) for row in page["items"]] - assert len(ids) == len(set(ids)) == 5 - assert third["next_cursor"] is None + fixtures = [ + ("未完成较晚", "2026-09-12T08:00:00Z", False), + ("未完成较早 A", "2026-09-10T08:00:00Z", False), + ("未完成较早 B", "2026-09-10T08:00:00Z", False), + ("未完成无日期", None, False), + ("已完成较早", "2026-09-09T08:00:00Z", True), + ("已完成无日期", None, True), + ] + for title, due_at, completed in fixtures: + task = client.post( + "/api/v1/tasks", json={"title": title, "list_id": inbox["id"], "due_at": due_at} + ).json() + if completed: + response = client.patch( + f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]} + ) + assert response.status_code == 200 + + pages = [] + cursor = None + while True: + params = {"limit": 2} + if cursor: + params["cursor"] = cursor + page = client.get("/api/v1/tasks", params=params).json() + pages.append(page) + cursor = page["next_cursor"] + if cursor is None: + break + + rows = [row for page in pages for row in page["items"]] + assert [row["title"] for row in rows] == [ + "未完成较早 A", "未完成较早 B", "未完成较晚", "未完成无日期", "已完成较早", "已完成无日期" + ] + assert len({row["id"] for row in rows}) == len(fixtures) + assert all(page["total"] == len(fixtures) for page in pages) assert client.get("/api/v1/tasks", params={"cursor": "broken"}).status_code == 422 + + +def _encoded_cursor_payload(keys): + return base64.urlsafe_b64encode(json.dumps({"v": 2, "keys": keys}).encode()).decode().rstrip("=") + + +def test_active_cursor_rejects_malicious_key_matrix_with_stable_422(client): + client = initialized_client(client) + task_id = "00000000-0000-0000-0000-000000000001" + created_at = "2026-09-10T08:00:00Z" + valid = [0, 1, None, 0, created_at, task_id] + invalid_keys = [ + [True, 1, None, 0, created_at, task_id], + [0, False, "2026-09-10T08:00:00Z", 0, created_at, task_id], + ["0", 1, None, 0, created_at, task_id], + [0, 1.0, None, 0, created_at, task_id], + [9, 1, None, 0, created_at, task_id], + [0, 9, None, 0, created_at, task_id], + [0, 0, None, 0, created_at, task_id], + [0, 1, "2026-09-10T08:00:00Z", 0, created_at, task_id], + [0, 0, "2026-09-10", 0, created_at, task_id], + [0, 0, "2026-09-10T08:00:00", 0, created_at, task_id], + [0, 0, "2026-09-10T08Z", 0, created_at, task_id], + [0, 0, "9999-12-31T23:59:59-14:00", 0, created_at, task_id], + [0, 1, None, True, created_at, task_id], + [0, 1, None, "0", created_at, task_id], + [0, 1, None, -1, created_at, task_id], + [0, 1, None, 2**63, created_at, task_id], + [0, 1, None, 0, "2026-09-10", task_id], + [0, 1, None, 0, "2026-09-10T08:00:00", task_id], + [0, 1, None, 0, "9999-12-31T23:59:59-14:00", task_id], + [0, 1, None, 0, created_at, True], + [0, 1, None, 0, created_at, "not-a-uuid"], + valid[:-1], + [*valid, "extra"], + ] + for keys in invalid_keys: + response = client.get("/api/v1/tasks", params={"cursor": _encoded_cursor_payload(keys)}) + assert response.status_code == 422, keys + assert response.json()["detail"] == "无效的游标" + + +def test_task_details_do_not_include_foreign_or_wrong_list_subtasks(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + other_list = client.post("/api/v1/lists", json={"name": "其他清单"}).json() + parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json() + + async def inject_malformed_children(): + from sqlalchemy import select + + from backend.db import get_engine + from backend.models import Task, User + + session_factory = async_sessionmaker(get_engine(), expire_on_commit=False) + async with session_factory() as db: + owner = await db.scalar(select(User).where(User.username == "owner")) + foreign = User(username="foreign", password_hash="unused") + db.add(foreign) + await db.flush() + db.add_all([ + Task( + user_id=foreign.id, + list_id=UUID(inbox["id"]), + parent_id=UUID(parent["id"]), + title="FOREIGN SECRET", + ), + Task( + user_id=owner.id, + list_id=UUID(other_list["id"]), + parent_id=UUID(parent["id"]), + title="WRONG LIST SECRET", + ), + ]) + await db.commit() + + import asyncio + asyncio.run(inject_malformed_children()) + + detail = client.get(f"/api/v1/tasks/{parent['id']}") + assert detail.status_code == 200 + assert detail.json()["subtasks"] == [] + + +def test_task_details_use_same_due_and_completion_ordering(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json() + fixtures = [ + ("子无日期", None, False), + ("子较晚", "2026-09-12T08:00:00Z", False), + ("子较早 A", "2026-09-10T08:00:00Z", False), + ("子较早 B", "2026-09-10T08:00:00Z", False), + ("子已完成", "2026-09-09T08:00:00Z", True), + ] + for title, due_at, completed in fixtures: + child = client.post( + "/api/v1/tasks", + json={"title": title, "list_id": inbox["id"], "parent_id": parent["id"], "due_at": due_at}, + ).json() + if completed: + client.patch(f"/api/v1/tasks/{child['id']}", json={"completed": True, "version": child["version"]}) + + detail = client.get(f"/api/v1/tasks/{parent['id']}").json() + assert [row["title"] for row in detail["subtasks"]] == [ + "子较早 A", "子较早 B", "子较晚", "子无日期", "子已完成" + ] + + +def test_reorder_treats_offset_equivalent_due_times_as_same_tier(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + first = client.post( + "/api/v1/tasks", + json={"title": "UTC", "list_id": inbox["id"], "due_at": "2026-09-10T08:00:00Z"}, + ).json() + second = client.post( + "/api/v1/tasks", + json={"title": "OFFSET", "list_id": inbox["id"], "due_at": "2026-09-10T16:00:00+08:00"}, + ).json() + + response = client.put("/api/v1/tasks/reorder", json={"task_ids": [second["id"], first["id"]]}) + assert response.status_code == 204 + listed = client.get("/api/v1/tasks", params={"list_id": inbox["id"], "page": 1}).json()["items"] + assert [item["title"] for item in listed] == ["OFFSET", "UTC"] + assert {item["due_at"] for item in listed} == {"2026-09-10T08:00:00Z"} + + +def test_reorder_tasks_rejects_different_sort_tiers(client): + client = initialized_client(client) + inbox = client.get("/api/v1/lists").json()[0] + no_due = client.post("/api/v1/tasks", json={"title": "无日期", "list_id": inbox["id"]}).json() + due_a = client.post( + "/api/v1/tasks", json={"title": "日期 A", "list_id": inbox["id"], "due_at": "2026-09-10T08:00:00Z"} + ).json() + due_b = client.post( + "/api/v1/tasks", json={"title": "日期 B", "list_id": inbox["id"], "due_at": "2026-09-11T08:00:00Z"} + ).json() + completed = client.patch( + f"/api/v1/tasks/{due_a['id']}", json={"completed": True, "version": due_a["version"]} + ).json() + + assert client.put("/api/v1/tasks/reorder", json={"task_ids": [no_due["id"], due_b["id"]]}).status_code == 400 + assert client.put("/api/v1/tasks/reorder", json={"task_ids": [completed["id"], due_b["id"]]}).status_code == 400 diff --git a/tests/test_mvp_backend.py b/tests/test_mvp_backend.py index c1af4cd..5a99cfd 100644 --- a/tests/test_mvp_backend.py +++ b/tests/test_mvp_backend.py @@ -1,7 +1,12 @@ -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta +from uuid import UUID from zoneinfo import ZoneInfo -from sqlalchemy import event +from sqlalchemy import event, select, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.ext.asyncio import async_sessionmaker + +from backend.models import RecurrenceException, RecurrenceTemplate, Task, UTCDateTime BUSINESS_TIME_ZONE = ZoneInfo("Asia/Shanghai") @@ -138,7 +143,7 @@ def test_completing_repeating_task_advances_due_date_instead_of_closing_it(clien assert completed.json()["completed"] is False assert completed.json()["due_at"].replace("Z", "") == "2026-09-08T09:00:00" recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() - assert recurrence["starts_at"].replace("Z", "") == "2026-09-08T09:00:00" + assert datetime.fromisoformat(recurrence["starts_at"]) == datetime(2026, 9, 8, 9, tzinfo=UTC) def test_completing_repeating_task_resets_completed_subtasks_for_next_occurrence(client): @@ -207,6 +212,95 @@ def test_recurrence_mutations_keep_exact_timestamp_validation(client): assert same_instant.status_code == 200 +def test_recurrence_chain_normalizes_absolute_instants_to_utc_on_sqlite(client): + inbox = boot(client) + created = client.post( + "/api/v1/tasks", + json={ + "title": "北京时间重复任务", + "list_id": inbox["id"], + "due_at": "2026-09-07T16:00:00+08:00", + "rrule": "FREQ=DAILY;COUNT=3", + }, + ) + assert created.status_code == 201 + task = created.json() + assert datetime.fromisoformat(task["due_at"]) == datetime(2026, 9, 7, 8, tzinfo=UTC) + + recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() + assert datetime.fromisoformat(recurrence["starts_at"]) == datetime(2026, 9, 7, 8, tzinfo=UTC) + + edited = client.patch( + f"/api/v1/recurrences/{recurrence['id']}", + params={"scope": "this", "occurrence_at": "2026-09-08T16:00:00+08:00"}, + json={"due_at": "2026-09-08T17:30:00+08:00"}, + ) + assert edited.status_code == 200 + completed = client.post( + f"/api/v1/recurrences/{recurrence['id']}/complete", + json={"occurrence_at": "2026-09-08T16:00:00+08:00"}, + ) + assert completed.status_code == 200 + + async def stored_values(): + from backend.db import get_engine + + session_factory = async_sessionmaker(get_engine(), expire_on_commit=False) + async with session_factory() as db: + template = await db.scalar( + select(RecurrenceTemplate).where(RecurrenceTemplate.id == UUID(recurrence["id"])) + ) + exception = await db.scalar( + select(RecurrenceException).where(RecurrenceException.template_id == template.id) + ) + return template.starts_at, exception.occurrence_at, exception.due_at, exception.completed + + import asyncio + + starts_at, occurrence_at, due_at, is_completed = asyncio.run(stored_values()) + assert starts_at == datetime(2026, 9, 7, 8, tzinfo=UTC) + assert occurrence_at == datetime(2026, 9, 8, 8, tzinfo=UTC) + assert due_at == datetime(2026, 9, 8, 9, 30, tzinfo=UTC) + assert is_completed is True + + +def test_utc_datetime_reads_legacy_sqlite_offset_text_as_the_same_instant(client): + inbox = boot(client) + task = client.post( + "/api/v1/tasks", + json={"title": "历史数据", "list_id": inbox["id"], "due_at": "2026-09-07T08:00:00Z"}, + ).json() + + async def inject_and_read(): + from backend.db import get_engine + + engine = get_engine() + async with engine.begin() as connection: + await connection.execute( + text("UPDATE tasks SET due_at = :value WHERE id = :task_id"), + {"value": "2026-09-07 16:00:00+08:00", "task_id": task["id"]}, + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as db: + return await db.scalar(select(Task.due_at).where(Task.id == UUID(task["id"]))) + + import asyncio + + assert asyncio.run(inject_and_read()) == datetime(2026, 9, 7, 8, tzinfo=UTC) + + +def test_recurrence_absolute_columns_use_utc_type_without_schema_change(): + for column in ( + Task.__table__.c.due_at, + RecurrenceTemplate.__table__.c.starts_at, + RecurrenceTemplate.__table__.c.ends_at, + RecurrenceException.__table__.c.occurrence_at, + RecurrenceException.__table__.c.due_at, + ): + assert isinstance(column.type, UTCDateTime) + assert column.type.compile(dialect=postgresql.dialect()) == "TIMESTAMP WITH TIME ZONE" + + def test_recurrence_rejects_occurrence_after_cutoff(client): inbox = boot(client) task = client.post( @@ -412,7 +506,8 @@ def test_tasks_support_numbered_pagination_with_total(client): def test_tasks_support_due_range_pagination(client): inbox = boot(client) - client.post("/api/v1/tasks", json={"title": "今天", "list_id": inbox["id"], "due_at": "2026-09-05T08:00:00Z"}) + client.post("/api/v1/tasks", json={"title": "今天较晚", "list_id": inbox["id"], "due_at": "2026-09-05T18:00:00Z"}) + client.post("/api/v1/tasks", json={"title": "今天较早", "list_id": inbox["id"], "due_at": "2026-09-05T08:00:00Z"}) client.post("/api/v1/tasks", json={"title": "以后", "list_id": inbox["id"], "due_at": "2026-09-08T08:00:00Z"}) client.post("/api/v1/tasks", json={"title": "无日期", "list_id": inbox["id"]}) @@ -422,8 +517,8 @@ def test_tasks_support_due_range_pagination(client): ) assert response.status_code == 200 - assert response.json()["total"] == 1 - assert [item["title"] for item in response.json()["items"]] == ["今天"] + assert response.json()["total"] == 2 + assert [item["title"] for item in response.json()["items"]] == ["今天较早", "今天较晚"] def test_trash_supports_numbered_pagination_with_total(client):