feat: sort tasks by due time
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-10 18:16:13 +08:00
parent ab8c0a5138
commit addd49363d
8 changed files with 576 additions and 58 deletions
+134 -22
View File
@@ -2,11 +2,12 @@ import base64
import json import json
import logging import logging
import os import os
import re
import shutil import shutil
import time import time
from collections import defaultdict, deque from collections import defaultdict, deque
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path, PureWindowsPath from pathlib import Path, PureWindowsPath
from uuid import UUID, uuid4 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.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles 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.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession 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: def _task_ordering():
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode() """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("=") 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: try:
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
position, timestamp, task_id = json.loads(raw) payload = json.loads(raw)
return int(position), datetime.fromisoformat(timestamp), UUID(task_id) 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: except (ValueError, TypeError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=422, detail="无效的游标") from exc raise HTTPException(status_code=422, detail="无效的游标") from exc
@@ -922,42 +1030,39 @@ async def list_tasks(
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match) 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 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: if page is not None:
size = page_size or limit size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) 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) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor: if cursor:
position, created_at, task_id = _decode_cursor(cursor) query = query.where(_task_after_cursor(_decode_task_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),
)
)
rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all())
has_more = len(rows) > limit has_more = len(rows) > limit
items = 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) 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]: async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]:
if not tasks: if not tasks:
return [] 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( subtasks = list(
( (
await db.scalars( await db.scalars(
select(Task) select(Task)
.where(Task.parent_id.in_(task_ids), Task.deleted_at.is_(None)) .where(
.order_by(Task.position, Task.created_at, Task.id) tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes),
Task.deleted_at.is_(None),
)
.order_by(*_task_ordering())
) )
).all() ).all()
) )
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list) subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for subtask in subtasks: for subtask in subtasks:
if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes:
subtasks_by_task[subtask.parent_id].append(subtask) subtasks_by_task[subtask.parent_id].append(subtask)
return [ return [
TaskDetailOut( TaskDetailOut(
@@ -985,9 +1090,16 @@ async def reorder_tasks(
parent_scopes = {row.parent_id for row in rows} parent_scopes = {row.parent_id for row in rows}
if len(parent_scopes) != 1: if len(parent_scopes) != 1:
raise HTTPException(status_code=400, detail="只能调整同一层级任务的顺序") 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)) parent_id = next(iter(parent_scopes))
scope_query = select(Task).where( scope_query = select(Task).where(
Task.user_id == user.id, Task.user_id == user.id,
Task.list_id == next(iter(list_scopes)),
Task.deleted_at.is_(None), Task.deleted_at.is_(None),
Task.parent_id.is_(None) if parent_id is None else Task.parent_id == parent_id, 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) ).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()) 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) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor: if cursor:
created_at, task_id = _decode_cursor(cursor) created_at, task_id = _decode_trash_cursor(cursor)
query = query.where( query = query.where(
or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) 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()) rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all())
has_more = len(rows) > limit has_more = len(rows) > limit
items = 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) return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
+38 -9
View File
@@ -15,11 +15,40 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import TypeDecorator
from uuid_utils import uuid7 from uuid_utils import uuid7
from .db import Base 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: def new_id() -> UUID:
return UUID(str(uuid7())) return UUID(str(uuid7()))
@@ -104,13 +133,13 @@ class Task(Base):
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
priority: Mapped[int] = mapped_column(Integer, default=0) priority: Mapped[int] = mapped_column(Integer, default=0)
completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True) 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) due_has_time: Mapped[bool] = mapped_column(Boolean, default=False)
version: Mapped[int] = mapped_column(Integer, default=1) version: Mapped[int] = mapped_column(Integer, default=1)
position: Mapped[int] = mapped_column(Integer, default=0) position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
external_id: Mapped[str | None] = mapped_column(String(255), 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) 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) task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), unique=True)
rrule: Mapped[str] = mapped_column(Text) rrule: Mapped[str] = mapped_column(Text)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) starts_at: Mapped[datetime] = mapped_column(UTCDateTime())
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
class RecurrenceException(Base): class RecurrenceException(Base):
@@ -130,9 +159,9 @@ class RecurrenceException(Base):
__table_args__ = (UniqueConstraint("template_id", "occurrence_at", name="uq_recurrence_exception"),) __table_args__ = (UniqueConstraint("template_id", "occurrence_at", name="uq_recurrence_exception"),)
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) 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) 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) 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) completed: Mapped[bool] = mapped_column(Boolean, default=False)
deleted: Mapped[bool] = mapped_column(Boolean, default=False) deleted: Mapped[bool] = mapped_column(Boolean, default=False)
+35 -6
View File
@@ -5,7 +5,7 @@ import {
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2, Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next' } 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 { 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 { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion' import { createCompletionPulse } from './lib/completion-motion'
@@ -100,6 +100,7 @@ const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
const taskSwipeOffsets = ref<Record<string, number>>({}) const taskSwipeOffsets = ref<Record<string, number>>({})
const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null) const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
const taskReorderTarget = ref('') const taskReorderTarget = ref('')
const taskReorderBlocked = ref(false)
const taskComposeOpen = ref(false) const taskComposeOpen = ref(false)
const composeTitle = ref('') const composeTitle = ref('')
const composeTitleError = 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 if (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 } taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
taskReorderTarget.value = task.id taskReorderTarget.value = task.id
taskReorderBlocked.value = false
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ } try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
} }
function taskById(id: string) { function taskById(id: string) {
@@ -636,16 +638,33 @@ function moveTaskReorder(task: Task, event: PointerEvent) {
const row = document.elementsFromPoint(event.clientX, event.clientY) const row = document.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-task-id]')) .map((element) => element.closest<HTMLElement>('[data-task-id]'))
.find((element) => element && element !== handle.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) { async function finishTaskReorder(task: Task, event: PointerEvent) {
const drag = taskReorder.value const drag = taskReorder.value
const targetId = taskReorderTarget.value const targetId = taskReorderTarget.value
const blocked = taskReorderBlocked.value
taskReorder.value = null taskReorder.value = null
taskReorderTarget.value = '' taskReorderTarget.value = ''
taskReorderBlocked.value = false
if (blocked) {
toast('只能调整相同完成状态和截止时间档的任务顺序')
return
}
if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return
const target = taskById(targetId) 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 placement = event.clientY >= drag.startY ? 'after' : 'before'
const previous = tasks.value const previous = tasks.value
const previousSelected = selectedTask.value const previousSelected = selectedTask.value
@@ -657,12 +676,12 @@ async function finishTaskReorder(task: Task, event: PointerEvent) {
if (reordered === parent.subtasks) return if (reordered === parent.subtasks) return
tasks.value = tasks.value.map((item) => item.id === parent.id ? { ...item, subtasks: reordered } : item) 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 } 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 { } else {
const next = moveItemWithinScope(previous, task.id, targetId, placement) const next = moveItemWithinScope(previous, task.id, targetId, placement)
if (next === previous) return if (next === previous) return
tasks.value = next tasks.value = next
ids = next.map((item) => item.id) ids = next.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id)
} }
try { try {
await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }) 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() { function cancelTaskReorder() {
taskReorder.value = null taskReorder.value = null
taskReorderTarget.value = '' taskReorderTarget.value = ''
taskReorderBlocked.value = false
} }
function startTaskSwipe(task: Task, event: TouchEvent) { function startTaskSwipe(task: Task, event: TouchEvent) {
@@ -771,6 +791,15 @@ function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
if (toggleChildren) toggleTaskChildren(task) if (toggleChildren) toggleTaskChildren(task)
selectTask(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() { async function saveTask() {
if (!selectedTask.value) return if (!selectedTask.value) return
const normalized = normalizeRequiredName(selectedTask.value.title) const normalized = normalizeRequiredName(selectedTask.value.title)
@@ -793,7 +822,7 @@ async function saveTask() {
} }
} }
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated } if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
if (activeView.value === 'today') void loadTodayTaskSummary() await refreshTodayAfterTaskSave()
toast('已保存') toast('已保存')
} catch (reason) { fail(reason) } } catch (reason) { fail(reason) }
} }
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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 = { type SearchTask = {
id: string id: string
@@ -33,6 +33,25 @@ describe('task utilities', () => {
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }]) 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', () => { it('moves an item before or after another item without changing other scopes', () => {
const rows = [ const rows = [
{ id: 'a', parent_id: null }, { id: 'a', parent_id: null },
+25
View File
@@ -4,6 +4,7 @@ export type MinimalTask = {
description?: string description?: string
parent_id?: string | null parent_id?: string | null
completed?: boolean completed?: boolean
due_at?: string | null
list_name?: string list_name?: string
subtasks?: MinimalTask[] subtasks?: MinimalTask[]
} }
@@ -92,6 +93,30 @@ export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
})) }))
} }
export function classifyTaskForToday(
task: Pick<MinimalTask, 'completed' | 'due_at'>,
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<MinimalTask, 'completed' | 'due_at'>,
target: Pick<MinimalTask, 'completed' | 'due_at'>,
) {
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<T extends { id: string; parent_id?: string | null }>(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') { export function moveItemWithinScope<T extends { id: string; parent_id?: string | null }>(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') {
if (sourceId === targetId) return items if (sourceId === targetId) return items
const source = items.find((item) => item.id === sourceId) const source = items.find((item) => item.id === sourceId)
+10
View File
@@ -629,6 +629,16 @@ describe('task and habit row decoration', () => {
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'") 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', () => { it('shows unfinished overdue tasks as a separate list inside Today', () => {
expect(app).toContain('const overdueTasks = ref<Task[]>([])') expect(app).toContain('const overdueTasks = ref<Task[]>([])')
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))") expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
+211 -12
View File
@@ -1,6 +1,10 @@
import base64
import json
import os import os
from uuid import UUID
from fastapi.testclient import TestClient 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_DATABASE_URL", "sqlite+aiosqlite:////tmp/dodo-health-test.db")
os.environ.setdefault("DODO_AUTO_CREATE_SCHEMA", "true") 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"] 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) client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0] inbox = client.get("/api/v1/lists").json()[0]
parent = client.post("/api/v1/tasks", json={"title": "父任务", "list_id": inbox["id"]}).json() 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"]]}) response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], child["id"]]})
assert response.status_code == 400 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): def test_task_update_rejects_null_title(client):
client.post( client.post(
@@ -395,6 +404,28 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
assert "tags" not in listed[0] 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): def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
client = initialized_client(client) client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0] 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): def test_cursor_pagination_is_stable_and_rejects_bad_cursor(client):
client = initialized_client(client) client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0] inbox = client.get("/api/v1/lists").json()[0]
for i in range(5): fixtures = [
client.post("/api/v1/tasks", json={"title": f"任务{i}", "list_id": inbox["id"]}) ("未完成较晚", "2026-09-12T08:00:00Z", False),
first = client.get("/api/v1/tasks", params={"limit": 2}).json() ("未完成较早 A", "2026-09-10T08:00:00Z", False),
second = client.get( ("未完成较早 B", "2026-09-10T08:00:00Z", False),
"/api/v1/tasks", params={"limit": 2, "cursor": first["next_cursor"]} ("未完成无日期", 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() ).json()
third = client.get( if completed:
"/api/v1/tasks", params={"limit": 2, "cursor": second["next_cursor"]} response = client.patch(
).json() f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
ids = [row["id"] for page in (first, second, third) for row in page["items"]] )
assert len(ids) == len(set(ids)) == 5 assert response.status_code == 200
assert third["next_cursor"] is None
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 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
+101 -6
View File
@@ -1,7 +1,12 @@
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID
from zoneinfo import ZoneInfo 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") 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()["completed"] is False
assert completed.json()["due_at"].replace("Z", "") == "2026-09-08T09:00:00" assert completed.json()["due_at"].replace("Z", "") == "2026-09-08T09:00:00"
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() 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): 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 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): def test_recurrence_rejects_occurrence_after_cutoff(client):
inbox = boot(client) inbox = boot(client)
task = client.post( task = client.post(
@@ -412,7 +506,8 @@ def test_tasks_support_numbered_pagination_with_total(client):
def test_tasks_support_due_range_pagination(client): def test_tasks_support_due_range_pagination(client):
inbox = boot(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"], "due_at": "2026-09-08T08:00:00Z"})
client.post("/api/v1/tasks", json={"title": "无日期", "list_id": inbox["id"]}) 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.status_code == 200
assert response.json()["total"] == 1 assert response.json()["total"] == 2
assert [item["title"] for item in response.json()["items"]] == ["今天"] assert [item["title"] for item in response.json()["items"]] == ["今天较早", "今天较晚"]
def test_trash_supports_numbered_pagination_with_total(client): def test_trash_supports_numbered_pagination_with_total(client):