From 2f2327e4cab21e6e89740414b12c798db8dbad69 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Thu, 2 Apr 2026 07:22:41 -0700 Subject: [PATCH] feat: overhaul subtask UX with three-panel navigation, custom confirmations, and main panel header redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove subtask expand/collapse foldout from main task list - Add three-panel slide navigation (task list → task detail → subtask detail) via taskStack - Create ConfirmDialog component replacing native confirm() dialogs - Cascade delete: deleting a task with subtasks warns and deletes all children - Subtask detail view: no nested subtasks, collapsible completed section, inline add at top - Redesign Move To as inline list in kebab menu (no overflow submenu) - Main panel header: list name + kebab below divider (matching task detail layout) - Move list management kebab from drawer to main panel; drawer shows chevrons - Add Delete Completed to both main panel and subtask kebab menus - Fix debouncedSave stale reference by snapshotting task before timer - New subtasks prepend to top of list --- .../src/lib/components/ConfirmDialog.svelte | 37 ++ .../src/lib/components/TaskDetailView.svelte | 295 +++++++++++----- apps/tauri/src/lib/screens/TasksScreen.svelte | 328 ++++++++++-------- apps/tauri/src/lib/stores/app.svelte.ts | 2 +- 4 files changed, 430 insertions(+), 232 deletions(-) create mode 100644 apps/tauri/src/lib/components/ConfirmDialog.svelte diff --git a/apps/tauri/src/lib/components/ConfirmDialog.svelte b/apps/tauri/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..a699791 --- /dev/null +++ b/apps/tauri/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,37 @@ + + + +
{ if (e.key === "Escape") { e.stopPropagation(); oncancel(); } }} +> +
+ +
e.stopPropagation()} + > +

{message}

