feat: show tasks completed today
This commit is contained in:
+18
-4
@@ -1024,6 +1024,8 @@ async def list_tasks(
|
|||||||
completed: bool | None = None,
|
completed: bool | None = None,
|
||||||
due_from: datetime | None = None,
|
due_from: datetime | None = None,
|
||||||
due_to: datetime | None = None,
|
due_to: datetime | None = None,
|
||||||
|
completed_from: datetime | None = None,
|
||||||
|
completed_to: datetime | None = None,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
page: int | None = Query(default=None, ge=1),
|
page: int | None = Query(default=None, ge=1),
|
||||||
page_size: int | None = Query(default=None, ge=1, le=100),
|
page_size: int | None = Query(default=None, ge=1, le=100),
|
||||||
@@ -1048,10 +1050,20 @@ async def list_tasks(
|
|||||||
query = query.where(Task.list_id == list_id)
|
query = query.where(Task.list_id == list_id)
|
||||||
if completed is not None:
|
if completed is not None:
|
||||||
query = query.where(Task.completed.is_(completed))
|
query = query.where(Task.completed.is_(completed))
|
||||||
if due_from is not None:
|
if due_from is not None and due_to is not None and completed_from is not None and completed_to is not None:
|
||||||
query = query.where(Task.due_at >= due_from)
|
query = query.where(or_(
|
||||||
if due_to is not None:
|
and_(Task.due_at >= due_from, Task.due_at < due_to),
|
||||||
query = query.where(Task.due_at < due_to)
|
and_(Task.completed_at >= completed_from, Task.completed_at < completed_to),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
if due_from is not None:
|
||||||
|
query = query.where(Task.due_at >= due_from)
|
||||||
|
if due_to is not None:
|
||||||
|
query = query.where(Task.due_at < due_to)
|
||||||
|
if completed_from is not None:
|
||||||
|
query = query.where(Task.completed_at >= completed_from)
|
||||||
|
if completed_to is not None:
|
||||||
|
query = query.where(Task.completed_at < completed_to)
|
||||||
if q:
|
if q:
|
||||||
pattern = f"%{q}%"
|
pattern = f"%{q}%"
|
||||||
list_match = exists(
|
list_match = exists(
|
||||||
@@ -1355,6 +1367,8 @@ async def batch_update_tasks(
|
|||||||
if payload.soft_delete:
|
if payload.soft_delete:
|
||||||
changes["deleted_at"] = utcnow()
|
changes["deleted_at"] = utcnow()
|
||||||
if changes:
|
if changes:
|
||||||
|
if changes.get("completed") is False:
|
||||||
|
changes["completed_at"] = None
|
||||||
changes["version"] = Task.version + 1
|
changes["version"] = Task.version + 1
|
||||||
changes["updated_at"] = utcnow()
|
changes["updated_at"] = utcnow()
|
||||||
target_ids = set(task_ids)
|
target_ids = set(task_ids)
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ 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)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True, index=True)
|
||||||
due_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), 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)
|
||||||
|
|||||||
+2
-1
@@ -1301,7 +1301,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
|
|||||||
"exported_at": utcnow().isoformat(),
|
"exported_at": utcnow().isoformat(),
|
||||||
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
||||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
||||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
||||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
||||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||||
@@ -1487,6 +1487,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
description=raw.get("description", ""),
|
description=raw.get("description", ""),
|
||||||
priority=raw.get("priority", 0),
|
priority=raw.get("priority", 0),
|
||||||
completed=raw.get("completed", False),
|
completed=raw.get("completed", False),
|
||||||
|
completed_at=datetime.fromisoformat(raw["completed_at"]) if raw.get("completed_at") else None,
|
||||||
due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None,
|
due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None,
|
||||||
due_has_time=raw.get("due_has_time", True),
|
due_has_time=raw.get("due_has_time", True),
|
||||||
external_id=ext,
|
external_id=ext,
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ async def apply_task_changes(
|
|||||||
recurrence.starts_at = changes["due_at"]
|
recurrence.starts_at = changes["due_at"]
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
|
if changes.get("completed") is True and not task.completed:
|
||||||
|
changes["completed_at"] = now
|
||||||
|
elif changes.get("completed") is False:
|
||||||
|
changes["completed_at"] = None
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
update(Task)
|
update(Task)
|
||||||
.where(
|
.where(
|
||||||
@@ -120,6 +124,6 @@ async def apply_task_changes(
|
|||||||
Task.deleted_at.is_(None),
|
Task.deleted_at.is_(None),
|
||||||
Task.completed.is_(True),
|
Task.completed.is_(True),
|
||||||
)
|
)
|
||||||
.values(completed=False, version=Task.version + 1, updated_at=now)
|
.values(completed=False, completed_at=None, version=Task.version + 1, updated_at=now)
|
||||||
)
|
)
|
||||||
return updated_task, changed
|
return updated_task, changed
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ class TaskOut(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
priority: int
|
priority: int
|
||||||
completed: bool
|
completed: bool
|
||||||
|
completed_at: datetime | None
|
||||||
due_at: datetime | None
|
due_at: datetime | None
|
||||||
due_has_time: bool
|
due_has_time: bool
|
||||||
version: int
|
version: int
|
||||||
|
|||||||
+23
-3
@@ -26,7 +26,7 @@ import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionC
|
|||||||
|
|
||||||
type FolderItem = { id: string; name: string }
|
type FolderItem = { id: string; name: string }
|
||||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||||||
type RepeatOption = TaskRepeatOption
|
type RepeatOption = TaskRepeatOption
|
||||||
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
|
||||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
|
||||||
@@ -365,7 +365,11 @@ const visibleTasks = computed(() => {
|
|||||||
const end = new Date(now); end.setDate(end.getDate() + 7)
|
const end = new Date(now); end.setDate(end.getDate() + 7)
|
||||||
let result = sourceTasks.value
|
let result = sourceTasks.value
|
||||||
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
|
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
|
||||||
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
if (activeView.value === 'today') result = result.filter((task) => {
|
||||||
|
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString()
|
||||||
|
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
|
||||||
|
return Boolean(dueToday || completedToday)
|
||||||
|
})
|
||||||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||||||
return query.value.trim() ? filterTasks(result, query.value) : result
|
return query.value.trim() ? filterTasks(result, query.value) : result
|
||||||
})
|
})
|
||||||
@@ -505,6 +509,10 @@ async function loadTodayTaskSummary() {
|
|||||||
params.set('due_from', isoAtLocalDayOffset(0))
|
params.set('due_from', isoAtLocalDayOffset(0))
|
||||||
params.set('due_to', isoAtLocalDayOffset(1))
|
params.set('due_to', isoAtLocalDayOffset(1))
|
||||||
params.set('completed', String(completed))
|
params.set('completed', String(completed))
|
||||||
|
if (completed) {
|
||||||
|
params.set('completed_from', isoAtLocalDayOffset(0))
|
||||||
|
params.set('completed_to', isoAtLocalDayOffset(1))
|
||||||
|
}
|
||||||
const data = await api(`/tasks?${params}`)
|
const data = await api(`/tasks?${params}`)
|
||||||
return Number(data.total ?? data.items?.length ?? 0)
|
return Number(data.total ?? data.items?.length ?? 0)
|
||||||
}
|
}
|
||||||
@@ -532,7 +540,14 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
|
|||||||
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
|
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
|
||||||
if (query.value) params.set('q', query.value)
|
if (query.value) params.set('q', query.value)
|
||||||
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
|
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
|
||||||
if (activeView.value === 'today') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(1)) }
|
if (activeView.value === 'today') {
|
||||||
|
params.set('due_from', isoAtLocalDayOffset(0))
|
||||||
|
params.set('due_to', isoAtLocalDayOffset(1))
|
||||||
|
if (showCompleted.value) {
|
||||||
|
params.set('completed_from', isoAtLocalDayOffset(0))
|
||||||
|
params.set('completed_to', isoAtLocalDayOffset(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
if (activeView.value === 'upcoming') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(8)) }
|
if (activeView.value === 'upcoming') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(8)) }
|
||||||
if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false')
|
if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false')
|
||||||
const data = await api(`/tasks?${params}`)
|
const data = await api(`/tasks?${params}`)
|
||||||
@@ -545,6 +560,10 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
|
|||||||
completedParams.set('page', '1')
|
completedParams.set('page', '1')
|
||||||
completedParams.set('page_size', '1')
|
completedParams.set('page_size', '1')
|
||||||
completedParams.set('completed', 'true')
|
completedParams.set('completed', 'true')
|
||||||
|
if (activeView.value === 'today') {
|
||||||
|
completedParams.set('completed_from', isoAtLocalDayOffset(0))
|
||||||
|
completedParams.set('completed_to', isoAtLocalDayOffset(1))
|
||||||
|
}
|
||||||
const completedData = await api(`/tasks?${completedParams}`)
|
const completedData = await api(`/tasks?${completedParams}`)
|
||||||
if (!isLatestRequest('tasks', request)) return
|
if (!isLatestRequest('tasks', request)) return
|
||||||
hiddenCompletedTaskCount.value = Number(completedData.total ?? completedData.items?.length ?? 0)
|
hiddenCompletedTaskCount.value = Number(completedData.total ?? completedData.items?.length ?? 0)
|
||||||
@@ -697,6 +716,7 @@ async function toggle(task: Task) {
|
|||||||
await waitForCompletionExit()
|
await waitForCompletionExit()
|
||||||
setTaskCompletionExiting(task.id, false)
|
setTaskCompletionExiting(task.id, false)
|
||||||
}
|
}
|
||||||
|
if (activeView.value === 'today' && showCompleted.value) await loadAll()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -774,6 +774,15 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
|
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('includes tasks actually completed today in Today even when they were overdue', () => {
|
||||||
|
expect(app).toContain('completed_at: string | null')
|
||||||
|
expect(app).toContain("const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()")
|
||||||
|
expect(app).toContain('return Boolean(dueToday || completedToday)')
|
||||||
|
expect(app).toContain("params.set('completed_from', isoAtLocalDayOffset(0))")
|
||||||
|
expect(app).toContain("params.set('completed_to', isoAtLocalDayOffset(1))")
|
||||||
|
expect(app).toContain("if (activeView.value === 'today' && showCompleted.value) await loadAll()")
|
||||||
|
})
|
||||||
|
|
||||||
it('reclassifies Today tasks after due edits without toggling page loading', () => {
|
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 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('))
|
const refreshBlock = app.slice(app.indexOf('async function refreshTodayAfterTaskSave()'), app.indexOf('async function saveTask('))
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add task completed_at timestamp
|
||||||
|
|
||||||
|
Revision ID: 0018_task_completed_at
|
||||||
|
Revises: 0017_memos
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0018_task_completed_at"
|
||||||
|
down_revision = "0017_memos"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("tasks") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
batch_op.create_index("ix_tasks_completed_at", ["completed_at"])
|
||||||
|
op.execute("UPDATE tasks SET completed_at = updated_at WHERE completed IS TRUE AND completed_at IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("tasks") as batch_op:
|
||||||
|
batch_op.drop_index("ix_tasks_completed_at")
|
||||||
|
batch_op.drop_column("completed_at")
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -83,8 +84,17 @@ def test_task_can_be_updated_completed_and_soft_deleted(client):
|
|||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
assert updated.json()["title"] == "新标题"
|
assert updated.json()["title"] == "新标题"
|
||||||
assert updated.json()["completed"] is True
|
assert updated.json()["completed"] is True
|
||||||
|
assert updated.json()["completed_at"] is not None
|
||||||
assert updated.json()["version"] == 2
|
assert updated.json()["version"] == 2
|
||||||
|
|
||||||
|
reopened = client.patch(
|
||||||
|
f"/api/v1/tasks/{task['id']}",
|
||||||
|
json={"completed": False, "version": updated.json()["version"]},
|
||||||
|
)
|
||||||
|
assert reopened.status_code == 200
|
||||||
|
assert reopened.json()["completed"] is False
|
||||||
|
assert reopened.json()["completed_at"] is None
|
||||||
|
|
||||||
conflict = client.patch(
|
conflict = client.patch(
|
||||||
f"/api/v1/tasks/{task['id']}",
|
f"/api/v1/tasks/{task['id']}",
|
||||||
json={"title": "冲突标题", "version": task["version"]},
|
json={"title": "冲突标题", "version": task["version"]},
|
||||||
@@ -119,6 +129,105 @@ 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_today_list_includes_tasks_completed_today_even_if_due_earlier(client, monkeypatch):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
overdue = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "昨天截止今天完成", "list_id": inbox["id"], "due_at": "2026-09-14T02:00:00Z"},
|
||||||
|
).json()
|
||||||
|
today_due = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "今天截止今天完成", "list_id": inbox["id"], "due_at": "2026-09-15T02:00:00Z"},
|
||||||
|
).json()
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "今天未完成", "list_id": inbox["id"], "due_at": "2026-09-15T10:00:00Z"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
completed_at = datetime(2026, 9, 15, 3, 30, tzinfo=UTC)
|
||||||
|
monkeypatch.setattr("backend.recurrence_service.utcnow", lambda: completed_at)
|
||||||
|
monkeypatch.setattr("backend.main.utcnow", lambda: completed_at)
|
||||||
|
|
||||||
|
completed_overdue = client.patch(
|
||||||
|
f"/api/v1/tasks/{overdue['id']}",
|
||||||
|
json={"completed": True, "version": overdue["version"]},
|
||||||
|
)
|
||||||
|
assert completed_overdue.status_code == 200
|
||||||
|
assert datetime.fromisoformat(completed_overdue.json()["completed_at"]) == completed_at
|
||||||
|
|
||||||
|
completed_today = client.patch(
|
||||||
|
f"/api/v1/tasks/{today_due['id']}",
|
||||||
|
json={"completed": True, "version": today_due["version"]},
|
||||||
|
)
|
||||||
|
assert completed_today.status_code == 200
|
||||||
|
assert datetime.fromisoformat(completed_today.json()["completed_at"]) == completed_at
|
||||||
|
|
||||||
|
today = client.get(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
params={
|
||||||
|
"due_from": "2026-09-15T00:00:00Z",
|
||||||
|
"due_to": "2026-09-16T00:00:00Z",
|
||||||
|
"completed_from": "2026-09-15T00:00:00Z",
|
||||||
|
"completed_to": "2026-09-16T00:00:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert today.status_code == 200
|
||||||
|
assert {item["title"] for item in today.json()["items"]} == {"昨天截止今天完成", "今天截止今天完成", "今天未完成"}
|
||||||
|
|
||||||
|
hidden_completed = client.get(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
params={
|
||||||
|
"due_from": "2026-09-15T00:00:00Z",
|
||||||
|
"due_to": "2026-09-16T00:00:00Z",
|
||||||
|
"completed": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert hidden_completed.status_code == 200
|
||||||
|
assert [item["title"] for item in hidden_completed.json()["items"]] == ["今天未完成"]
|
||||||
|
|
||||||
|
shown_completed = client.get(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
params={
|
||||||
|
"due_from": "2026-09-15T00:00:00Z",
|
||||||
|
"due_to": "2026-09-16T00:00:00Z",
|
||||||
|
"completed": True,
|
||||||
|
"completed_from": "2026-09-15T00:00:00Z",
|
||||||
|
"completed_to": "2026-09-16T00:00:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert shown_completed.status_code == 200
|
||||||
|
assert {item["title"] for item in shown_completed.json()["items"]} == {"昨天截止今天完成", "今天截止今天完成"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_complete_records_completed_at(client, monkeypatch):
|
||||||
|
client = initialized_client(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
first = client.post("/api/v1/tasks", json={"title": "批量1", "list_id": inbox["id"]}).json()
|
||||||
|
second = client.post("/api/v1/tasks", json={"title": "批量2", "list_id": inbox["id"]}).json()
|
||||||
|
completed_at = datetime(2026, 9, 15, 6, 0, tzinfo=UTC)
|
||||||
|
monkeypatch.setattr("backend.recurrence_service.utcnow", lambda: completed_at)
|
||||||
|
monkeypatch.setattr("backend.main.utcnow", lambda: completed_at)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/tasks/batch",
|
||||||
|
json={
|
||||||
|
"task_ids": [first["id"], second["id"]],
|
||||||
|
"completed": True,
|
||||||
|
"versions": {first["id"]: first["version"], second["id"]: second["version"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
listed = client.get("/api/v1/tasks", params={"completed": True}).json()["items"]
|
||||||
|
completed_map = {
|
||||||
|
item["title"]: datetime.fromisoformat(item["completed_at"])
|
||||||
|
for item in listed
|
||||||
|
if item["title"] in {"批量1", "批量2"}
|
||||||
|
}
|
||||||
|
assert completed_map == {"批量1": completed_at, "批量2": completed_at}
|
||||||
|
|
||||||
|
|
||||||
def test_reorder_tasks_rejects_mixed_parent_scopes_and_lists(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]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run_alembic(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}"
|
||||||
|
return subprocess.run(
|
||||||
|
["uv", "run", "alembic", *args], cwd=repo, env=env, text=True, capture_output=True, check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_completed_at_migration_upgrade_and_downgrade(tmp_path: Path):
|
||||||
|
repo = Path(__file__).resolve().parents[1]
|
||||||
|
database = tmp_path / "migration.sqlite3"
|
||||||
|
assert run_alembic(repo, database, "upgrade", "0017_memos").returncode == 0
|
||||||
|
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, timezone, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
("00000000-0000-0000-0000-000000000001", "owner", "hash", "Asia/Shanghai", "2026-09-15 00:00:00"),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO task_lists (id, user_id, folder_id, name, is_inbox, position, created_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001", None, "收集箱", 1, 0, "2026-09-15 00:00:00", None),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO tasks (id, user_id, list_id, parent_id, title, description, priority, completed, due_at, due_has_time, version, position, created_at, updated_at, deleted_at, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("00000000-0000-0000-0000-000000000003", "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", None, "已完成任务", "", 0, 1, None, 0, 1, 0, "2026-09-14 00:00:00", "2026-09-15 06:00:00", None, None),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
upgraded = run_alembic(repo, database, "upgrade", "0018_task_completed_at")
|
||||||
|
assert upgraded.returncode == 0, upgraded.stderr
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
columns = {row[1]: row for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||||
|
assert "completed_at" in columns
|
||||||
|
indexes = {row[1] for row in connection.execute("PRAGMA index_list(tasks)")}
|
||||||
|
assert "ix_tasks_completed_at" in indexes
|
||||||
|
completed_at = connection.execute("SELECT completed_at FROM tasks WHERE id = ?", ("00000000-0000-0000-0000-000000000003",)).fetchone()
|
||||||
|
assert completed_at == ("2026-09-15 06:00:00",)
|
||||||
|
sqlite_boolean = connection.execute("SELECT completed IS TRUE FROM tasks WHERE id = ?", ("00000000-0000-0000-0000-000000000003",)).fetchone()
|
||||||
|
assert sqlite_boolean == (1,)
|
||||||
|
|
||||||
|
downgraded = run_alembic(repo, database, "downgrade", "0017_memos")
|
||||||
|
assert downgraded.returncode == 0, downgraded.stderr
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
columns = {row[1]: row for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||||
|
assert "completed_at" not in columns
|
||||||
Reference in New Issue
Block a user