feat: keep subtasks in task details
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m28s

This commit is contained in:
2026-09-11 16:09:04 +08:00
parent 5f2f38da3e
commit 5b2f599243
2 changed files with 41 additions and 66 deletions
+2 -17
View File
@@ -99,7 +99,6 @@ const todayHabitTotal = ref(0)
const todayHabitCompleted = ref(0) const todayHabitCompleted = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const expandedFolders = ref(new Set<string>()) const expandedFolders = ref(new Set<string>())
const collapsedTaskIds = ref(new Set<string>())
const justCompletedTaskIds = ref(new Set<string>()) const justCompletedTaskIds = ref(new Set<string>())
const completionExitingTaskIds = ref(new Set<string>()) const completionExitingTaskIds = ref(new Set<string>())
function setTaskCompletionExiting(id: string, active: boolean) { function setTaskCompletionExiting(id: string, active: boolean) {
@@ -398,7 +397,6 @@ async function loadRestoredView() {
if (activeView.value === 'trash') await loadTrash() if (activeView.value === 'trash') await loadTrash()
else if (isTaskView(activeView.value)) { else if (isTaskView(activeView.value)) {
await loadAll() await loadAll()
if (activeView.value === 'tasks' || activeView.value === 'upcoming') collapseLoadedTaskChildren()
} }
else { tasks.value = []; totalTasks.value = 0 } else { tasks.value = []; totalTasks.value = 0 }
} }
@@ -611,7 +609,6 @@ async function switchView(view: View, listId?: string) {
else if (!isTaskView(view)) tasks.value = [] else if (!isTaskView(view)) tasks.value = []
else { else {
await loadAll() await loadAll()
if (view === 'tasks' || view === 'upcoming') collapseLoadedTaskChildren()
} }
} }
async function loadTodayView() { async function loadTodayView() {
@@ -814,21 +811,11 @@ function cancelTaskSwipe(task?: Task) {
taskSwipeStart.value = null taskSwipeStart.value = null
if (task) taskSwipeOffsets.value[task.id] = 0 if (task) taskSwipeOffsets.value[task.id] = 0
} }
function collapseLoadedTaskChildren() { function selectTaskUnlessSwiped(task: Task) {
collapsedTaskIds.value = new Set(tasks.value.filter((task) => task.subtasks?.length).map((task) => task.id))
}
function toggleTaskChildren(task: Task) {
if (!task.subtasks?.length) return
const next = new Set(collapsedTaskIds.value)
next.has(task.id) ? next.delete(task.id) : next.add(task.id)
collapsedTaskIds.value = next
}
function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
if (suppressTaskClickId === task.id) { if (suppressTaskClickId === task.id) {
suppressTaskClickId = '' suppressTaskClickId = ''
return return
} }
if (toggleChildren) toggleTaskChildren(task)
selectTask(task) selectTask(task)
} }
async function refreshTodayAfterTaskSave() { async function refreshTodayAfterTaskSave() {
@@ -1376,7 +1363,6 @@ onUnmounted(() => {
<div class="task-list overdue-list"> <div class="task-list overdue-list">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`"> <template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article :data-task-id="node.task.id" class="task-row overdue-task" :class="{'completion-exiting':completionExitingTaskIds.has(node.task.id)}"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article> <article :data-task-id="node.task.id" class="task-row overdue-task" :class="{'completion-exiting':completionExitingTaskIds.has(node.task.id)}"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
<article v-for="subtask in node.subtasks" :key="`overdue-subtask-${subtask.id}`" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),'completion-exiting':completionExitingTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></article>
</template> </template>
</div> </div>
</section> </section>
@@ -1389,10 +1375,9 @@ onUnmounted(() => {
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"> <article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'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="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button> <button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button> <button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div> <div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span> <span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>
</article> </article>
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),'completion-exiting':completionExitingTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button v-if="activeView!=='trash'" class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView!=='trash'" class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(subtask)"><Ellipsis/></button></span></article>
</template> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
</section> </section>
+39 -49
View File
@@ -11,38 +11,31 @@ const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue
const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8') const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8')
describe('unified task due display', () => { describe('unified task due display', () => {
it('uses the shared display for overdue, ordinary parent, and child 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(4) expect(app.match(/<TaskDueDisplay/g)).toHaveLength(2)
expect(app).toContain(':due-at="node.task.due_at"') expect(app).toContain(':due-at="node.task.due_at"')
expect(app).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(')
expect(app).not.toContain('overdue-badge') expect(app).not.toContain('overdue-badge')
expect(taskDueDisplay).not.toMatch(/setInterval|setTimeout/) expect(taskDueDisplay).not.toMatch(/setInterval|setTimeout/)
}) })
it('keeps overdue parent subtasks interactive without duplicating ordinary rows', () => { it('keeps overdue subtasks out of the list while preserving their parent progress summary', () => {
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"')) const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
expect(overdue).toContain('v-for="subtask in node.subtasks"') expect(overdue).not.toContain('v-for="subtask in node.subtasks"')
expect(overdue).toContain('@click.stop="toggle(subtask)"') expect(overdue).not.toContain('selectTaskUnlessSwiped(subtask)')
expect(overdue).toContain('@click="selectTaskUnlessSwiped(subtask)"') expect(overdue).toContain('node.subtasks.filter(t=>t.completed).length')
expect(overdue).not.toContain('class="drag-handle task-drag-handle"') expect(overdue).toContain('{{node.subtasks.length}}')
expect(overdue).not.toContain('startTaskReorder(subtask, $event)')
expect(overdue.match(/v-for="subtask in node.subtasks"/g)).toHaveLength(1)
expect(overdue).not.toContain('swipe-bg')
}) })
it('places every 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(4) expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(2)
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><span v-if="activeView===\'trash\'" class="task-actions">')
const ordinaryChildren = app.slice(app.indexOf('<article v-if="!collapsedTaskIds.has(node.task.id)"'), app.indexOf('</template>', app.indexOf('<article v-if="!collapsedTaskIds.has(node.task.id)"'))) expect(app).not.toContain('restoreTask(subtask)')
expect(ordinaryChildren).toContain('v-if="activeView!==\'trash\'" class="drag-handle task-drag-handle"') expect(app).not.toContain('purgeTask(subtask)')
expect(ordinaryChildren).toContain('v-if="activeView!==\'trash\'" class="task-check"')
expect(ordinaryChildren).not.toContain('restoreTask(subtask)')
expect(ordinaryChildren).not.toContain('purgeTask(subtask)')
expect(ordinaryChildren).not.toContain('v-if="activeView===\'trash\'" class="task-actions"')
expect(app).toContain('<span v-else class="task-actions"><button class="icon ghost task-detail-trigger"') expect(app).toContain('<span v-else class="task-actions"><button class="icon ghost task-detail-trigger"')
expect(app).toContain('<span v-if="node.task.priority" class="priority"') expect(app).toContain('<span v-if="node.task.priority" class="priority"')
expect(app).toContain('v-if="node.subtasks.length" class="meta"') expect(app).toContain('v-if="node.subtasks.length" class="meta"')
@@ -215,11 +208,11 @@ describe('solid cream material system', () => {
}) })
describe('approved UI detail direction', () => { describe('approved UI detail direction', () => {
it('uses one More detail entry for ordinary parent and child rows while preserving Trash and overdue actions', () => { it('uses one More detail entry for ordinary parent rows while preserving Trash and overdue actions', () => {
const ordinaryRows = app.slice(app.indexOf('<section class="task-list"'), app.indexOf('</section>', app.indexOf('<section class="task-list"'))) const ordinaryRows = app.slice(app.indexOf('<section class="task-list"'), app.indexOf('</section>', app.indexOf('<section class="task-list"')))
expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(2) expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(1)
expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>') expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>')
expect(ordinaryRows).toContain('@click.stop="selectTask(subtask)"><Ellipsis/>') 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"') expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
expect(ordinaryRows).toContain('restoreTask(node.task)') expect(ordinaryRows).toContain('restoreTask(node.task)')
@@ -228,9 +221,9 @@ describe('approved UI detail direction', () => {
expect(overdue).not.toContain('aria-label="打开任务详情"') expect(overdue).not.toContain('aria-label="打开任务详情"')
}) })
it('keeps the due tail immediately before the More detail entry', () => { it('keeps the parent due tail immediately before the More detail entry', () => {
expect(app).toContain('</span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>') expect(app).toContain('</span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>')
expect(app).toContain('</span><span v-if="activeView!==\'trash\'" class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(subtask)"><Ellipsis/></button></span>') expect(app).not.toContain('@click.stop="selectTask(subtask)"><Ellipsis/>')
expect(css).toContain('.task-detail-trigger:hover{color:var(--text-primary);background:#fff7eb}') expect(css).toContain('.task-detail-trigger:hover{color:var(--text-primary);background:#fff7eb}')
}) })
@@ -503,8 +496,8 @@ describe('task and habit row decoration', () => {
expect(mvpPanel).not.toContain('RefreshCw,') expect(mvpPanel).not.toContain('RefreshCw,')
}) })
it('supports vertical drag handles for reordering tasks and habits', () => { it('supports vertical drag handles for reordering visible parent tasks and habits', () => {
expect(app.match(/class="drag-handle task-drag-handle"/g)?.length).toBe(2) expect(app.match(/class="drag-handle task-drag-handle"/g)?.length).toBe(1)
expect(app).toContain('@pointerdown.stop="startTaskReorder') expect(app).toContain('@pointerdown.stop="startTaskReorder')
expect(app).toContain('@pointerup.stop="finishTaskReorder') expect(app).toContain('@pointerup.stop="finishTaskReorder')
expect(app).toContain("api('/tasks/reorder'") expect(app).toContain("api('/tasks/reorder'")
@@ -520,11 +513,10 @@ describe('task and habit row decoration', () => {
expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1) expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1)
}) })
it('exposes complete task titles on every ellipsized parent and child 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(2)
expect(app.match(/<strong :title="subtask\.title">\{\{subtask\.title\}\}<\/strong>/g)).toHaveLength(2) 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>')
expect(app).not.toContain('<strong>{{subtask.title}}</strong>')
}) })
it('keeps the Trash restore action at least 44px tall', () => { it('keeps the Trash restore action at least 44px tall', () => {
@@ -549,11 +541,11 @@ describe('task and habit row decoration', () => {
expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`") expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`")
}) })
it('supports pointer dragging for desktop task and subtask completion', () => { it('supports pointer dragging for desktop visible task completion', () => {
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(3) expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(1)
expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(3) expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(1)
expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(3) expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(1)
expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(3) expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(1)
}) })
it('supports pointer dragging for desktop habit rows in Today and Habits views', () => { it('supports pointer dragging for desktop habit rows in Today and Habits views', () => {
@@ -579,27 +571,25 @@ describe('task and habit row decoration', () => {
expect(mvpPanel).toContain('shouldToggleRowSwipe') expect(mvpPanel).toContain('shouldToggleRowSwipe')
}) })
it('collapses and expands subtasks when the parent task is clicked', () => { it('shows subtasks only in task details while keeping the parent progress summary', () => {
expect(app).toContain('const collapsedTaskIds = ref(new Set<string>())') const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
expect(app).toContain('function toggleTaskChildren(task: Task)') const ordinaryRows = app.slice(app.indexOf('<section class="task-list"'), app.indexOf('</section>', app.indexOf('<section class="task-list"')))
expect(app).toContain('selectTaskUnlessSwiped(node.task, true)') expect(overdue).not.toContain('v-for="subtask in node.subtasks"')
expect(app).toContain('v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks"') expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"')
expect(app).toContain(':aria-expanded="!collapsedTaskIds.has(node.task.id)"') expect(app).not.toContain('collapsedTaskIds')
expect(app).not.toContain('toggleTaskChildren')
expect(app.match(/<span v-if="node\.subtasks\.length" class="meta">/g)).toHaveLength(2)
expect(app).toContain('v-for="subtask in selectedTaskSubtasks"')
expect(app).toContain('class="subtask-detail"')
}) })
it('starts parent tasks collapsed when entering a task list or upcoming view', () => { it('shows visible completion buttons for parent tasks while keeping subtask completion in details', () => {
expect(app).toContain('function collapseLoadedTaskChildren()')
expect(app).toContain("else if (isTaskView(activeView.value)) {\n await loadAll()\n if (activeView.value === 'tasks' || activeView.value === 'upcoming') collapseLoadedTaskChildren()")
expect(app).toContain("else {\n await loadAll()\n if (view === 'tasks' || view === 'upcoming') collapseLoadedTaskChildren()")
expect(app).toContain('tasks.value.filter((task) => task.subtasks?.length).map((task) => task.id)')
})
it('shows visible completion buttons for tasks and subtasks while keeping swipe shortcuts', () => {
expect(app).not.toContain('class="sr-only" :aria-label="node.task.completed') expect(app).not.toContain('class="sr-only" :aria-label="node.task.completed')
expect(app).not.toContain('class="sr-only" :aria-label="subtask.completed') expect(app).not.toContain('class="sr-only" :aria-label="subtask.completed')
expect(app).toContain('class="task-check"') expect(app).toContain('class="task-check"')
expect(app.match(/class="task-check"/g)?.length).toBe(4) expect(app.match(/class="task-check"/g)?.length).toBe(2)
expect(app).toContain('class="task-check-mark"') expect(app).toContain('class="task-check-mark"')
expect(app).toContain('class="subtask-detail" @click="toggle(subtask)"')
expect(css).toContain('.task-check{') expect(css).toContain('.task-check{')
expect(css).toContain('.task-check-mark{') expect(css).toContain('.task-check-mark{')
expect(css).toContain('.task-check:focus-visible{') expect(css).toContain('.task-check:focus-visible{')