+ {#if detail} +

{detail}

+ {/if} +
+ + +
+
+
diff --git a/apps/tauri/src/lib/components/TaskDetailView.svelte b/apps/tauri/src/lib/components/TaskDetailView.svelte index d844c39..824b9ab 100644 --- a/apps/tauri/src/lib/components/TaskDetailView.svelte +++ b/apps/tauri/src/lib/components/TaskDetailView.svelte @@ -2,6 +2,7 @@ import type { Task } from "../types"; import { app } from "../stores/app.svelte"; import DateTimePicker from "./DateTimePicker.svelte"; + import ConfirmDialog from "./ConfirmDialog.svelte"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { platform } from "@tauri-apps/plugin-os"; @@ -9,15 +10,19 @@ const currentPlatform = platform(); const isDesktop = currentPlatform === "linux" || currentPlatform === "windows"; - let { task, onback }: { task: Task; onback: () => void } = $props(); + let { task, onback, onopen }: { task: Task; onback: () => void; onopen?: (task: Task) => void } = $props(); let title = $state(task.title); let description = $state(task.description); let showMenu = $state(false); - let showMoveSubmenu = $state(false); let menuEl = $state(null); let showDatePicker = $state(false); let saveTimer: ReturnType; + let confirmDelete = $state(false); + + $effect(() => { + return () => clearTimeout(saveTimer); + }); let otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId)); @@ -29,8 +34,9 @@ function debouncedSave(fields: Partial) { clearTimeout(saveTimer); + var snapshot = { ...task }; saveTimer = setTimeout(() => { - app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() }); + app.updateTask({ ...snapshot, ...fields, updated_at: new Date().toISOString() }); }, 400); } @@ -51,17 +57,22 @@ onback(); } - async function handleDelete() { + function promptDelete() { showMenu = false; - if (!confirm(`Delete task "${task.title}"?`)) return; + confirmDelete = true; + } + + async function executeDelete() { + confirmDelete = false; + // Cascade: delete subtasks first + for (const s of subtasks) await app.deleteTask(s.id); await app.deleteTask(task.id); onback(); } function handleMenuClickOutside(e: MouseEvent) { - if (showMenu && menuEl && !menuEl.contains(e.target as Node)) { + if (showMenu && menuEl && !menuEl.contains(e.target as Node)) showMenu = false; - } } $effect(() => { @@ -72,17 +83,42 @@ }); let isCompleted = $derived(task.status === "completed"); + let isSubtask = $derived(!!task.parent_id); let subtasks = $derived(app.getSubtasks(task.id)); + let pendingSubtasks = $derived(subtasks.filter(s => s.status !== "completed")); + let completedSubtasks = $derived(subtasks.filter(s => s.status === "completed")); let addingSubtask = $state(false); let subtaskTitle = $state(""); + let showSubtaskMenu = $state(false); + let subtaskMenuEl = $state(null); + let showCompletedSubtasks = $state(false); + let completedSubtasksVisible = $state(false); + let confirmDeleteCompleted = $state(false); async function handleAddSubtask() { if (!subtaskTitle.trim()) return; await app.createTask(subtaskTitle.trim(), undefined, task.id); subtaskTitle = ""; - addingSubtask = false; } + async function executeDeleteCompletedSubtasks() { + confirmDeleteCompleted = false; + showSubtaskMenu = false; + for (const s of completedSubtasks) await app.deleteTask(s.id); + } + + function handleSubtaskMenuClickOutside(e: MouseEvent) { + if (showSubtaskMenu && subtaskMenuEl && !subtaskMenuEl.contains(e.target as Node)) + showSubtaskMenu = false; + } + + $effect(() => { + if (showSubtaskMenu) { + window.addEventListener("mousedown", handleSubtaskMenuClickOutside); + return () => window.removeEventListener("mousedown", handleSubtaskMenuClickOutside); + } + }); + function formatDateChip(iso: string): string { const d = new Date(iso); const today = new Date(); @@ -139,36 +175,8 @@ {isCompleted ? "Restore task" : "Mark as completed"} - {#if otherLists.length > 0} -
- - {#if showMoveSubmenu} -
- {#each otherLists as list} - - {/each} -
- {/if} -
- {/if} + {#if otherLists.length > 0} +
+

Move to...

+ {#each otherLists as list} + + {/each} + {/if} {/if} @@ -236,58 +259,131 @@ {/if} - -
-
- - - - Subtasks{subtasks.length > 0 ? ` (${subtasks.filter(s => s.status === "completed").length}/${subtasks.length})` : ""} -
- {#each subtasks as subtask (subtask.id)} - - {/each} - {#if addingSubtask} -
-
- { if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { addingSubtask = false; subtaskTitle = ""; } }} - autofocus - /> -
- {:else} - - {/if} -
+ Subtasks{subtasks.length > 0 ? ` (${completedSubtasks.length}/${subtasks.length})` : ""} + + {#if completedSubtasks.length > 0} +
+ + {#if showSubtaskMenu} +
+ +
+ {/if} +
+ {/if} + + + + {#if addingSubtask} +
+
+ { if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { e.stopPropagation(); addingSubtask = false; subtaskTitle = ""; } }} + onblur={async () => { if (subtaskTitle.trim()) { await handleAddSubtask(); addingSubtask = false; } else { addingSubtask = false; subtaskTitle = ""; } }} + autofocus + /> +
+ {:else} + + {/if} + + + {#each pendingSubtasks as subtask (subtask.id)} + + {/each} + + + {#if completedSubtasks.length > 0} + + {#if completedSubtasksVisible} +
+ {#each completedSubtasks as subtask (subtask.id)} + + {/each} +
+ {/if} + {/if} + + {/if} @@ -299,3 +395,26 @@ onclose={() => (showDatePicker = false)} /> {/if} + + +{#if confirmDelete} + 0 ? `This will also delete ${subtasks.length} subtask${subtasks.length === 1 ? '' : 's'}.` : undefined} + confirmText="Delete" + danger + onconfirm={executeDelete} + oncancel={() => (confirmDelete = false)} + /> +{/if} + + +{#if confirmDeleteCompleted} + (confirmDeleteCompleted = false)} + /> +{/if} diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index fc97d8f..3e09e5d 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -3,6 +3,7 @@ import TaskItem from "../components/TaskItem.svelte"; import TaskDetailView from "../components/TaskDetailView.svelte"; import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte"; + import ConfirmDialog from "../components/ConfirmDialog.svelte"; import SettingsScreen from "./SettingsScreen.svelte"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { platform } from "@tauri-apps/plugin-os"; @@ -13,15 +14,23 @@ const isDesktop = currentPlatform === "linux" || currentPlatform === "windows"; const isWindows = currentPlatform === "windows"; - let selectedTaskId = $state(null); - let selectedTask = $derived(selectedTaskId ? app.tasks.find(t => t.id === selectedTaskId) ?? null : null); + let taskStack = $state([]); + let parentTask = $derived(taskStack.length >= 1 ? app.tasks.find(t => t.id === taskStack[0]) ?? null : null); + let subtaskDetail = $derived(taskStack.length >= 2 ? app.tasks.find(t => t.id === taskStack[1]) ?? null : null); function openTask(task: Task) { - selectedTaskId = task.id; + taskStack = [task.id]; + } + + function pushTask(task: Task) { + taskStack = [...taskStack, task.id]; } function closeDetail() { - selectedTaskId = null; + if (taskStack.length > 1) + taskStack = taskStack.slice(0, -1); + else + taskStack = []; } let showDrawer = $state(false); @@ -31,22 +40,24 @@ let workspacePickerEl = $state(null); function handleWindowClick(e: MouseEvent) { - if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node)) { + if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node)) showWorkspacePicker = false; - } - const target = e.target as HTMLElement; - if (listMenuId && !target.closest("[data-list-menu]")) listMenuId = null; + if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node)) + showListMenu = false; + var target = e.target as HTMLElement; if (wsMenuName && !target.closest("[data-ws-menu]")) wsMenuName = null; } let newListName = $state(""); let showCompleted = $state(false); let completedVisible = $state(false); - let listMenuId = $state(null); let wsMenuName = $state(null); let renamingListId = $state(null); let renameValue = $state(""); - let expandedTasks = $state(new Set()); + let showListMenu = $state(false); + let listMenuEl = $state(null); + let confirmDeleteList = $state(false); + let confirmDeleteCompleted = $state(false); let dragId = $state(null); let dragOverId = $state(null); let resizing = $state(false); @@ -73,43 +84,57 @@ showNewList = false; } - async function handleDeleteList(id: string) { - listMenuId = null; - const list = app.lists.find(l => l.id === id); - if (!confirm(`Delete list "${list?.title ?? id}" and all its tasks?`)) return; - await app.deleteList(id); + function promptDeleteCompleted() { + showListMenu = false; + confirmDeleteCompleted = true; } - function startRenameList(id: string) { - listMenuId = null; - const list = app.lists.find(l => l.id === id); + async function executeDeleteCompleted() { + confirmDeleteCompleted = false; + for (var t of app.completedTasks) await app.deleteTask(t.id); + } + + function promptDeleteList() { + showListMenu = false; + confirmDeleteList = true; + } + + async function executeDeleteList() { + confirmDeleteList = false; + if (app.activeListId) await app.deleteList(app.activeListId); + } + + function startRenameList() { + showListMenu = false; + if (!app.activeListId) return; + var list = app.lists.find(l => l.id === app.activeListId); if (!list) return; - renamingListId = id; + renamingListId = app.activeListId; renameValue = list.title; } async function handleRenameList() { if (!renamingListId || !renameValue.trim()) { renamingListId = null; return; } - const list = app.lists.find(l => l.id === renamingListId); - if (renameValue.trim() !== list?.title) { + var list = app.lists.find(l => l.id === renamingListId); + if (renameValue.trim() !== list?.title) await app.renameList(renamingListId, renameValue.trim()); - } renamingListId = null; } - async function handleToggleGroupByDueDate(id: string) { - listMenuId = null; - const list = app.lists.find(l => l.id === id); + async function handleToggleGroupByDueDate() { + showListMenu = false; + if (!app.activeListId) return; + var list = app.lists.find(l => l.id === app.activeListId); if (!list) return; - await app.setGroupByDueDate(id, !list.group_by_due_date); + await app.setGroupByDueDate(app.activeListId, !list.group_by_due_date); } function handleKeydown(e: KeyboardEvent) { if (e.key !== "Escape") return; if (showSettings) { showSettings = false; return; } - if (selectedTaskId) { selectedTaskId = null; return; } + if (taskStack.length > 0) { closeDetail(); return; } + if (showListMenu) { showListMenu = false; return; } if (showDrawer) { closeDrawer(); return; } - if (listMenuId) { listMenuId = null; return; } if (wsMenuName) { wsMenuName = null; return; } if (showWorkspacePicker) { showWorkspacePicker = false; return; } } @@ -164,7 +189,6 @@ function closeDrawer() { showDrawer = false; showNewList = false; - listMenuId = null; } function openSettings() { @@ -276,78 +300,20 @@
{#each app.lists as list (list.id)} -
- {#if renamingListId === list.id} -
- { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }} - onblur={handleRenameList} - autofocus - /> -
- {:else} - + - {#if listMenuId === list.id} -
- - - -
- {/if} -
-
+ {list.title} + + + + {/each} @@ -410,10 +376,10 @@
-
+
- -
-

{app.activeList?.title ?? "Tasks"}

-
+
{#if isDesktop} -
+
{#if isWindows}
+ +
+ {#if app.activeListId} + +
+ + {#if showListMenu} +
+ + + {#if app.completedTasks.length > 0} + + {/if} + +
+ {/if} +
+ {/if} + {#if renamingListId === app.activeListId} + { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }} + onblur={handleRenameList} + autofocus + /> + {:else} +

{app.activeList?.title ?? "Tasks"}

+ {/if} +
+
{#if app.lists.length === 0} @@ -482,33 +521,8 @@ ondrop={(e) => handleDrop(e, task.id)} class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}" > - {#if app.getSubtasks(task.id).length > 0} - -
- -
- -
-
- {:else} - - {/if} +
- - {#if expandedTasks.has(task.id)} - {#each app.getSubtasks(task.id) as subtask (subtask.id)} -
- -
- {/each} - {/if} {/each} {#if app.pendingTasks.length === 0} @@ -545,11 +559,6 @@
{#each app.completedTasks as task (task.id)} - {#each app.getSubtasks(task.id).filter(s => s.status === "completed") as subtask (subtask.id)} -
- -
- {/each} {/each}
{/if} @@ -559,7 +568,7 @@
-
- {#if selectedTask} - +
+ {#if parentTask} + {#key parentTask.id} + + {/key} + {/if} +
+ + +
+ {#if subtaskDetail} + {#key subtaskDetail.id} + + {/key} {/if}
@@ -619,3 +639,25 @@
+ + +{#if confirmDeleteList} + (confirmDeleteList = false)} + /> +{/if} + + +{#if confirmDeleteCompleted} + (confirmDeleteCompleted = false)} + /> +{/if} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index 6b44dd8..9b35858 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -177,7 +177,7 @@ async function createTask(title: string, description?: string, parentId?: string description: description ?? "", parentId: parentId ?? null, }); - tasks = [...tasks, task]; + tasks = parentId ? [task, ...tasks] : [...tasks, task]; error = null; return task; } catch (e) {