diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4c18962..7ccb148 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -49,6 +49,7 @@ const pageSize = 50 const totalTasks = ref(0) const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const expandedFolders = ref(new Set()) +const collapsedTaskIds = ref(new Set()) const navigationLoaded = ref(false) const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null) const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null) @@ -554,11 +555,18 @@ function cancelTaskSwipe(task?: Task) { taskSwipeStart.value = null if (task) taskSwipeOffsets.value[task.id] = 0 } -function selectTaskUnlessSwiped(task: Task) { +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) { suppressTaskClickId = '' return } + if (toggleChildren) toggleTaskChildren(task) selectTask(task) } async function saveTask() { @@ -725,13 +733,13 @@ onMounted(bootstrap)
-
{{node.task.title}}{{formatDue(node.task.due_at,node.task.due_has_time)}}{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}
+
{{node.task.title}}{{formatDue(node.task.due_at,node.task.due_has_time)}}{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}
{{['','低','中','高'][node.task.priority]}}
-
{{subtask.title}}
+
{{subtask.title}}
{{query?'没有匹配的任务':'这里还很安静'}}{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}
diff --git a/frontend/src/lib/task-utils.test.ts b/frontend/src/lib/task-utils.test.ts index e7e1ca9..1fb6580 100644 --- a/frontend/src/lib/task-utils.test.ts +++ b/frontend/src/lib/task-utils.test.ts @@ -8,6 +8,7 @@ type SearchTask = { parent_id?: string | null completed?: boolean list_name?: string + subtasks?: SearchTask[] } const tasks: SearchTask[] = [ @@ -26,6 +27,12 @@ describe('task utilities', () => { expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }]) }) + it('preserves subtasks already nested by the task API', () => { + const child: SearchTask = { id: 'nested-child', title: 'Nested child', parent_id: 'nested-parent' } + const parent: SearchTask = { id: 'nested-parent', title: 'Nested parent', parent_id: null, subtasks: [child] } + expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }]) + }) + it('moves an item before or after another item without changing other scopes', () => { const rows = [ { id: 'a', parent_id: null }, diff --git a/frontend/src/lib/task-utils.ts b/frontend/src/lib/task-utils.ts index e92dbdd..1bb51ae 100644 --- a/frontend/src/lib/task-utils.ts +++ b/frontend/src/lib/task-utils.ts @@ -5,6 +5,7 @@ export type MinimalTask = { parent_id?: string | null completed?: boolean list_name?: string + subtasks?: MinimalTask[] } const escapeHtml = (value: string) => @@ -85,7 +86,10 @@ export function groupTaskTree(tasks: T[]) { if (!task.parent_id) continue children.set(task.parent_id, [...(children.get(task.parent_id) ?? []), task]) } - return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] })) + return tasks.filter((task) => !task.parent_id).map((task) => ({ + task, + subtasks: children.get(task.id) ?? (task.subtasks as T[] | undefined) ?? [], + })) } export function moveItemWithinScope(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') { diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 5eb4b71..385bdf2 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -82,6 +82,14 @@ describe('task and habit row decoration', () => { expect(mvpPanel).toContain('shouldToggleRowSwipe') }) + it('collapses and expands subtasks when the parent task is clicked', () => { + expect(app).toContain('const collapsedTaskIds = ref(new Set())') + expect(app).toContain('function toggleTaskChildren(task: Task)') + expect(app).toContain('selectTaskUnlessSwiped(node.task, true)') + expect(app).toContain('v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks"') + expect(app).toContain(':aria-expanded="!collapsedTaskIds.has(node.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="subtask.completed')