From ebabb877809d33f1e63cebc6629cc38003b1b819 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Tue, 15 Sep 2026 12:52:43 +0800 Subject: [PATCH] feat: show tasks completed today --- backend/main.py | 22 +++- backend/models.py | 1 + backend/mvp.py | 3 +- backend/recurrence_service.py | 6 +- backend/schemas.py | 1 + frontend/src/App.vue | 26 ++++- frontend/src/style.test.ts | 9 ++ migrations/versions/0018_task_completed_at.py | 26 +++++ tests/test_app.py | 109 ++++++++++++++++++ tests/test_migration_task_completed_at.py | 51 ++++++++ 10 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 migrations/versions/0018_task_completed_at.py create mode 100644 tests/test_migration_task_completed_at.py diff --git a/backend/main.py b/backend/main.py index 3dcf81b..a502c92 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1024,6 +1024,8 @@ async def list_tasks( completed: bool | None = None, due_from: datetime | None = None, due_to: datetime | None = None, + completed_from: datetime | None = None, + completed_to: datetime | None = None, cursor: str | None = None, page: int | None = Query(default=None, ge=1), 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) if completed is not None: query = query.where(Task.completed.is_(completed)) - 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 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(or_( + and_(Task.due_at >= due_from, 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: pattern = f"%{q}%" list_match = exists( @@ -1355,6 +1367,8 @@ async def batch_update_tasks( if payload.soft_delete: changes["deleted_at"] = utcnow() if changes: + if changes.get("completed") is False: + changes["completed_at"] = None changes["version"] = Task.version + 1 changes["updated_at"] = utcnow() target_ids = set(task_ids) diff --git a/backend/models.py b/backend/models.py index 94af965..43ee71f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -133,6 +133,7 @@ class Task(Base): description: Mapped[str] = mapped_column(Text, default="") priority: Mapped[int] = mapped_column(Integer, default=0) completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True, index=True) due_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) due_has_time: Mapped[bool] = mapped_column(Boolean, default=False) version: Mapped[int] = mapped_column(Integer, default=1) diff --git a/backend/mvp.py b/backend/mvp.py index b912a3d..a2f5642 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -1301,7 +1301,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo "exported_at": utcnow().isoformat(), "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], - "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], "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], @@ -1487,6 +1487,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg description=raw.get("description", ""), priority=raw.get("priority", 0), 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_has_time=raw.get("due_has_time", True), external_id=ext, diff --git a/backend/recurrence_service.py b/backend/recurrence_service.py index 97b312a..d806e0d 100644 --- a/backend/recurrence_service.py +++ b/backend/recurrence_service.py @@ -96,6 +96,10 @@ async def apply_task_changes( recurrence.starts_at = changes["due_at"] 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( update(Task) .where( @@ -120,6 +124,6 @@ async def apply_task_changes( Task.deleted_at.is_(None), 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 diff --git a/backend/schemas.py b/backend/schemas.py index d7c2458..e253b84 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -198,6 +198,7 @@ class TaskOut(BaseModel): description: str priority: int completed: bool + completed_at: datetime | None due_at: datetime | None due_has_time: bool version: int diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5545d2a..52b9039 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -26,7 +26,7 @@ import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionC type FolderItem = { id: string; name: string } 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 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' @@ -365,7 +365,11 @@ const visibleTasks = computed(() => { const end = new Date(now); end.setDate(end.getDate() + 7) let result = sourceTasks.value 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) 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_to', isoAtLocalDayOffset(1)) 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}`) 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) }) if (query.value) params.set('q', query.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 (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false') const data = await api(`/tasks?${params}`) @@ -545,6 +560,10 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) { completedParams.set('page', '1') completedParams.set('page_size', '1') 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}`) if (!isLatestRequest('tasks', request)) return hiddenCompletedTaskCount.value = Number(completedData.total ?? completedData.items?.length ?? 0) @@ -697,6 +716,7 @@ async function toggle(task: Task) { await waitForCompletionExit() setTaskCompletionExiting(task.id, false) } + if (activeView.value === 'today' && showCompleted.value) await loadAll() }, ) } diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index f7685f6..f5d18f7 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -774,6 +774,15 @@ describe('task and habit row decoration', () => { 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', () => { 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(')) diff --git a/migrations/versions/0018_task_completed_at.py b/migrations/versions/0018_task_completed_at.py new file mode 100644 index 0000000..88f6d14 --- /dev/null +++ b/migrations/versions/0018_task_completed_at.py @@ -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") diff --git a/tests/test_app.py b/tests/test_app.py index 5e4871e..12d38f4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,7 @@ import base64 import json import os +from datetime import UTC, datetime from uuid import UUID 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.json()["title"] == "新标题" assert updated.json()["completed"] is True + assert updated.json()["completed_at"] is not None 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( f"/api/v1/tasks/{task['id']}", 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"] +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): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] diff --git a/tests/test_migration_task_completed_at.py b/tests/test_migration_task_completed_at.py new file mode 100644 index 0000000..b352f77 --- /dev/null +++ b/tests/test_migration_task_completed_at.py @@ -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