refactor: unify task and habit list rows
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
+31
-36
@@ -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', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user