From 740eae8a599171ca0f4336d582366f94794799d6 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Mon, 21 Sep 2026 09:00:07 +0800 Subject: [PATCH] [verified] redesign trash with deadline groups --- backend/main.py | 38 ++++++----- frontend/e2e/trash-grouped-layout.spec.ts | 72 ++++++++++++++++++++ frontend/e2e/ui-reduction-acceptance.spec.ts | 3 + frontend/src/App.vue | 53 +++++++++----- frontend/src/TodayEnvironment.test.ts | 2 +- frontend/src/lib/task-utils.test.ts | 27 +++++++- frontend/src/lib/task-utils.ts | 21 ++++++ frontend/src/style.css | 12 +++- frontend/src/style.test.ts | 27 ++++---- frontend/src/visual-polish.test.ts | 14 ++-- tests/test_app.py | 20 ++++++ 11 files changed, 234 insertions(+), 55 deletions(-) create mode 100644 frontend/e2e/trash-grouped-layout.spec.ts diff --git a/backend/main.py b/backend/main.py index 98d7102..2b64453 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1091,22 +1091,22 @@ async def list_tasks( 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], + *, + include_deleted_subtasks: bool = False, +) -> list[TaskDetailOut]: if not tasks: return [] allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks} - subtasks = list( - ( - await db.scalars( - select(Task) - .where( - tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes), - Task.deleted_at.is_(None), - ) - .order_by(*_task_ordering()) - ) - ).all() + subtask_query = select(Task).where( + tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes) ) + subtask_query = subtask_query.where( + Task.deleted_at.is_not(None) if include_deleted_subtasks else Task.deleted_at.is_(None) + ) + subtasks = list((await db.scalars(subtask_query.order_by(*_task_ordering()))).all()) subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list) for subtask in subtasks: if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes: @@ -1257,11 +1257,17 @@ async def list_trash( Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None) ) total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 - ordering = (Task.created_at, Task.id) + grouping_rank = case( + (Task.due_at < utcnow(), 0), + (Task.due_at.is_not(None), 1), + else_=2, + ) if page is not None: size = page_size or limit - 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) + page_ordering = (grouping_rank, Task.due_at, Task.created_at, Task.id) + items = list((await db.scalars(query.order_by(*page_ordering).offset((page - 1) * size).limit(size))).all()) + return TaskPage(items=await _task_details(db, items, include_deleted_subtasks=True), total=total, page=page, page_size=size) + ordering = (Task.created_at, Task.id) if cursor: created_at, task_id = _decode_trash_cursor(cursor) query = query.where( @@ -1271,7 +1277,7 @@ async def list_trash( has_more = len(rows) > limit items = rows[:limit] 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, include_deleted_subtasks=True), next_cursor=next_cursor, total=total, page=1, page_size=limit) @app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut) diff --git a/frontend/e2e/trash-grouped-layout.spec.ts b/frontend/e2e/trash-grouped-layout.spec.ts new file mode 100644 index 0000000..edf10fe --- /dev/null +++ b/frontend/e2e/trash-grouped-layout.spec.ts @@ -0,0 +1,72 @@ +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test } from './fixtures' + +async function csrf(request: APIRequestContext) { + const state = await request.storageState() + return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? '' +} + +async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters[1]) { + return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) +} + +async function openTrash(page: Page) { + if ((await page.viewportSize())!.width <= 930) { + await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() + } + await page.locator('.sidebar').getByRole('button', { name: '回收站', exact: true }).click() +} + +test('Trash uses compact deadline groups without exposing child-level actions', async ({ page, request, baseURL }) => { + const suffix = `${test.info().project.name}-${Date.now()}` + const bootstrap = await request.get('/api/v1/bootstrap') + const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) + expect(inbox).toBeTruthy() + + const createDeleted = async (title: string, dueAt?: string) => { + const created = await mutate(request, baseURL!, '/api/v1/tasks', { + method: 'POST', data: { title, list_id: inbox.id, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) }, + }) + expect(created.ok(), await created.text()).toBeTruthy() + const task = await created.json() as { id: string } + expect((await mutate(request, baseURL!, `/api/v1/tasks/${task.id}`, { method: 'DELETE' })).ok()).toBeTruthy() + return task + } + + const overdueTitle = `回收站过期-${suffix}` + const futureTitle = `回收站未来-${suffix}` + const undatedTitle = `回收站无日期-${suffix}` + await createDeleted(overdueTitle, '2026-01-02T15:59:00.000Z') + await createDeleted(futureTitle, '2099-12-30T15:59:00.000Z') + await createDeleted(undatedTitle) + + await page.goto('/') + await openTrash(page) + + await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。') + for (const group of ['已过期', '未来截止', '无截止日期']) { + await expect(page.getByRole('heading', { name: group, exact: true })).toBeVisible() + } + for (const title of [overdueTitle, futureTitle, undatedTitle]) { + const row = page.locator('.task-row--trash').filter({ hasText: title }) + await expect(row).toHaveCount(1) + await expect(row.getByRole('button', { name: '恢复' })).toBeVisible() + await expect(row.getByRole('button', { name: '永久删除' })).toBeVisible() + expect(await row.locator('.task-check').count()).toBe(0) + } + await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除') + + const geometry = await page.evaluate(() => ({ + viewport: innerWidth, + document: document.documentElement.scrollWidth, + controls: [...document.querySelectorAll('.trash-list button')].map(button => { + const rect = button.getBoundingClientRect() + return { width: rect.width, height: rect.height } + }), + })) + expect(geometry.document).toBe(geometry.viewport) + for (const control of geometry.controls) { + expect(control.width).toBeGreaterThanOrEqual(44) + expect(control.height).toBeGreaterThanOrEqual(44) + } +}) diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index 8ab715a..b0f15db 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -101,6 +101,9 @@ test('task rows use the body for detail and Trash keeps distinct actions', async await page.getByRole('button', { name: '关闭详情' }).click() await openSidebarView(page, '回收站') + await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。') + await expect(page.getByRole('heading', { name: '无截止日期', exact: true })).toBeVisible() + await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除') const restoreRow = await taskRow(page, restoreTitle) const purgeRow = await taskRow(page, purgeTitle) for (const deletedRow of [restoreRow, purgeRow]) { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6997122..4fd0af1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,7 @@ import { Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Settings, Trash2, X, Repeat2, StickyNote, } from 'lucide-vue-next' -import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' +import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' @@ -428,6 +428,7 @@ const visibleTasks = computed(() => { }) const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? []) const taskTree = computed(() => groupTaskTree(visibleTasks.value)) +const trashGroups = computed(() => groupTrashTaskTree(visibleTasks.value)) const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value)) watch(composeDueAt, (value) => { if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' } @@ -1163,7 +1164,11 @@ async function restoreTask(task: Task) { await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true) } async function purgeTask(task: Task) { - if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return + const childCount = task.subtasks?.length ?? 0 + const impact = childCount + ? `此任务及其 ${childCount} 个子任务将被永久删除,不能撤销。` + : '此任务将被永久删除,不能撤销。' + if (!(await confirmAction(`永久删除“${task.title}”?`, impact, true))) return await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false) } async function addSubtask() { @@ -1700,7 +1705,7 @@ onUnmounted(() => {
-

{{ activeName }}

+

{{ activeName }}

+
+

回收站

删除的任务保留在这里,可整组恢复或永久删除。

+ 共 {{totalTasks}} 项 +
任务{{ totalTasks }}
-
共 {{totalTasks}} 项
-
- -
已隐藏已完成任务
-
{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}
-
+
+
+

{{group.label}}

{{group.nodes.length}}
+
+
+
{{node.task.title}}含 {{node.subtasks.length}} 个子任务,整组处理
+ + +
+
+
+

父任务与子任务始终作为一个整体处理,不支持子任务脱离父任务单独恢复或删除。

+
+
+ +
已隐藏已完成任务
+
{{ activeView==='trash' ? '回收站是空的' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}{{activeView==='trash' ? '删除的任务会显示在这里' : hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}
+
diff --git a/frontend/src/TodayEnvironment.test.ts b/frontend/src/TodayEnvironment.test.ts index 0b0f75f..69b82e6 100644 --- a/frontend/src/TodayEnvironment.test.ts +++ b/frontend/src/TodayEnvironment.test.ts @@ -90,7 +90,7 @@ describe('Today environment integration', () => { expect(filter).toBeGreaterThan(remaining) expect(overdue).toBeGreaterThan(filter) expect(main.match(/>今天<\/h1>/g)).toHaveLength(1) - expect(main).toContain("
{ expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }]) }) + it('groups trash parents by overdue, upcoming, and no deadline while keeping parent-child units intact', () => { + const now = new Date('2026-09-21T08:00:00+08:00') + const child: TaskFixture = { id: 'child', title: 'Child', parent_id: 'overdue' } + const rows: TaskFixture[] = [ + { id: 'none', title: 'No deadline', parent_id: null }, + { id: 'future', title: 'Future', parent_id: null, due_at: '2026-10-20T15:59:00.000Z' }, + { id: 'overdue', title: 'Overdue', parent_id: null, due_at: '2026-09-07T01:11:00.000Z' }, + child, + ] + + expect(groupTrashTaskTree(rows, now)).toEqual([ + { key: 'overdue', label: '已过期', nodes: [{ task: rows[2], subtasks: [child] }] }, + { key: 'upcoming', label: '未来截止', nodes: [{ task: rows[1], subtasks: [] }] }, + { key: 'undated', label: '无截止日期', nodes: [{ task: rows[0], subtasks: [] }] }, + ]) + }) + + it('treats invalid trash deadlines as undated instead of overdue', () => { + const task: TaskFixture = { id: 'invalid', title: 'Invalid', parent_id: null, due_at: 'not-a-date' } + expect(groupTrashTaskTree([task], new Date('2026-09-21T08:00:00+08:00'))).toEqual([ + { key: 'undated', label: '无截止日期', nodes: [{ task, subtasks: [] }] }, + ]) + }) + 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') diff --git a/frontend/src/lib/task-utils.ts b/frontend/src/lib/task-utils.ts index 3ce4d10..bfe87f4 100644 --- a/frontend/src/lib/task-utils.ts +++ b/frontend/src/lib/task-utils.ts @@ -85,6 +85,27 @@ export function groupTaskTree(tasks: T[]) { })) } +export type TrashTaskGroup = { + key: 'overdue' | 'upcoming' | 'undated' + label: '已过期' | '未来截止' | '无截止日期' + nodes: Array<{ task: T; subtasks: T[] }> +} + +export function groupTrashTaskTree(tasks: T[], now = new Date()): TrashTaskGroup[] { + const nodes = groupTaskTree(tasks) + const groups: TrashTaskGroup[] = [ + { key: 'overdue', label: '已过期', nodes: [] }, + { key: 'upcoming', label: '未来截止', nodes: [] }, + { key: 'undated', label: '无截止日期', nodes: [] }, + ] + for (const node of nodes) { + const due = node.task.due_at ? Date.parse(node.task.due_at) : Number.NaN + const key = Number.isFinite(due) ? (due < now.valueOf() ? 'overdue' : 'upcoming') : 'undated' + groups.find((group) => group.key === key)!.nodes.push(node) + } + return groups.filter((group) => group.nodes.length) +} + export function classifyTaskForToday( task: Pick, start: Date, diff --git a/frontend/src/style.css b/frontend/src/style.css index 20402c9..b3c34e8 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -116,9 +116,19 @@ input,select,textarea{background:var(--surface-raised);border-color:var(--border .task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis} .plain-list .task-due--timed .task-due__text{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}.plain-list .task-due--timed .task-due__separator{display:inline}.plain-list>.task-row:has(>.task-tail .task-due--timed){height:58px;min-height:58px;max-height:58px} -.task-row--trash{grid-template-columns:minmax(0,1fr) auto;padding-left:12px!important} +.task-row--trash{grid-template-columns:minmax(0,1fr) auto auto;padding-left:0!important} .task-row--trash .task-main{cursor:default}.task-row--trash .task-actions{min-width:0} +/* Approved Trash 01: compact deadline groups. */ +main:has(>.trash-page-context){background:#fdfaf3} +main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:min(100%,900px);margin-left:auto;margin-right:auto} +.trash-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;padding:7px 0 21px;border-bottom:1px solid #e8e0d5} +.trash-page-title{margin:0;font-size:34px;line-height:1.1;font-weight:700;letter-spacing:-1.2px}.trash-page-summary{margin:8px 0 0;color:var(--muted);font-size:13px}.trash-page-count{min-height:44px;display:flex;align-items:center;color:var(--muted);font-size:12px} +.trash-groups{display:grid}.trash-group{display:grid}.trash-group-heading{min-height:44px;padding-top:11px;display:flex;align-items:center;gap:7px;border-bottom:1px solid #e8e0d5}.trash-group-heading h2{margin:0;font-size:13px;font-weight:750}.trash-group-heading span{color:var(--muted);font-size:12px} +.trash-list>.task-row{height:62px;min-height:62px;max-height:62px}.trash-list .task-main{display:grid;align-content:center;gap:3px}.trash-list .task-main strong{font-size:14px;font-weight:620}.trash-list .task-main .meta{padding:0;font-size:11px}.trash-list .task-main .meta-item{display:inline-flex;align-items:center;gap:4px}.trash-list .task-main .meta-item svg{width:13px;height:13px}.trash-list .task-actions{display:flex;align-items:center;gap:0}.trash-list .restore{min-height:44px}.trash-list .trash-more{width:44px;height:44px;color:var(--danger)}.trash-list .trash-more svg{width:18px;height:18px}.trash-safety-note{margin:18px 0 0;padding:12px 14px;border:1px solid #ead7b4;border-radius:12px;background:#fff9ec;color:#755e3d;font-size:12px;line-height:1.55} +@media(max-width:720px){main:has(>.trash-page-context){padding-left:29px;padding-right:29px;padding-bottom:calc(78px + var(--safe-area-bottom))}main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:100%}.trash-page-context{padding-top:2px;padding-bottom:17px}.trash-page-title{font-size:24px}.trash-page-summary{font-size:12px;line-height:1.5}.trash-group-heading{padding-top:9px}.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) auto}.trash-list .task-tail{display:none}.trash-list .restore{min-width:72px;padding-inline:12px}.trash-safety-note{margin-top:16px}} +@media(max-width:390px){main:has(>.trash-page-context){padding-left:17px;padding-right:17px}.trash-page-context{gap:10px}.trash-page-summary{max-width:230px}.trash-list .task-actions{margin-left:6px}.trash-list .restore{min-width:68px;padding-inline:10px}} + /* Approved task-list and full-Habits 01 parity. */ main.list-main{background:#fdfaf3} main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px);margin-left:auto;margin-right:auto} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 702c9f7..c5f91c0 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -14,7 +14,7 @@ const appSheet = readFileSync('src/components/AppSheet.vue', 'utf8') describe('unified task due display', () => { it('uses the shared display for overdue and ordinary parent task rows', () => { expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'") - expect(app.match(/ { }) it('places every visible-list due display in a right tail before stable actions', () => { - expect(app.match(/') + expect(app).toContain('