feat: show tasks completed today
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m38s

This commit is contained in:
2026-09-15 12:52:43 +08:00
parent d519843a9f
commit ebabb87780
10 changed files with 245 additions and 9 deletions
+18 -4
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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,
+5 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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()
}, },
) )
} }
+9
View File
@@ -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")
+109
View File
@@ -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]
+51
View File
@@ -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