From 0cf1c490947b41dbdab1af6e6da57a3e2ed75e01 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sat, 19 Sep 2026 19:43:32 +0800 Subject: [PATCH] feat: align upcoming with list layout --- frontend/e2e/upcoming-list-parity.spec.ts | 89 +++++++++++++++++++++++ frontend/src/App.vue | 36 +++++---- frontend/src/TodayEnvironment.test.ts | 6 +- frontend/src/TodaySections.test.ts | 2 +- frontend/src/style.test.ts | 6 +- frontend/src/visual-polish.test.ts | 13 ++-- 6 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 frontend/e2e/upcoming-list-parity.spec.ts diff --git a/frontend/e2e/upcoming-list-parity.spec.ts b/frontend/e2e/upcoming-list-parity.spec.ts new file mode 100644 index 0000000..986c514 --- /dev/null +++ b/frontend/e2e/upcoming-list-parity.spec.ts @@ -0,0 +1,89 @@ +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 openSidebarView(page: Page, name: string) { + const sidebar = page.locator('.sidebar') + const target = sidebar.getByRole('button', { name, exact: true }) + if (await page.locator('.shell').evaluate(element => element.classList.contains('sidebar-collapsed'))) { + await page.getByRole('button', { name: '展开菜单' }).click() + } else if (await page.evaluate(() => window.innerWidth <= 930)) { + await page.locator('main .topbar > button').first().click() + } else if (!await target.isVisible()) { + await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click() + } + await target.click() +} + +function futureDueAt(days: number) { + const date = new Date() + date.setDate(date.getDate() + days) + date.setHours(12, 0, 0, 0) + return date.toISOString() +} + +test('Upcoming shares the task-list hierarchy while keeping its date scope and no manual reorder', async ({ page, request, baseURL }) => { + const bootstrapResponse = await request.get('/api/v1/bootstrap') + expect(bootstrapResponse.ok()).toBeTruthy() + const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) + expect(inbox).toBeTruthy() + + for (const [title, days] of [['未来一天任务', 1], ['未来三天任务', 3]] as const) { + const response = await mutate(request, baseURL!, '/api/v1/tasks', { + method: 'POST', + data: { title, list_id: inbox.id, due_at: futureDueAt(days), due_has_time: false }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + } + + await page.goto('/') + await openSidebarView(page, '最近 7 天') + + await expect(page.locator('main')).toHaveClass(/list-main/) + await expect(page.locator('.topbar-title')).toHaveCount(0) + const header = page.locator('.list-page-context') + await expect(header.getByRole('heading', { name: '最近 7 天' })).toBeVisible() + await expect(header).toContainText(/还有 \d+ 项待完成/) + await expect(header.getByRole('switch', { name: '显示已完成' })).toBeVisible() + await expect(page.locator('.list-section-heading')).toContainText('任务') + await expect(page.getByRole('button', { name: '调整顺序' })).toHaveCount(0) + await expect(page.locator('.task-row')).toHaveCount(2) + await expect(page.locator('.task-row')).toContainText(['未来一天任务', '未来三天任务']) + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => { + const value = document.querySelector(selector)!.getBoundingClientRect() + return { left: value.left, right: value.right, width: value.width, top: value.top, bottom: value.bottom } + } + return { + viewportWidth: innerWidth, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + header: rect('.list-page-context'), + search: rect('.list-search-reveal'), + section: rect('.list-section-heading'), + list: rect('.task-list'), + } + }) + expect(geometry.overflow).toBe(0) + expect(geometry.header.left).toBeCloseTo(geometry.search.left, 0) + expect(geometry.search.left).toBeCloseTo(geometry.section.left, 0) + expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0) + expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0) + if (geometry.viewportWidth <= 720) { + expect(geometry.header.left).toBeCloseTo(29, 0) + expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 29, 0) + } + + const fab = page.getByRole('button', { name: '添加任务' }) + await expect(fab).toBeVisible() + await fab.click() + await expect(page.getByRole('dialog', { name: '添加任务' })).toBeVisible() +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 81e8dd5..1228c23 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -369,7 +369,6 @@ function updateTodayHabitSummary(value: { total: number; completed: number }) { const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value) const visibleTasks = computed(() => { const now = new Date() - 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) => { @@ -377,7 +376,7 @@ const visibleTasks = computed(() => { 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) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)) return query.value.trim() ? filterTasks(result, query.value) : result }) const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? []) @@ -505,11 +504,14 @@ async function loadTaskPages(path: string) { } while (cursor) return rows } -function isoAtLocalDayOffset(offset: number) { +function startOfLocalDay(offset: number) { const day = new Date() day.setDate(day.getDate() + offset) day.setHours(0, 0, 0, 0) - return day.toISOString() + return day +} +function isoAtLocalDayOffset(offset: number) { + return startOfLocalDay(offset).toISOString() } async function loadTodayTaskSummary() { const token = ++todaySummaryLoadToken @@ -585,15 +587,18 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) { if (!isLatestRequest('tasks', request)) return tasks.value = data.items ?? [] totalTasks.value = data.total ?? tasks.value.length + if (activeView.value === 'upcoming' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value - if (activeView.value === 'tasks' && (showCompleted.value || query.value)) { + if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && (showCompleted.value || query.value)) { const token = ++taskOpenTotalLoadToken const listId = activeList.value const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' }) - if (listId) openParams.set('list_id', listId) + if (activeView.value === 'tasks' && listId) openParams.set('list_id', listId) + if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) } + const openView = activeView.value loadTaskOpenTotal( () => api(`/tasks?${openParams}`), - () => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === 'tasks' && activeList.value === listId, + () => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === openView && (openView !== 'tasks' || activeList.value === listId), value => { taskOpenTotal.value = value }, ) } @@ -1685,16 +1690,15 @@ onUnmounted(() => { -
+
-

{{ activeName }}

+

{{ activeName }}

-
-
+
@@ -1713,11 +1717,11 @@ onUnmounted(() => {
-
+

{{ activeName }}

{{ taskOpenTotal === null ? '待完成统计暂不可用' : `还有 ${taskOpenTotal} 项待完成` }}

-
+
@@ -1733,11 +1737,11 @@ onUnmounted(() => {
-
任务{{ totalTasks }}
-
{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项
+
任务{{ totalTasks }}
+
{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项
第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项
{{page}} / {{totalPages}}
-
+