From a4c1ac59782c777a89ff607d89e9d89ead38023a Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 18 Sep 2026 06:25:13 +0800 Subject: [PATCH] refactor: unify task and habit list rows --- frontend/e2e/task-row-rhythm.spec.ts | 15 ++-- frontend/e2e/ui-reduction-acceptance.spec.ts | 45 ++++++++--- frontend/e2e/visual-polish.spec.ts | 81 +++++++++++++++++++- frontend/src/App.vue | 8 +- frontend/src/MvpPanel.vue | 41 +++------- frontend/src/style.css | 30 +++++--- frontend/src/style.test.ts | 67 ++++++++-------- frontend/src/visual-polish.test.ts | 40 ++++++++-- 8 files changed, 219 insertions(+), 108 deletions(-) diff --git a/frontend/e2e/task-row-rhythm.spec.ts b/frontend/e2e/task-row-rhythm.spec.ts index 8c2d5ea..24e376e 100644 --- a/frontend/e2e/task-row-rhythm.spec.ts +++ b/frontend/e2e/task-row-rhythm.spec.ts @@ -32,8 +32,8 @@ async function expectTaskRowGeometry(row: Locator) { directChildrenInside, } }) - expect(metrics.height).toBeGreaterThanOrEqual(64) - expect(metrics.height).toBeLessThanOrEqual(68) + expect(metrics.height).toBeGreaterThanOrEqual(57) + expect(metrics.height).toBeLessThanOrEqual(59) expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) expect(metrics.tailInside).toBe(true) expect(metrics.directChildrenInside).toBe(true) @@ -70,9 +70,14 @@ test('task rows keep approved rhythm without clipping across desktop and mobile' const dueTitle = `含截止日期-${suffix}` const parentTitle = `含子任务-${suffix}` const localDate = (offset: number) => { - const date = new Date() - date.setDate(date.getDate() + offset) - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + const shanghaiDate = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(new Date()) + const date = new Date(`${shanghaiDate}T12:00:00+08:00`) + date.setUTCDate(date.getUTCDate() + offset) + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(date) } await create(ordinaryTitle) await create(completedTitle) diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index ce9d814..5cd43a2 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -1,5 +1,5 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' -import { expect, test } from './fixtures' +import { allowExpectedError, expect, test } from './fixtures' async function csrf(request: APIRequestContext) { const state = await request.storageState() @@ -61,16 +61,19 @@ async function box(locator: Locator) { } test('task rows use the body for detail and Trash keeps distinct actions', async ({ page, request, baseURL }, testInfo) => { - const suffix = testInfo.project.name + const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 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 todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度` const inboxTitle = `验收清单任务-${suffix}` - const trashTitle = `验收回收站任务-${suffix}` + const restoreTitle = `验收恢复任务-${suffix}` + const purgeTitle = `验收永久删除任务-${suffix}` await createTask(request, baseURL!, inboxTitle, inbox.id) - const trash = await createTask(request, baseURL!, trashTitle, inbox.id) - expect((await mutate(request, baseURL!, `/api/v1/tasks/${trash.id}`, { method: 'DELETE' })).ok()).toBeTruthy() + const restoreCandidate = await createTask(request, baseURL!, restoreTitle, inbox.id) + const purgeCandidate = await createTask(request, baseURL!, purgeTitle, inbox.id) + expect((await mutate(request, baseURL!, `/api/v1/tasks/${restoreCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy() + expect((await mutate(request, baseURL!, `/api/v1/tasks/${purgeCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy() await page.goto('/') await page.getByRole('button', { name: '添加任务' }).click() @@ -98,10 +101,30 @@ test('task rows use the body for detail and Trash keeps distinct actions', async await page.getByRole('button', { name: '关闭详情' }).click() await openSidebarView(page, '回收站') - const deletedRow = await taskRow(page, trashTitle) - await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible() - await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible() - expect(await deletedRow.locator('.task-detail-trigger').count()).toBe(0) + const restoreRow = await taskRow(page, restoreTitle) + const purgeRow = await taskRow(page, purgeTitle) + for (const deletedRow of [restoreRow, purgeRow]) { + await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible() + await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible() + await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role') + await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex') + expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0) + } + await restoreRow.getByRole('button', { name: '恢复' }).click() + await expect(restoreRow).toHaveCount(0) + await purgeRow.getByRole('button', { name: '永久删除' }).click() + const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` }) + await expect(purgeDialog).toBeVisible() + // The UI can abort the completed 204 request while the confirmation overlay closes. + allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`) + await purgeDialog.getByRole('button', { name: '确认', exact: true }).click() + await expect(purgeRow).toHaveCount(0) + await page.reload() + await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0) + await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0) + await openSidebarView(page, '收集箱') + await expect(await taskRow(page, restoreTitle)).toHaveCount(1) + await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0) await expectNoHorizontalOverflow(page, 'tasks-trash', testInfo) }) @@ -131,8 +154,8 @@ test('Settings removes intro/empty danger and places mode-specific restore risk }) test('Habits and Countdowns use reduced headers, compact rows, and continuous archive styling', async ({ page, request, baseURL }, testInfo) => { - const suffix = testInfo.project.name - const day = new Date().toLocaleDateString('sv-SE') + const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()) const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true) await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day) const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day) diff --git a/frontend/e2e/visual-polish.spec.ts b/frontend/e2e/visual-polish.spec.ts index 1b981a3..f53eb23 100644 --- a/frontend/e2e/visual-polish.spec.ts +++ b/frontend/e2e/visual-polish.spec.ts @@ -10,6 +10,24 @@ async function mutate(request: APIRequestContext, baseURL: string, path: string, return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) } +async function createHabit(request: APIRequestContext, baseURL: string, name: string, extra: Record = {}) { + const response = await mutate(request, baseURL, '/api/v1/habits', { + method: 'POST', + data: { name, kind: 'boolean', schedule_type: 'daily', ...extra }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise<{ id: string }> +} + +function shanghaiToday() { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', + }).formatToParts(new Date()) + const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)!.value + const weekdays: Record = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 } + return { day: `${value('year')}-${value('month')}-${value('day')}`, weekday: weekdays[value('weekday')] } +} + function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } @@ -39,8 +57,55 @@ async function expectNotClipped(locator: Locator) { expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight) } +test('boolean habits complete in place while paused and unscheduled rows explain why they are read-only', async ({ page, request, baseURL }, testInfo) => { + const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const today = shanghaiToday() + const booleanName = `布尔习惯-${suffix}` + const pausedName = `暂停习惯-${suffix}` + const unscheduledName = `未安排习惯-${suffix}` + await createHabit(request, baseURL!, booleanName) + const paused = await createHabit(request, baseURL!, pausedName) + await createHabit(request, baseURL!, unscheduledName, { schedule_type: 'weekly', weekdays: [(today.weekday + 1) % 7] }) + const pause = await mutate(request, baseURL!, `/api/v1/habits/${paused.id}/pauses`, { + method: 'POST', data: { start_date: today.day, end_date: today.day }, + }) + expect(pause.ok(), await pause.text()).toBeTruthy() + + await page.goto('/') + await bottomTab(page, '习惯').click() + const booleanRow = page.locator('.habit-row').filter({ hasText: booleanName }) + const booleanCheck = booleanRow.getByRole('button', { name: `完成${booleanName}一次` }) + const checkGeometry = await booleanCheck.evaluate((element) => { + const control = element.getBoundingClientRect() + const row = element.closest('.habit-row')!.getBoundingClientRect() + return { + visibleWidth: control.width - Math.max(row.left - control.left, 0) - Math.max(control.right - row.right, 0), + height: control.height, + } + }) + expect(checkGeometry.visibleWidth).toBeGreaterThanOrEqual(44) + expect(checkGeometry.height).toBeGreaterThanOrEqual(44) + await booleanCheck.click() + await expect(booleanRow).toHaveClass(/done/) + await expect(booleanRow.getByRole('button', { name: `减少${booleanName}一次` })).toHaveAttribute('aria-pressed', 'true') + await page.reload() + const persistedBooleanRow = page.locator('.habit-row').filter({ hasText: booleanName }) + await expect(persistedBooleanRow).toHaveClass(/done/) + await persistedBooleanRow.locator('.habit-main').focus() + await persistedBooleanRow.locator('.habit-main').press('Enter') + await expect(page.getByRole('dialog', { name: booleanName })).toBeVisible() + await page.getByRole('button', { name: '关闭习惯详情' }).click() + + const pausedRow = page.locator('.habit-row').filter({ hasText: pausedName }) + await expect(pausedRow).toContainText('今天已暂停') + await expect(pausedRow.getByRole('button', { name: '今天已暂停' })).toBeDisabled() + const unscheduledRow = page.locator('.habit-row').filter({ hasText: unscheduledName }) + await expect(unscheduledRow).toContainText('今天未安排') + await expect(unscheduledRow.getByRole('button', { name: '今天未安排' })).toBeDisabled() +}) + test('approved polish keeps search state, dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => { - const suffix = testInfo.project.name + const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 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) @@ -60,7 +125,7 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq }) expect(memoResponse.ok(), await memoResponse.text()).toBeTruthy() - const day = new Date().toLocaleDateString('sv-SE') + const day = shanghaiToday().day const countdownTitle = `紧凑倒数-${suffix}` const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', @@ -78,8 +143,16 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await searchInput.fill(`搜索保留-${suffix}`) const taskRow = page.locator('.task-row').filter({ hasText: taskTitle }) await expect(taskRow).toHaveCount(1) - await expectHeightInRange(taskRow, 64, 68) + await expectHeightInRange(taskRow, 57, 59) await expectNotClipped(taskRow) + const transparent = 'rgba(0, 0, 0, 0)' + await expect(taskRow).toHaveCSS('background-color', transparent) + await taskRow.hover() + await expect(taskRow).toHaveCSS('background-color', transparent) + await taskRow.locator('.task-main').click() + await expect(taskRow).toHaveClass(/selected/) + await expect(taskRow).toHaveCSS('background-color', transparent) + await page.keyboard.press('Escape') await searchInput.press('Escape') const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' }) await expect(collapsedToggle).toBeFocused() @@ -96,7 +169,7 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await page.getByRole('button', { name: '添加习惯', exact: true }).click() const habitRow = page.locator('.habit-row').filter({ hasText: habitName }) await expect(habitRow).toHaveCount(1) - await expectHeightInRange(habitRow, 84, 88) + await expectHeightInRange(habitRow, 57, 59) await expectNotClipped(habitRow) await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click() const habitDetail = page.getByRole('dialog', { name: habitName }) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2f63726..5858653 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1533,7 +1533,7 @@ onUnmounted(() => {
{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项
{{page}} / {{totalPages}}
-
+
diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index 2b36497..5662930 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -235,22 +235,8 @@ function habitProgressText(h: Habit) { if (h.kind !== 'numeric') return '' return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}` } -function habitProgressMax(h: Habit) { - return Math.max(Number(h.target ?? 1), 1) -} -function habitProgressValue(h: Habit) { - return Math.min(Math.max(Number(logFor(h, todayKey.value)?.value ?? 0), 0), habitProgressMax(h)) -} -function habitWeekday(day: string) { - const [year, month, date] = day.split('-').map(Number) - return new Intl.DateTimeFormat('zh-CN', { weekday: 'narrow' }).format(new Date(year, month - 1, date)) -} -function habitCellDone(h: Habit, cell: NonNullable[number]) { - return isHabitComplete(h.kind, cell.value, h.target ?? 1) -} -function habitCellLabel(h: Habit, cell: NonNullable[number]) { - const state = !cell.scheduled ? '未安排' : cell.paused ? '已暂停' : habitCellDone(h, cell) ? '已完成' : '未完成' - return `${cell.day} ${habitWeekday(cell.day)}:${state}` +function habitRowStatus(h: Habit) { + return habitAction(h).reason || habitProgressText(h) || (isDone(h, todayKey.value) ? '已完成' : '未完成') } function setLocalHabitValue(h: Habit, next: number | boolean) { @@ -745,15 +731,13 @@ onBeforeUnmount(() => {