diff --git a/CLAUDE.md b/CLAUDE.md index 5d644b1..3a7d516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,11 +87,11 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - Desktop packaging (Linux: AppImage + .deb) - Flutter GUI at full parity with Tauri (WebDAV UI, has_time, sync status, sync mode) - Tauri desktop-only deps (notify, keyring) feature-gated for Android compilation +- Subtask hierarchy (expand/collapse in task list, inline add in detail view, cascade toggle/delete) ### GUI features NOT yet done - Workspace retarget/migrate -- Subtask hierarchy (data model exists, not used anywhere) - Search/filter tasks - Desktop packaging for Windows and macOS diff --git a/PLAN.md b/PLAN.md index 2c3f343..437bd2a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -724,7 +724,7 @@ WorkspaceConfig { - [x] Sync status indicators (last-sync time + upload/download counts chip in TasksScreen) - [x] Push/pull sync mode selection (session-only sync direction selector in SettingsScreen) - [x] Group-by-due-date toggle per list (checkmark toggle in list kebab menu) -- [ ] Subtask hierarchy (data model exists, needs UI) +- [x] Subtask hierarchy (expand/collapse, inline add, cascade toggle/delete) - [ ] Search/filter tasks - [x] Desktop packaging (Linux: AppImage + .deb; Windows/macOS not yet verified) - [x] File watcher (notify crate, 500ms debounce, auto-reloads UI on external file changes) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 7cbe413..fcc8693 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -220,6 +220,7 @@ fn create_task( list_id: String, title: String, description: Option, + parent_id: Option, state: State<'_, Mutex>, ) -> Result { let mut s = state.lock().unwrap(); @@ -230,6 +231,10 @@ fn create_task( if let Some(desc) = description.filter(|d| !d.is_empty()) { task.description = desc; } + if let Some(pid) = parent_id { + let parent_uuid = Uuid::parse_str(&pid).map_err(|e| e.to_string())?; + task.parent_id = Some(parent_uuid); + } s.repo .as_mut() .unwrap() @@ -265,10 +270,18 @@ fn delete_task( mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; - s.repo - .as_mut() - .unwrap() - .delete_task(lid, tid) + let repo = s.repo.as_mut().unwrap(); + // Cascade-delete subtasks first + let all_tasks = repo.list_tasks(lid).map_err(|e| e.to_string())?; + let child_ids: Vec = all_tasks + .iter() + .filter(|t| t.parent_id == Some(tid)) + .map(|t| t.id) + .collect(); + for child_id in child_ids { + let _ = repo.delete_task(lid, child_id); + } + repo.delete_task(lid, tid) .map_err(|e| e.to_string()) } @@ -291,6 +304,17 @@ fn toggle_task( } repo.update_task(lid, task.clone()) .map_err(|e| e.to_string())?; + // Cascade: complete/uncomplete subtasks to match parent + let all_tasks = repo.list_tasks(lid).map_err(|e| e.to_string())?; + for mut child in all_tasks.into_iter().filter(|t| t.parent_id == Some(tid)) { + if child.status != task.status { + match task.status { + TaskStatus::Backlog => child.uncomplete(), + TaskStatus::Completed => child.complete(), + } + let _ = repo.update_task(lid, child); + } + } Ok(task) } diff --git a/apps/tauri/src/lib/components/TaskDetailView.svelte b/apps/tauri/src/lib/components/TaskDetailView.svelte index 506c8e6..d844c39 100644 --- a/apps/tauri/src/lib/components/TaskDetailView.svelte +++ b/apps/tauri/src/lib/components/TaskDetailView.svelte @@ -72,6 +72,16 @@ }); let isCompleted = $derived(task.status === "completed"); + let subtasks = $derived(app.getSubtasks(task.id)); + let addingSubtask = $state(false); + let subtaskTitle = $state(""); + + async function handleAddSubtask() { + if (!subtaskTitle.trim()) return; + await app.createTask(subtaskTitle.trim(), undefined, task.id); + subtaskTitle = ""; + addingSubtask = false; + } function formatDateChip(iso: string): string { const d = new Date(iso); @@ -169,6 +179,14 @@ {/if} + + {#if task.parent_id} + {@const parent = app.tasks.find(t => t.id === task.parent_id)} + {#if parent} +

Subtask of: {parent.title}

+ {/if} + {/if} + {/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} +
diff --git a/apps/tauri/src/lib/components/TaskItem.svelte b/apps/tauri/src/lib/components/TaskItem.svelte index 3a5ab5c..520a2c7 100644 --- a/apps/tauri/src/lib/components/TaskItem.svelte +++ b/apps/tauri/src/lib/components/TaskItem.svelte @@ -6,7 +6,10 @@ import type { Task } from "../types"; import { app } from "../stores/app.svelte"; - let { task, onopen }: { task: Task; onopen?: (task: Task) => void } = $props(); + let { task, onopen, depth = 0 }: { task: Task; onopen?: (task: Task) => void; depth?: number } = $props(); + + let subtasks = $derived(app.getSubtasks(task.id)); + let subtaskCount = $derived(subtasks.length); let touchStartX = $state(0); let swipeX = $state(0); @@ -104,8 +107,8 @@ +
+ +
+ + {:else} + + {/if} + + {#if expandedTasks.has(task.id)} + {#each app.getSubtasks(task.id) as subtask (subtask.id)} +
+ +
+ {/each} + {/if} {/each} {#if app.pendingTasks.length === 0} @@ -519,6 +545,11 @@
{#each app.completedTasks as task (task.id)} + {#each app.getSubtasks(task.id).filter(s => s.status === "completed") as subtask (subtask.id)} +
+ +
+ {/each} {/each}
{/if} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index f928efd..6b44dd8 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -31,8 +31,25 @@ let error = $state(null); // ── Derived ────────────────────────────────────────────────────────── let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null); -let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog")); -let completedTasks = $derived(tasks.filter((t) => t.status === "completed")); +let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id)); +let completedTasks = $derived(tasks.filter((t) => t.status === "completed" && !t.parent_id)); + +// Build a map of parent_id -> children for subtask hierarchy +let childrenMap = $derived.by(() => { + const map = new Map(); + for (const t of tasks) { + if (t.parent_id) { + const siblings = map.get(t.parent_id); + if (siblings) siblings.push(t); + else map.set(t.parent_id, [t]); + } + } + return map; +}); + +function getSubtasks(parentId: string): Task[] { + return childrenMap.get(parentId) ?? []; +} let hasWorkspace = $derived( config !== null && config.current_workspace !== null && @@ -151,13 +168,14 @@ async function deleteList(id: string) { } } -async function createTask(title: string, description?: string): Promise { +async function createTask(title: string, description?: string, parentId?: string): Promise { if (!activeListId) return null; try { const task = await invoke("create_task", { listId: activeListId, title, description: description ?? "", + parentId: parentId ?? null, }); tasks = [...tasks, task]; error = null; @@ -350,6 +368,7 @@ export const app = { get hasWorkspace() { return hasWorkspace; }, + getSubtasks, loadConfig, addWorkspace, switchWorkspace,