refactor: unify task and habit list rows
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m45s

This commit is contained in:
2026-09-18 06:25:13 +08:00
parent e4e1d96b30
commit a4c1ac5978
8 changed files with 219 additions and 108 deletions
+10 -5
View File
@@ -32,8 +32,8 @@ async function expectTaskRowGeometry(row: Locator) {
directChildrenInside, directChildrenInside,
} }
}) })
expect(metrics.height).toBeGreaterThanOrEqual(64) expect(metrics.height).toBeGreaterThanOrEqual(57)
expect(metrics.height).toBeLessThanOrEqual(68) expect(metrics.height).toBeLessThanOrEqual(59)
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
expect(metrics.tailInside).toBe(true) expect(metrics.tailInside).toBe(true)
expect(metrics.directChildrenInside).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 dueTitle = `含截止日期-${suffix}`
const parentTitle = `含子任务-${suffix}` const parentTitle = `含子任务-${suffix}`
const localDate = (offset: number) => { const localDate = (offset: number) => {
const date = new Date() const shanghaiDate = new Intl.DateTimeFormat('en-CA', {
date.setDate(date.getDate() + offset) timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` }).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(ordinaryTitle)
await create(completedTitle) await create(completedTitle)
+34 -11
View File
@@ -1,5 +1,5 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test' import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { expect, test } from './fixtures' import { allowExpectedError, expect, test } from './fixtures'
async function csrf(request: APIRequestContext) { async function csrf(request: APIRequestContext) {
const state = await request.storageState() 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) => { 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 bootstrap = await request.get('/api/v1/bootstrap')
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy() expect(inbox).toBeTruthy()
const todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度` const todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度`
const inboxTitle = `验收清单任务-${suffix}` const inboxTitle = `验收清单任务-${suffix}`
const trashTitle = `验收回收站任务-${suffix}` const restoreTitle = `验收恢复任务-${suffix}`
const purgeTitle = `验收永久删除任务-${suffix}`
await createTask(request, baseURL!, inboxTitle, inbox.id) await createTask(request, baseURL!, inboxTitle, inbox.id)
const trash = await createTask(request, baseURL!, trashTitle, inbox.id) const restoreCandidate = await createTask(request, baseURL!, restoreTitle, inbox.id)
expect((await mutate(request, baseURL!, `/api/v1/tasks/${trash.id}`, { method: 'DELETE' })).ok()).toBeTruthy() 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.goto('/')
await page.getByRole('button', { name: '添加任务' }).click() 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 page.getByRole('button', { name: '关闭详情' }).click()
await openSidebarView(page, '回收站') await openSidebarView(page, '回收站')
const deletedRow = await taskRow(page, trashTitle) const restoreRow = await taskRow(page, restoreTitle)
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible() const purgeRow = await taskRow(page, purgeTitle)
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible() for (const deletedRow of [restoreRow, purgeRow]) {
expect(await deletedRow.locator('.task-detail-trigger').count()).toBe(0) 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) 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) => { test('Habits and Countdowns use reduced headers, compact rows, and continuous archive styling', 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 day = new Date().toLocaleDateString('sv-SE') const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())
const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true) const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true)
await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day) await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day)
const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day) const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day)
+77 -4
View File
@@ -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 } }) 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<string, unknown> = {}) {
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<string, number> = { 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) { function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) 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) 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) => { 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') const bootstrapResponse = await request.get('/api/v1/bootstrap')
expect(bootstrapResponse.ok()).toBeTruthy() expect(bootstrapResponse.ok()).toBeTruthy()
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) 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() expect(memoResponse.ok(), await memoResponse.text()).toBeTruthy()
const day = new Date().toLocaleDateString('sv-SE') const day = shanghaiToday().day
const countdownTitle = `紧凑倒数-${suffix}` const countdownTitle = `紧凑倒数-${suffix}`
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', {
method: 'POST', method: 'POST',
@@ -78,8 +143,16 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
await searchInput.fill(`搜索保留-${suffix}`) await searchInput.fill(`搜索保留-${suffix}`)
const taskRow = page.locator('.task-row').filter({ hasText: taskTitle }) const taskRow = page.locator('.task-row').filter({ hasText: taskTitle })
await expect(taskRow).toHaveCount(1) await expect(taskRow).toHaveCount(1)
await expectHeightInRange(taskRow, 64, 68) await expectHeightInRange(taskRow, 57, 59)
await expectNotClipped(taskRow) 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') await searchInput.press('Escape')
const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' }) const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' })
await expect(collapsedToggle).toBeFocused() 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() await page.getByRole('button', { name: '添加习惯', exact: true }).click()
const habitRow = page.locator('.habit-row').filter({ hasText: habitName }) const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
await expect(habitRow).toHaveCount(1) await expect(habitRow).toHaveCount(1)
await expectHeightInRange(habitRow, 84, 88) await expectHeightInRange(habitRow, 57, 59)
await expectNotClipped(habitRow) await expectNotClipped(habitRow)
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click() await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
const habitDetail = page.getByRole('dialog', { name: habitName }) const habitDetail = page.getByRole('dialog', { name: habitName })
+4 -4
View File
@@ -1533,7 +1533,7 @@ onUnmounted(() => {
<template v-if="activeView==='today'"> <template v-if="activeView==='today'">
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section"> <section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '' : '⌄' }}</span></button> <button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '' : '⌄' }}</span></button>
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list overdue-list" role="region" aria-labelledby="today-overdue-heading"> <div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list plain-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`"> <template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(node.task)" @keydown.enter="selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article> <article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(node.task)" @keydown.enter="selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
</template> </template>
@@ -1543,12 +1543,12 @@ onUnmounted(() => {
</template> </template>
<div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div> <div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div> <div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : undefined"> <section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : undefined">
<template v-for="node in taskTree" :key="node.task.id"> <template v-for="node in taskTree" :key="node.task.id">
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"> <article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'task-row--trash':activeView==='trash','just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button> <button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button> <button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div> <div class="task-main" :role="activeView==='trash' ? undefined : 'button'" :tabindex="activeView==='trash' ? undefined : 0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span> <span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span>
</article> </article>
</template> </template>
+9 -32
View File
@@ -235,22 +235,8 @@ function habitProgressText(h: Habit) {
if (h.kind !== 'numeric') return '' if (h.kind !== 'numeric') return ''
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}` return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
} }
function habitProgressMax(h: Habit) { function habitRowStatus(h: Habit) {
return Math.max(Number(h.target ?? 1), 1) return habitAction(h).reason || habitProgressText(h) || (isDone(h, todayKey.value) ? '已完成' : '未完成')
}
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<Habit['cells']>[number]) {
return isHabitComplete(h.kind, cell.value, h.target ?? 1)
}
function habitCellLabel(h: Habit, cell: NonNullable<Habit['cells']>[number]) {
const state = !cell.scheduled ? '未安排' : cell.paused ? '已暂停' : habitCellDone(h, cell) ? '已完成' : '未完成'
return `${cell.day} ${habitWeekday(cell.day)}${state}`
} }
function setLocalHabitValue(h: Habit, next: number | boolean) { function setLocalHabitValue(h: Habit, next: number | boolean) {
@@ -745,15 +731,13 @@ onBeforeUnmount(() => {
<template v-if="view === 'habits' || view === 'today-habits'"> <template v-if="view === 'habits' || view === 'today-habits'">
<!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 --> <!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 -->
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list"> <div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list plain-list today-habit-list">
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)"> <article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button> <button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"> <div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
<span class="habit-name">{{ h.name }}</span> <span class="habit-name" :title="h.name">{{ h.name }}</span>
<small v-if="habitAction(h).reason" class="habit-state-note">{{ habitAction(h).reason }}</small>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
</div> </div>
<small class="habit-row-meta" :title="habitRowStatus(h)" :aria-label="habitRowStatus(h)">{{ habitRowStatus(h) }}</small>
</article> </article>
<div v-if="!visibleTodayHabits.length && !busy" class="empty-panel today-empty-panel"><span>{{ !showCompleted && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="showCompleted || !todayHabits.length" class="soft-button empty-action" @click="openHabitComposer"><Check/>添加习惯</button></div> <div v-if="!visibleTodayHabits.length && !busy" class="empty-panel today-empty-panel"><span>{{ !showCompleted && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="showCompleted || !todayHabits.length" class="soft-button empty-action" @click="openHabitComposer"><Check/>添加习惯</button></div>
</div> </div>
@@ -776,21 +760,14 @@ onBeforeUnmount(() => {
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div> <div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
<!-- 习惯列表支持整行滑动记录 --> <!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list"> <div v-if="view === 'habits'" class="habit-list plain-list">
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)"> <article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row habit-row--full swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<button v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button> <button v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button> <button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"> <div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
<span class="habit-name">{{ h.name }}</span> <span class="habit-name" :title="h.name">{{ h.name }}</span>
<small v-if="habitAction(h).reason" class="habit-state-note">{{ habitAction(h).reason }}</small>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
<div class="habit-week" aria-label="最近一周打卡">
<span v-for="cell in h.cells" :key="cell.day" class="habit-week-cell" :class="{ done: habitCellDone(h, cell), unscheduled: !cell.scheduled || cell.paused, today: cell.day === todayKey }" :title="habitCellLabel(h, cell)" :aria-label="habitCellLabel(h, cell)">
<small class="habit-week-day">{{ habitWeekday(cell.day) }}</small><i></i>
</span>
</div>
</div> </div>
<small class="habit-row-meta" :title="habitRowStatus(h)" :aria-label="habitRowStatus(h)">{{ habitRowStatus(h) }}</small>
</article> </article>
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏' : '还没有习惯从一件容易坚持的小事开始' }}</div> <div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏' : '还没有习惯从一件容易坚持的小事开始' }}</div>
</div> </div>
+21 -9
View File
File diff suppressed because one or more lines are too long
+31 -36
View File
@@ -42,30 +42,23 @@ describe('unified task due display', () => {
expect(app).toContain('<span class="meta-item"><ListChecks/>') expect(app).toContain('<span class="meta-item"><ListChecks/>')
}) })
it('uses a right-aligned desktop tail and a compact two-line mobile tail without empty slots', () => { it('uses a right-aligned single-line tail without empty slots', () => {
expect(css).toContain('.task-tail{flex:0 1 auto;min-width:0;max-width:') expect(css).toContain('.task-tail{flex:0 1 auto;min-width:0;max-width:')
expect(css).toContain('text-align:right;white-space:nowrap') expect(css).toContain('text-align:right;white-space:nowrap')
expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}') expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}')
expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}') expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}')
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;') expect(css).toContain('.plain-list .task-due--timed .task-due__text{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}') expect(css).toContain('.plain-list .task-due--timed .task-due__separator{display:inline}')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed) .task-main{padding:4px 0}')
expect(css).toContain('.task-due--timed .task-due__text{display:grid;justify-items:end;overflow:hidden;text-overflow:ellipsis;')
expect(css).toContain('.task-due--timed .task-due__absolute,.task-due--timed .task-due__relative{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}')
expect(css).toContain('.task-due--timed .task-due__separator{display:none}')
expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;') expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;')
expect(css).toContain('.task-check{width:44px;height:44px;') expect(css).toContain('.task-check{width:44px;height:44px;')
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;') expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/) expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
}) })
it('keeps date-only rows compact on mobile while preserving the timed two-line layout', () => { it('overrides the legacy timed mobile presentation inside every plain list', () => {
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;')
expect(css).toContain('.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}') expect(css).toContain('.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}') expect(css).toContain('.plain-list .task-due--timed .task-due__text{display:block;')
expect(css).not.toContain('.task-row:has(>.task-tail){height:66px;max-height:68px;}') expect(css).toContain('.plain-list .task-due--timed .task-due__separator{display:inline}')
expect(css).toContain('.task-due--timed .task-due__text{display:grid;justify-items:end;')
expect(css).toContain('.task-due--timed .task-due__separator{display:none}')
}) })
it('scopes content metadata and keeps date semantics without a visible calendar icon', () => { it('scopes content metadata and keeps date semantics without a visible calendar icon', () => {
@@ -247,7 +240,7 @@ describe('approved UI detail direction', () => {
}) })
it('opens Today habit bodies with keyboard parity while check clicks stay isolated', () => { it('opens Today habit bodies with keyboard parity while check clicks stay isolated', () => {
const todayRows = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->')) const todayRows = mvpPanel.slice(mvpPanel.indexOf('class="habit-list plain-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
expect(todayRows).toContain('role="button" tabindex="0"') expect(todayRows).toContain('role="button" tabindex="0"')
expect(todayRows).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"') expect(todayRows).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"') expect(todayRows).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
@@ -758,29 +751,24 @@ describe('task and habit row decoration', () => {
expect(toggleBlock).not.toContain('await loadHabits()') expect(toggleBlock).not.toContain('await loadHabits()')
}) })
it('places numeric habit progress at the far right instead of under the title', () => { it('keeps habit status in the right-side tail and outside the title', () => {
expect(mvpPanel.match(/<small v-if="habitProgressText\(h\)" class="habit-progress">/g)?.length).toBe(2) expect(mvpPanel.match(/class="habit-row-meta"/g)?.length).toBe(2)
expect(mvpPanel).not.toContain('class="habit-progress"')
expect(mvpPanel).not.toContain('<span><span class="habit-name">{{ h.name }}</span><small') expect(mvpPanel).not.toContain('<span><span class="habit-name">{{ h.name }}</span><small')
expect(css).toContain('.habit-progress{margin-left:auto;')
}) })
it('keeps Today focused and adds the weekly grid only to the full habits view', () => { it('keeps weekly history out of both active list renderers', () => {
expect(mvpPanel.match(/class="habit-week"/g)?.length).toBe(1) expect(mvpPanel).not.toContain('class="habit-week"')
expect(mvpPanel).toContain('v-for="cell in h.cells"') expect(mvpPanel).not.toContain('v-for="cell in h.cells"')
expect(mvpPanel).toContain('class="habit-week-cell"')
expect(mvpPanel).toContain('class="habit-week-day"')
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?class="habit-week"[\s\S]*?<!-- 完整习惯列表 -->/)
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/) expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/)
}) })
it('uses numeric progress as the main feedback and omits it for boolean habits', () => { it('uses the same visible text status tail in Today and full habits', () => {
expect(mvpPanel.match(/<progress v-if="h.kind === 'numeric'" class="habit-progress-bar"/g)?.length).toBe(2) expect(mvpPanel).not.toContain('<progress v-if="h.kind === \'numeric\'" class="habit-progress-bar"')
expect(mvpPanel.match(/:value="habitProgressValue\(h\)" :max="habitProgressMax\(h\)"/g)?.length).toBe(2) expect(mvpPanel.match(/class="habit-row-meta"/g)?.length).toBe(2)
expect(mvpPanel.match(/:aria-label="`\$\{h.name\}进度:\$\{habitProgressText\(h\)\}`"/g)?.length).toBe(2) expect(mvpPanel.match(/:aria-label="habitRowStatus\(h\)"/g)?.length).toBe(2)
expect(css).toContain('.habit-progress{margin-left:auto;') expect(mvpPanel).not.toContain('class="habit-state-note"')
expect(css).toContain('font-size:16px') expect(css).toContain('.habit-row-meta{')
expect(css).toContain('.habit-progress-bar{grid-column:1/-1;')
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:var(--success)')
}) })
it('restores the current page and list from local storage', () => { it('restores the current page and list from local storage', () => {
@@ -988,11 +976,18 @@ describe('task and habit row decoration', () => {
expect(css).not.toContain('.habit-row.done .habit-name{color:var(--accent);text-decoration:line-through') expect(css).not.toContain('.habit-row.done .habit-name{color:var(--accent);text-decoration:line-through')
}) })
it('keeps habit cards borderless so the rounded left edge has no visual gap', () => { it('keeps every plain-list container completely cardless', () => {
expect(css).toMatch(/\.habit-row\{border:0;/) expect(css).toContain('.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
expect(css).not.toMatch(/\.habit-row\{[^}]*border-top:/) expect(css).not.toMatch(/\.plain-list\{[^}]*padding:/)
expect(css).not.toMatch(/\.habit-row\{[^}]*border-right:/) expect(css).not.toMatch(/\.plain-list\{[^}]*border:(?!0)/)
expect(css).not.toMatch(/\.habit-row\{[^}]*border-bottom:/) expect(css).not.toMatch(/\.plain-list\{[^}]*border-radius:(?!0)/)
expect(css).not.toMatch(/\.plain-list\{[^}]*box-shadow:(?!none)/)
})
it('uses a continuous bottom divider instead of card borders', () => {
expect(css).toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border:0;[^}]*border-bottom:1px solid #e8e0d5/)
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-top:/)
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-right:/)
}) })
it('keeps task rows borderless on the left and habit rows fully borderless', () => { it('keeps task rows borderless on the left and habit rows fully borderless', () => {
+33 -7
View File
@@ -34,14 +34,40 @@ describe('approved five-detail polish', () => {
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/) expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/)
}) })
it('keeps every desktop and mobile task row at the approved height', () => { it('uses one 58px plain-list contract for active task and habit rows', () => {
const polishStart = css.indexOf('/* Approved five-detail list/search polish. */') expect(app).toContain("class=\"task-list plain-list\"")
const polish = css.slice(polishStart) expect(app).toContain("'task-row--trash':activeView==='trash'")
expect(polish).toContain('}\n.task-row{min-height:66px;height:66px;max-height:68px}') expect(app).toContain(':role="activeView===\'trash\' ? undefined : \'button\'"')
expect(polish).toContain('.habit-row{min-height:86px;height:86px;max-height:88px}') expect(app).toContain(':tabindex="activeView===\'trash\' ? undefined : 0"')
expect(polish).toContain('.today-habit-row{min-height:86px;height:86px;max-height:88px}') expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;') expect(app).toContain('v-if="activeView!==\'trash\'" class="task-check"')
expect(habits).toContain('class=\"habit-list plain-list\"')
expect(habits).toContain('class=\"habit-row habit-row--full swipeable\"')
expect(css).toContain('.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
expect(css).toContain('.plain-list-row,.plain-list>.task-row,.plain-list>.habit-row{height:58px;min-height:58px;max-height:58px;background:transparent;border:0;border-bottom:1px solid #e8e0d5;border-radius:0;box-shadow:none}')
expect(css).toContain('.task-row{grid-template-columns:44px minmax(0,1fr) auto}')
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto}')
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
expect(css).toContain('.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}')
expect(css).toContain('.task-check{width:44px;') expect(css).toContain('.task-check{width:44px;')
expect(css).toContain('.plain-list .habit-check{margin-left:0}')
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
})
it('moves habit history out of every active list row and retains a visible status tail', () => {
const todayRows = habits.slice(habits.indexOf('class="habit-list plain-list today-habit-list"'), habits.indexOf('<!-- 完整习惯列表 -->'))
const fullRows = habits.slice(habits.indexOf('class="habit-list plain-list">'), habits.indexOf('class="habit-archive-section"'))
for (const rows of [todayRows, fullRows]) {
expect(rows).not.toContain('habit-week')
expect(rows).not.toContain('habit-progress-bar')
expect(rows).not.toContain('habit-state-note')
expect(rows).toContain('class="habit-row-meta"')
expect(rows).toContain(':aria-label="habitRowStatus(h)"')
}
})
it('keeps plain-list task and habit rows transparent in all persistent states', () => {
expect(css).toContain('.plain-list>.task-row:hover,.plain-list>.task-row.selected,.plain-list>.task-row.done,.plain-list>.habit-row:hover,.plain-list>.habit-row.done,.plain-list>.overdue-task{background:transparent}')
}) })
it('renders memo rows with title and time only at a fixed compact height', () => { it('renders memo rows with title and time only at a fixed compact height', () => {