feat: sort tasks by due time
This commit is contained in:
+135
-23
@@ -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,43 +1030,40 @@ 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:
|
||||||
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 [
|
return [
|
||||||
TaskDetailOut(
|
TaskDetailOut(
|
||||||
**TaskOut.model_validate(task).model_dump(),
|
**TaskOut.model_validate(task).model_dump(),
|
||||||
@@ -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
@@ -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
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))")
|
||||||
|
|||||||
+212
-13
@@ -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),
|
||||||
).json()
|
("已完成较早", "2026-09-09T08:00:00Z", True),
|
||||||
third = client.get(
|
("已完成无日期", None, True),
|
||||||
"/api/v1/tasks", params={"limit": 2, "cursor": second["next_cursor"]}
|
]
|
||||||
).json()
|
for title, due_at, completed in fixtures:
|
||||||
ids = [row["id"] for page in (first, second, third) for row in page["items"]]
|
task = client.post(
|
||||||
assert len(ids) == len(set(ids)) == 5
|
"/api/v1/tasks", json={"title": title, "list_id": inbox["id"], "due_at": due_at}
|
||||||
assert third["next_cursor"] is None
|
).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
|
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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user