[verified] redesign trash with deadline groups
This commit is contained in:
+22
-16
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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="刷新当前页面"')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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{')
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user