[verified] redesign trash with deadline groups
ci / gitleaks (push) Successful in 11s
ci / docker (push) Successful in 8m0s

This commit is contained in:
2026-09-21 09:00:07 +08:00
parent c7bb9742f1
commit 740eae8a59
11 changed files with 234 additions and 55 deletions
+22 -16
View File
@@ -1091,22 +1091,22 @@ async def list_tasks(
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]: async def _task_details(
db: AsyncSession,
tasks: list[Task],
*,
include_deleted_subtasks: bool = False,
) -> list[TaskDetailOut]:
if not tasks: if not tasks:
return [] return []
allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks} allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks}
subtasks = list( subtask_query = select(Task).where(
( tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes)
await db.scalars(
select(Task)
.where(
tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes),
Task.deleted_at.is_(None),
)
.order_by(*_task_ordering())
)
).all()
) )
subtask_query = subtask_query.where(
Task.deleted_at.is_not(None) if include_deleted_subtasks else Task.deleted_at.is_(None)
)
subtasks = list((await db.scalars(subtask_query.order_by(*_task_ordering()))).all())
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list) subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for subtask in subtasks: for subtask in subtasks:
if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes: if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes:
@@ -1257,11 +1257,17 @@ async def list_trash(
Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None) Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None)
) )
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
ordering = (Task.created_at, Task.id) grouping_rank = case(
(Task.due_at < utcnow(), 0),
(Task.due_at.is_not(None), 1),
else_=2,
)
if page is not None: if page is not None:
size = page_size or limit size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) page_ordering = (grouping_rank, Task.due_at, Task.created_at, Task.id)
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size) items = list((await db.scalars(query.order_by(*page_ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=await _task_details(db, items, include_deleted_subtasks=True), total=total, page=page, page_size=size)
ordering = (Task.created_at, Task.id)
if cursor: if cursor:
created_at, task_id = _decode_trash_cursor(cursor) created_at, task_id = _decode_trash_cursor(cursor)
query = query.where( query = query.where(
@@ -1271,7 +1277,7 @@ async def list_trash(
has_more = len(rows) > limit has_more = len(rows) > limit
items = rows[:limit] items = rows[:limit]
next_cursor = _encode_trash_cursor(items[-1].created_at, items[-1].id) if has_more else None next_cursor = _encode_trash_cursor(items[-1].created_at, items[-1].id) if has_more else None
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) return TaskPage(items=await _task_details(db, items, include_deleted_subtasks=True), next_cursor=next_cursor, total=total, page=1, page_size=limit)
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut) @app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
+72
View File
@@ -0,0 +1,72 @@
import type { APIRequestContext, Page } from '@playwright/test'
import { expect, test } from './fixtures'
async function csrf(request: APIRequestContext) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
}
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
}
async function openTrash(page: Page) {
if ((await page.viewportSize())!.width <= 930) {
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
}
await page.locator('.sidebar').getByRole('button', { name: '回收站', exact: true }).click()
}
test('Trash uses compact deadline groups without exposing child-level actions', async ({ page, request, baseURL }) => {
const suffix = `${test.info().project.name}-${Date.now()}`
const bootstrap = await request.get('/api/v1/bootstrap')
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const createDeleted = async (title: string, dueAt?: string) => {
const created = await mutate(request, baseURL!, '/api/v1/tasks', {
method: 'POST', data: { title, list_id: inbox.id, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) },
})
expect(created.ok(), await created.text()).toBeTruthy()
const task = await created.json() as { id: string }
expect((await mutate(request, baseURL!, `/api/v1/tasks/${task.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
return task
}
const overdueTitle = `回收站过期-${suffix}`
const futureTitle = `回收站未来-${suffix}`
const undatedTitle = `回收站无日期-${suffix}`
await createDeleted(overdueTitle, '2026-01-02T15:59:00.000Z')
await createDeleted(futureTitle, '2099-12-30T15:59:00.000Z')
await createDeleted(undatedTitle)
await page.goto('/')
await openTrash(page)
await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。')
for (const group of ['已过期', '未来截止', '无截止日期']) {
await expect(page.getByRole('heading', { name: group, exact: true })).toBeVisible()
}
for (const title of [overdueTitle, futureTitle, undatedTitle]) {
const row = page.locator('.task-row--trash').filter({ hasText: title })
await expect(row).toHaveCount(1)
await expect(row.getByRole('button', { name: '恢复' })).toBeVisible()
await expect(row.getByRole('button', { name: '永久删除' })).toBeVisible()
expect(await row.locator('.task-check').count()).toBe(0)
}
await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除')
const geometry = await page.evaluate(() => ({
viewport: innerWidth,
document: document.documentElement.scrollWidth,
controls: [...document.querySelectorAll<HTMLElement>('.trash-list button')].map(button => {
const rect = button.getBoundingClientRect()
return { width: rect.width, height: rect.height }
}),
}))
expect(geometry.document).toBe(geometry.viewport)
for (const control of geometry.controls) {
expect(control.width).toBeGreaterThanOrEqual(44)
expect(control.height).toBeGreaterThanOrEqual(44)
}
})
@@ -101,6 +101,9 @@ test('task rows use the body for detail and Trash keeps distinct actions', async
await page.getByRole('button', { name: '关闭详情' }).click() await page.getByRole('button', { name: '关闭详情' }).click()
await openSidebarView(page, '回收站') await openSidebarView(page, '回收站')
await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。')
await expect(page.getByRole('heading', { name: '无截止日期', exact: true })).toBeVisible()
await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除')
const restoreRow = await taskRow(page, restoreTitle) const restoreRow = await taskRow(page, restoreTitle)
const purgeRow = await taskRow(page, purgeTitle) const purgeRow = await taskRow(page, purgeTitle)
for (const deletedRow of [restoreRow, purgeRow]) { for (const deletedRow of [restoreRow, purgeRow]) {
+37 -16
View File
@@ -5,7 +5,7 @@ import {
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
Settings, Trash2, X, Repeat2, StickyNote, Settings, Trash2, X, Repeat2, StickyNote,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
@@ -428,6 +428,7 @@ const visibleTasks = computed(() => {
}) })
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? []) const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
const taskTree = computed(() => groupTaskTree(visibleTasks.value)) const taskTree = computed(() => groupTaskTree(visibleTasks.value))
const trashGroups = computed(() => groupTrashTaskTree(visibleTasks.value))
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value)) const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
watch(composeDueAt, (value) => { watch(composeDueAt, (value) => {
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' } if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
@@ -1163,7 +1164,11 @@ async function restoreTask(task: Task) {
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true) await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true)
} }
async function purgeTask(task: Task) { async function purgeTask(task: Task) {
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return const childCount = task.subtasks?.length ?? 0
const impact = childCount
? `此任务及其 ${childCount} 个子任务将被永久删除,不能撤销。`
: '此任务将被永久删除,不能撤销。'
if (!(await confirmAction(`永久删除“${task.title}”?`, impact, true))) return
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false) await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false)
} }
async function addSubtask() { async function addSubtask() {
@@ -1700,7 +1705,7 @@ onUnmounted(() => {
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}"> <main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined"> <header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button> <button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div> <div v-if="!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
</header> </header>
<template v-if="['habits','settings'].includes(activeView)"> <template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" /> <MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" />
@@ -1731,20 +1736,36 @@ onUnmounted(() => {
</section> </section>
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button> <button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button>
</template> </template>
<section v-if="activeView==='trash'" class="trash-page-context">
<div><h1 class="trash-page-title">回收站</h1><p class="trash-page-summary">删除的任务保留在这里可整组恢复或永久删除</p></div>
<span class="trash-page-count"> {{totalTasks}} </span>
</section>
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div> <div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><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> <section v-if="activeView==='trash' && visibleTasks.length" ref="taskListElement" class="trash-groups" aria-label="回收站任务分组">
<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' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined" ref="taskListElement"> <section v-for="group in trashGroups" :key="group.key" class="trash-group" :aria-labelledby="`trash-group-${group.key}`">
<template v-for="node in taskTree" :key="node.task.id"> <header class="trash-group-heading"><h2 :id="`trash-group-${group.key}`">{{group.label}}</h2><span>{{group.nodes.length}}</span></header>
<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)"> <div class="task-list plain-list trash-list">
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="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> <article v-for="node in group.nodes" :key="node.task.id" :data-task-id="node.task.id" class="task-row task-row--trash">
<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"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/> {{node.subtasks.length}} 个子任务整组处理</span></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="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 class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost trash-more" aria-label="永久删除" :title="`永久删除${node.task.title}`" @click.stop="purgeTask(node.task)"><Ellipsis/></button></span>
</article> </article>
</template> </div>
<div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div> </section>
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div> <p class="trash-safety-note">父任务与子任务始终作为一个整体处理不支持子任务脱离父任务单独恢复或删除</p>
</section> </section>
<section v-else :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' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined" ref="taskListElement">
<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)">
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="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 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="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><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>
</article>
</template>
<div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ activeView==='trash' ? '回收站是空的' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{activeView==='trash' ? '删除的任务会显示在这里' : hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
</section>
<nav v-if="totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)" class="pager" aria-label="任务分页"><button class="pager-button pager-button--previous" :disabled="page<=1 || loading" aria-label="上一页" @click="previousPage"><ChevronLeft aria-hidden="true"/><span>上一页</span></button><span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span><button class="pager-button pager-button--next" :disabled="page>=totalPages || loading" aria-label="下一页" @click="nextPage"><span>下一页</span><ChevronRight aria-hidden="true"/></button></nav> <nav v-if="totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)" class="pager" aria-label="任务分页"><button class="pager-button pager-button--previous" :disabled="page<=1 || loading" aria-label="上一页" @click="previousPage"><ChevronLeft aria-hidden="true"/><span>上一页</span></button><span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span><button class="pager-button pager-button--next" :disabled="page>=totalPages || loading" aria-label="下一页" @click="nextPage"><span>下一页</span><ChevronRight aria-hidden="true"/></button></nav>
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor"> <div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button> <button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button>
+1 -1
View File
@@ -90,7 +90,7 @@ describe('Today environment integration', () => {
expect(filter).toBeGreaterThan(remaining) expect(filter).toBeGreaterThan(remaining)
expect(overdue).toBeGreaterThan(filter) expect(overdue).toBeGreaterThan(filter)
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1) expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1") expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
expect(main).not.toContain('class="topbar-actions"') expect(main).not.toContain('class="topbar-actions"')
expect(main).not.toContain('class="topbar-filter"') expect(main).not.toContain('class="topbar-filter"')
expect(main).not.toContain('aria-label="刷新当前页面"') expect(main).not.toContain('aria-label="刷新当前页面"')
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils' import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
type TaskFixture = { type TaskFixture = {
id: string id: string
@@ -7,6 +7,7 @@ type TaskFixture = {
description?: string description?: string
parent_id?: string | null parent_id?: string | null
completed?: boolean completed?: boolean
due_at?: string | null
list_name?: string list_name?: string
subtasks?: TaskFixture[] subtasks?: TaskFixture[]
} }
@@ -28,6 +29,30 @@ describe('task utilities', () => {
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }]) expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }])
}) })
it('groups trash parents by overdue, upcoming, and no deadline while keeping parent-child units intact', () => {
const now = new Date('2026-09-21T08:00:00+08:00')
const child: TaskFixture = { id: 'child', title: 'Child', parent_id: 'overdue' }
const rows: TaskFixture[] = [
{ id: 'none', title: 'No deadline', parent_id: null },
{ id: 'future', title: 'Future', parent_id: null, due_at: '2026-10-20T15:59:00.000Z' },
{ id: 'overdue', title: 'Overdue', parent_id: null, due_at: '2026-09-07T01:11:00.000Z' },
child,
]
expect(groupTrashTaskTree(rows, now)).toEqual([
{ key: 'overdue', label: '已过期', nodes: [{ task: rows[2], subtasks: [child] }] },
{ key: 'upcoming', label: '未来截止', nodes: [{ task: rows[1], subtasks: [] }] },
{ key: 'undated', label: '无截止日期', nodes: [{ task: rows[0], subtasks: [] }] },
])
})
it('treats invalid trash deadlines as undated instead of overdue', () => {
const task: TaskFixture = { id: 'invalid', title: 'Invalid', parent_id: null, due_at: 'not-a-date' }
expect(groupTrashTaskTree([task], new Date('2026-09-21T08:00:00+08:00'))).toEqual([
{ key: 'undated', label: '无截止日期', nodes: [{ task, subtasks: [] }] },
])
})
it('classifies due edits for Today membership', () => { it('classifies due edits for Today membership', () => {
const start = new Date('2026-09-10T00:00:00+08:00') const start = new Date('2026-09-10T00:00:00+08:00')
const end = new Date('2026-09-11T00:00:00+08:00') const end = new Date('2026-09-11T00:00:00+08:00')
+21
View File
@@ -85,6 +85,27 @@ export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
})) }))
} }
export type TrashTaskGroup<T extends MinimalTask> = {
key: 'overdue' | 'upcoming' | 'undated'
label: '已过期' | '未来截止' | '无截止日期'
nodes: Array<{ task: T; subtasks: T[] }>
}
export function groupTrashTaskTree<T extends MinimalTask>(tasks: T[], now = new Date()): TrashTaskGroup<T>[] {
const nodes = groupTaskTree(tasks)
const groups: TrashTaskGroup<T>[] = [
{ key: 'overdue', label: '已过期', nodes: [] },
{ key: 'upcoming', label: '未来截止', nodes: [] },
{ key: 'undated', label: '无截止日期', nodes: [] },
]
for (const node of nodes) {
const due = node.task.due_at ? Date.parse(node.task.due_at) : Number.NaN
const key = Number.isFinite(due) ? (due < now.valueOf() ? 'overdue' : 'upcoming') : 'undated'
groups.find((group) => group.key === key)!.nodes.push(node)
}
return groups.filter((group) => group.nodes.length)
}
export function classifyTaskForToday( export function classifyTaskForToday(
task: Pick<MinimalTask, 'completed' | 'due_at'>, task: Pick<MinimalTask, 'completed' | 'due_at'>,
start: Date, start: Date,
+11 -1
View File
@@ -116,9 +116,19 @@ input,select,textarea{background:var(--surface-raised);border-color:var(--border
.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis} .task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}
.plain-list .task-due--timed .task-due__text{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}.plain-list .task-due--timed .task-due__separator{display:inline}.plain-list>.task-row:has(>.task-tail .task-due--timed){height:58px;min-height:58px;max-height:58px} .plain-list .task-due--timed .task-due__text{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}.plain-list .task-due--timed .task-due__separator{display:inline}.plain-list>.task-row:has(>.task-tail .task-due--timed){height:58px;min-height:58px;max-height:58px}
.task-row--trash{grid-template-columns:minmax(0,1fr) auto;padding-left:12px!important} .task-row--trash{grid-template-columns:minmax(0,1fr) auto auto;padding-left:0!important}
.task-row--trash .task-main{cursor:default}.task-row--trash .task-actions{min-width:0} .task-row--trash .task-main{cursor:default}.task-row--trash .task-actions{min-width:0}
/* Approved Trash 01: compact deadline groups. */
main:has(>.trash-page-context){background:#fdfaf3}
main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:min(100%,900px);margin-left:auto;margin-right:auto}
.trash-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;padding:7px 0 21px;border-bottom:1px solid #e8e0d5}
.trash-page-title{margin:0;font-size:34px;line-height:1.1;font-weight:700;letter-spacing:-1.2px}.trash-page-summary{margin:8px 0 0;color:var(--muted);font-size:13px}.trash-page-count{min-height:44px;display:flex;align-items:center;color:var(--muted);font-size:12px}
.trash-groups{display:grid}.trash-group{display:grid}.trash-group-heading{min-height:44px;padding-top:11px;display:flex;align-items:center;gap:7px;border-bottom:1px solid #e8e0d5}.trash-group-heading h2{margin:0;font-size:13px;font-weight:750}.trash-group-heading span{color:var(--muted);font-size:12px}
.trash-list>.task-row{height:62px;min-height:62px;max-height:62px}.trash-list .task-main{display:grid;align-content:center;gap:3px}.trash-list .task-main strong{font-size:14px;font-weight:620}.trash-list .task-main .meta{padding:0;font-size:11px}.trash-list .task-main .meta-item{display:inline-flex;align-items:center;gap:4px}.trash-list .task-main .meta-item svg{width:13px;height:13px}.trash-list .task-actions{display:flex;align-items:center;gap:0}.trash-list .restore{min-height:44px}.trash-list .trash-more{width:44px;height:44px;color:var(--danger)}.trash-list .trash-more svg{width:18px;height:18px}.trash-safety-note{margin:18px 0 0;padding:12px 14px;border:1px solid #ead7b4;border-radius:12px;background:#fff9ec;color:#755e3d;font-size:12px;line-height:1.55}
@media(max-width:720px){main:has(>.trash-page-context){padding-left:29px;padding-right:29px;padding-bottom:calc(78px + var(--safe-area-bottom))}main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:100%}.trash-page-context{padding-top:2px;padding-bottom:17px}.trash-page-title{font-size:24px}.trash-page-summary{font-size:12px;line-height:1.5}.trash-group-heading{padding-top:9px}.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) auto}.trash-list .task-tail{display:none}.trash-list .restore{min-width:72px;padding-inline:12px}.trash-safety-note{margin-top:16px}}
@media(max-width:390px){main:has(>.trash-page-context){padding-left:17px;padding-right:17px}.trash-page-context{gap:10px}.trash-page-summary{max-width:230px}.trash-list .task-actions{margin-left:6px}.trash-list .restore{min-width:68px;padding-inline:10px}}
/* Approved task-list and full-Habits 01 parity. */ /* Approved task-list and full-Habits 01 parity. */
main.list-main{background:#fdfaf3} main.list-main{background:#fdfaf3}
main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px);margin-left:auto;margin-right:auto} main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px);margin-left:auto;margin-right:auto}
+14 -13
View File
@@ -14,7 +14,7 @@ const appSheet = readFileSync('src/components/AppSheet.vue', 'utf8')
describe('unified task due display', () => { describe('unified task due display', () => {
it('uses the shared display for overdue and ordinary parent task rows', () => { it('uses the shared display for overdue and ordinary parent task rows', () => {
expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'") expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'")
expect(app.match(/<TaskDueDisplay/g)).toHaveLength(2) expect(app.match(/<TaskDueDisplay/g)).toHaveLength(3)
expect(app).toContain(':due-at="node.task.due_at"') expect(app).toContain(':due-at="node.task.due_at"')
expect(app).not.toContain(':due-at="subtask.due_at"') expect(app).not.toContain(':due-at="subtask.due_at"')
expect(app).not.toContain('formatDue(') expect(app).not.toContain('formatDue(')
@@ -31,10 +31,10 @@ describe('unified task due display', () => {
}) })
it('places every visible-list due display in a right tail before stable actions', () => { it('places every visible-list due display in a right tail before stable actions', () => {
expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(2) expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(3)
expect(app).not.toContain('class="meta"><TaskDueDisplay') expect(app).not.toContain('class="meta"><TaskDueDisplay')
expect(app).toContain('</div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay') expect(app).toContain('</div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay')
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">') expect(app).toContain('<span class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)">')
expect(app).not.toContain('restoreTask(subtask)') expect(app).not.toContain('restoreTask(subtask)')
expect(app).not.toContain('purgeTask(subtask)') expect(app).not.toContain('purgeTask(subtask)')
expect(app).not.toContain('task-detail-trigger') expect(app).not.toContain('task-detail-trigger')
@@ -160,7 +160,7 @@ describe('approved Settings 01 paper ledger', () => {
it('moves Settings identity into the body and suppresses the duplicate shell title', () => { it('moves Settings identity into the body and suppresses the duplicate shell title', () => {
expect(app).not.toContain("'settings-main':activeView==='settings'") expect(app).not.toContain("'settings-main':activeView==='settings'")
expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"") expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"")
expect(app).toContain("v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"") expect(app).toContain("v-if=\"!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"') expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
expect(app).not.toContain('aria-label="刷新当前页面"') expect(app).not.toContain('aria-label="刷新当前页面"')
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>') expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
@@ -317,22 +317,23 @@ describe('solid cream material system', () => {
describe('approved UI detail direction', () => { describe('approved UI detail direction', () => {
it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => { it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => {
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"') const ordinaryStart = app.indexOf('<section v-else :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart)) const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)") expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
expect(ordinaryRows).not.toContain('task-detail-trigger') expect(ordinaryRows).not.toContain('task-detail-trigger')
expect(ordinaryRows).not.toContain('selectTask(subtask)') expect(ordinaryRows).not.toContain('selectTask(subtask)')
expect(ordinaryRows).not.toContain('aria-label="删除任务"') expect(ordinaryRows).not.toContain('aria-label="删除任务"')
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"') const trashRows = app.slice(app.indexOf('class="trash-groups"'), ordinaryStart)
expect(ordinaryRows).toContain('restoreTask(node.task)') expect(trashRows).toContain('class="task-actions"')
expect(ordinaryRows).toContain('purgeTask(node.task)') expect(trashRows).toContain('restoreTask(node.task)')
expect(trashRows).toContain('purgeTask(node.task)')
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"')) const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
expect(overdue).toContain('selectTaskUnlessSwiped(node.task)') expect(overdue).toContain('selectTaskUnlessSwiped(node.task)')
expect(overdue).not.toContain('aria-label="打开任务详情"') expect(overdue).not.toContain('aria-label="打开任务详情"')
}) })
it('keeps the due tail as the final parent-row control and opens details from the task body', () => { it('keeps the due tail as the final parent-row control and opens details from the task body', () => {
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"') expect(app).toContain('@click="selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('<span v-if="node.task.due_at" class="task-tail">') expect(app).toContain('<span v-if="node.task.due_at" class="task-tail">')
expect(app).not.toContain('task-detail-trigger') expect(app).not.toContain('task-detail-trigger')
expect(css).not.toContain('.task-detail-trigger') expect(css).not.toContain('.task-detail-trigger')
@@ -798,7 +799,7 @@ describe('task and habit row decoration', () => {
it('shows task drag handles only in an explicit available reorder mode', () => { it('shows task drag handles only in an explicit available reorder mode', () => {
expect(app).toContain('const taskReorderMode = ref(false)') expect(app).toContain('const taskReorderMode = ref(false)')
expect(app).toContain("const taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1)") expect(app).toContain("const taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1)")
expect(app).toContain('class="soft-button reorder-mode-toggle task-reorder-toggle"') expect(app).toContain('class="list-section-action"')
expect(app).toContain("{{ taskReorderMode ? '完成' : '调整顺序' }}") expect(app).toContain("{{ taskReorderMode ? '完成' : '调整顺序' }}")
expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"') expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"')
expect(app).toContain('if (!taskReorderAvailable.value) taskReorderMode.value = false') expect(app).toContain('if (!taskReorderAvailable.value) taskReorderMode.value = false')
@@ -835,7 +836,7 @@ describe('task and habit row decoration', () => {
}) })
it('exposes complete titles on every ellipsized visible task title node', () => { it('exposes complete titles on every ellipsized visible task title node', () => {
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(2) expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(3)
expect(app).not.toContain('<strong :title="subtask.title">{{subtask.title}}</strong>') expect(app).not.toContain('<strong :title="subtask.title">{{subtask.title}}</strong>')
expect(app).not.toContain('<strong>{{node.task.title}}</strong>') expect(app).not.toContain('<strong>{{node.task.title}}</strong>')
}) })
@@ -901,7 +902,7 @@ describe('task and habit row decoration', () => {
expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"') expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"')
expect(app).not.toContain('collapsedTaskIds') expect(app).not.toContain('collapsedTaskIds')
expect(app).not.toContain('toggleTaskChildren') expect(app).not.toContain('toggleTaskChildren')
expect(app.match(/<span v-if="node\.subtasks\.length" class="meta">/g)).toHaveLength(2) expect(app.match(/<span v-if="node\.subtasks\.length" class="meta">/g)).toHaveLength(3)
expect(app).toContain('v-for="subtask in selectedTaskSubtasks"') expect(app).toContain('v-for="subtask in selectedTaskSubtasks"')
expect(app).toContain('class="subtask-detail"') expect(app).toContain('class="subtask-detail"')
}) })
@@ -1135,7 +1136,7 @@ describe('task and habit row decoration', () => {
expect(mvpPanel).toContain("emit('summary', value)") expect(mvpPanel).toContain("emit('summary', value)")
expect(app).toContain('taskComposeTitle') expect(app).toContain('taskComposeTitle')
expect(app).toContain('添加今天任务') expect(app).toContain('添加今天任务')
expect(app).toContain('v-if="activeView===\'trash\' || totalPages > 1 || totalTasks > 0"') expect(app).toContain('<span class="trash-page-count">共 {{totalTasks}} 项</span>')
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"') expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
expect(mvpPanel).toContain('添加习惯') expect(mvpPanel).toContain('添加习惯')
expect(css).toContain('.today-context{') expect(css).toContain('.today-context{')
+7 -7
View File
@@ -71,7 +71,7 @@ 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('uses approved Today-sourced page headers for task lists and Upcoming without changing Trash IA', () => { it('uses approved Today-sourced page headers and the grouped Trash layout', () => {
expect(app).toContain('v-if="activeView===\'tasks\' || activeView===\'upcoming\'" class="list-page-context"') expect(app).toContain('v-if="activeView===\'tasks\' || activeView===\'upcoming\'" class="list-page-context"')
expect(app).toContain('<h1 class="list-page-title" :title="activeName">{{ activeName }}</h1>') expect(app).toContain('<h1 class="list-page-title" :title="activeName">{{ activeName }}</h1>')
expect(app).toContain('<p class="list-page-summary">{{ taskOpenTotal === null ? \'待完成统计暂不可用\' : `还有 ${taskOpenTotal} 项待完成` }}</p>') expect(app).toContain('<p class="list-page-summary">{{ taskOpenTotal === null ? \'待完成统计暂不可用\' : `还有 ${taskOpenTotal} 项待完成` }}</p>')
@@ -105,7 +105,8 @@ describe('approved five-detail polish', () => {
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"") expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>')) const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>'))
expect(taskTopbar).not.toContain('CompletedFilterPill v-if="activeView!==\'today\'"') expect(taskTopbar).not.toContain('CompletedFilterPill v-if="activeView!==\'today\'"')
expect(app).toContain('v-if="activeView===\'trash\'" class="list-toolbar"') expect(app).toContain('v-if="activeView===\'trash\'" class="trash-page-context"')
expect(app).toContain('class="trash-groups"')
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}') expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}') expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}') expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}')
@@ -143,11 +144,10 @@ describe('approved five-detail polish', () => {
it('uses one 58px plain-list contract for active task and habit rows', () => { it('uses one 58px plain-list contract for active task and habit rows', () => {
expect(app).toContain("class=\"task-list plain-list\"") expect(app).toContain("class=\"task-list plain-list\"")
expect(app).toContain("'task-row--trash':activeView==='trash'") expect(app).toContain('class="task-row task-row--trash"')
expect(app).toContain(':role="activeView===\'trash\' ? undefined : \'button\'"') expect(app).toContain('class="task-main" role="button" tabindex="0"')
expect(app).toContain(':tabindex="activeView===\'trash\' ? undefined : 0"') expect(app).toContain('@click="selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"') expect(app).toContain('class="task-check" :aria-label="node.task.completed')
expect(app).toContain('v-if="activeView!==\'trash\'" class="task-check"')
expect(habits).toContain('class=\"habit-list plain-list\"') expect(habits).toContain('class=\"habit-list plain-list\"')
expect(habits).toContain('class=\"habit-row habit-row--full swipeable\"') 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{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
+20
View File
@@ -400,6 +400,7 @@ def test_task_subtasks_and_recycle_bin(client):
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204 assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
trash = client.get("/api/v1/trash").json()["items"] trash = client.get("/api/v1/trash").json()["items"]
assert len(trash) == 1 assert len(trash) == 1
assert trash[0]["subtasks"][0]["title"] == "比较价格"
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 200 assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 200
@@ -566,6 +567,25 @@ def test_trash_cursor_paginates_more_than_fifty_items(client):
assert second.json()["next_cursor"] is None assert second.json()["next_cursor"] is None
def test_trash_page_orders_deadline_groups_globally(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
rows = [
("无日期", None),
("未来", "2099-12-30T15:59:00Z"),
("过期", "2020-01-02T15:59:00Z"),
]
for title, due_at in rows:
payload = {"title": title, "list_id": inbox["id"]}
if due_at:
payload.update({"due_at": due_at, "due_has_time": False})
task = client.post("/api/v1/tasks", json=payload).json()
assert client.delete(f"/api/v1/tasks/{task['id']}").status_code == 204
page = client.get("/api/v1/trash", params={"page": 1, "page_size": 50}).json()
assert [item["title"] for item in page["items"]] == ["过期", "未来", "无日期"]
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client): def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
client = initialized_client(client) client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0] inbox = client.get("/api/v1/lists").json()[0]