feat: add subtask hierarchy UI to Tauri GUI

Wire up the existing parent_id data model into a full subtask UI:
- Backend: create_task accepts parent_id, cascade delete/toggle for subtasks
- Store: subtask-aware derived state (root tasks only in pending/completed),
  childrenMap for O(1) subtask lookup, createTask accepts parentId
- TaskItem: depth-based indentation, subtask completion counter badge
- TasksScreen: expand/collapse chevron for parent tasks, indented subtask
  rendering with left border, subtasks shown under completed parents
- TaskDetailView: "Add subtask" inline input, subtask list with toggleable
  checkboxes, parent task breadcrumb for subtasks

https://claude.ai/code/session_01XWcSekaCAJ7qnr4hN3tgL8
This commit is contained in:
Claude 2026-04-02 12:14:56 +00:00
parent aa61f85d5f
commit 45b7cc57de
No known key found for this signature in database
5 changed files with 167 additions and 11 deletions

View file

@ -220,6 +220,7 @@ fn create_task(
list_id: String, list_id: String,
title: String, title: String,
description: Option<String>, description: Option<String>,
parent_id: Option<String>,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<Task, String> { ) -> Result<Task, String> {
let mut s = state.lock().unwrap(); let mut s = state.lock().unwrap();
@ -230,6 +231,10 @@ fn create_task(
if let Some(desc) = description.filter(|d| !d.is_empty()) { if let Some(desc) = description.filter(|d| !d.is_empty()) {
task.description = desc; 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 s.repo
.as_mut() .as_mut()
.unwrap() .unwrap()
@ -265,10 +270,18 @@ fn delete_task(
mute_watcher(&mut s); mute_watcher(&mut s);
let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; 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())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?;
s.repo let repo = s.repo.as_mut().unwrap();
.as_mut() // Cascade-delete subtasks first
.unwrap() let all_tasks = repo.list_tasks(lid).map_err(|e| e.to_string())?;
.delete_task(lid, tid) let child_ids: Vec<Uuid> = 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()) .map_err(|e| e.to_string())
} }
@ -291,6 +304,17 @@ fn toggle_task(
} }
repo.update_task(lid, task.clone()) repo.update_task(lid, task.clone())
.map_err(|e| e.to_string())?; .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) Ok(task)
} }

View file

@ -72,6 +72,16 @@
}); });
let isCompleted = $derived(task.status === "completed"); 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 { function formatDateChip(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
@ -169,6 +179,14 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- Parent task indicator -->
{#if task.parent_id}
{@const parent = app.tasks.find(t => t.id === task.parent_id)}
{#if parent}
<p class="mb-2 text-xs opacity-40">Subtask of: {parent.title}</p>
{/if}
{/if}
<!-- Title --> <!-- Title -->
<input <input
type="text" type="text"
@ -217,6 +235,59 @@
</button> </button>
{/if} {/if}
</div> </div>
<!-- Subtasks section -->
<div class="mt-6">
<div class="flex items-center gap-2 mb-2">
<svg class="h-5 w-5 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm2 4a1 1 0 011-1h10a1 1 0 110 2H6a1 1 0 01-1-1zm2 4a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1z" />
</svg>
<span class="text-sm font-medium opacity-60">Subtasks{subtasks.length > 0 ? ` (${subtasks.filter(s => s.status === "completed").length}/${subtasks.length})` : ""}</span>
</div>
{#each subtasks as subtask (subtask.id)}
<button
onclick={() => onback()}
class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
>
<!-- Checkbox -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={(e) => { e.stopPropagation(); app.toggleTask(subtask.id); }}
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 {subtask.status === 'completed' ? 'border-primary bg-primary' : 'border-gray-400 dark:border-gray-500'}"
>
{#if subtask.status === "completed"}
<svg class="h-2.5 w-2.5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{/if}
</div>
<span class="text-sm {subtask.status === 'completed' ? 'line-through opacity-50' : ''}">{subtask.title}</span>
</button>
{/each}
{#if addingSubtask}
<div class="flex items-center gap-2 px-2 py-1">
<div class="h-4 w-4 shrink-0 rounded-full border-2 border-gray-400 dark:border-gray-500"></div>
<input
type="text"
bind:value={subtaskTitle}
placeholder="Subtask title"
class="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
onkeydown={(e) => { if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { addingSubtask = false; subtaskTitle = ""; } }}
autofocus
/>
</div>
{:else}
<button
onclick={() => (addingSubtask = true)}
class="flex w-full items-center gap-2 px-2 py-2 text-sm text-primary opacity-60 hover:opacity-100"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
</svg>
Add subtask
</button>
{/if}
</div>
</main> </main>
<!-- Date picker overlay --> <!-- Date picker overlay -->

View file

@ -6,7 +6,10 @@
import type { Task } from "../types"; import type { Task } from "../types";
import { app } from "../stores/app.svelte"; 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 touchStartX = $state(0);
let swipeX = $state(0); let swipeX = $state(0);
@ -104,8 +107,8 @@
<!-- Task content --> <!-- Task content -->
<button <button
class="group flex w-full items-start gap-3 bg-surface-light px-4 py-3 text-left hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5" class="group flex w-full items-start gap-3 bg-surface-light py-3 pr-4 text-left hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5"
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}" style="padding-left: {1 + depth * 1.5}rem; transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
onclick={() => onopen?.(task)} onclick={() => onopen?.(task)}
> >
<!-- Checkbox --> <!-- Checkbox -->
@ -143,6 +146,14 @@
{formatDate(task.due_date)} {formatDate(task.due_date)}
</span> </span>
{/if} {/if}
{#if subtaskCount > 0}
<span class="mt-1 inline-flex items-center gap-1 text-xs opacity-40">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm2 4a1 1 0 011-1h10a1 1 0 110 2H6a1 1 0 01-1-1zm2 4a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1z" />
</svg>
{subtasks.filter(s => s.status === "completed").length}/{subtaskCount}
</span>
{/if}
</div> </div>
<!-- Chevron --> <!-- Chevron -->

View file

@ -46,6 +46,7 @@
let wsMenuName = $state<string | null>(null); let wsMenuName = $state<string | null>(null);
let renamingListId = $state<string | null>(null); let renamingListId = $state<string | null>(null);
let renameValue = $state(""); let renameValue = $state("");
let expandedTasks = $state(new Set<string>());
let dragId = $state<string | null>(null); let dragId = $state<string | null>(null);
let dragOverId = $state<string | null>(null); let dragOverId = $state<string | null>(null);
let resizing = $state(false); let resizing = $state(false);
@ -481,8 +482,33 @@
ondrop={(e) => handleDrop(e, task.id)} ondrop={(e) => handleDrop(e, task.id)}
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}" 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}
<!-- Parent with subtasks: show expand/collapse toggle -->
<div class="flex items-center">
<button
onclick={(e) => { e.stopPropagation(); expandedTasks.has(task.id) ? expandedTasks.delete(task.id) : expandedTasks.add(task.id); expandedTasks = new Set(expandedTasks); }}
class="flex h-full shrink-0 items-center pl-2 pr-0 py-3 opacity-40 hover:opacity-70"
>
<svg class="h-3.5 w-3.5 transition-transform {expandedTasks.has(task.id) ? 'rotate-90' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
</button>
<div class="min-w-0 flex-1">
<TaskItem {task} onopen={openTask} /> <TaskItem {task} onopen={openTask} />
</div> </div>
</div>
{:else}
<TaskItem {task} onopen={openTask} />
{/if}
</div>
<!-- Render subtasks if expanded -->
{#if expandedTasks.has(task.id)}
{#each app.getSubtasks(task.id) as subtask (subtask.id)}
<div class="border-l-2 border-border-light ml-4 dark:border-border-dark">
<TaskItem task={subtask} onopen={openTask} depth={1} />
</div>
{/each}
{/if}
{/each} {/each}
{#if app.pendingTasks.length === 0} {#if app.pendingTasks.length === 0}
@ -519,6 +545,11 @@
<div class="transition-all duration-300 ease-out {showCompleted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}"> <div class="transition-all duration-300 ease-out {showCompleted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}">
{#each app.completedTasks as task (task.id)} {#each app.completedTasks as task (task.id)}
<TaskItem {task} onopen={openTask} /> <TaskItem {task} onopen={openTask} />
{#each app.getSubtasks(task.id).filter(s => s.status === "completed") as subtask (subtask.id)}
<div class="border-l-2 border-border-light ml-4 dark:border-border-dark">
<TaskItem task={subtask} onopen={openTask} depth={1} />
</div>
{/each}
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -31,8 +31,25 @@ let error = $state<string | null>(null);
// ── Derived ────────────────────────────────────────────────────────── // ── Derived ──────────────────────────────────────────────────────────
let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null); let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null);
let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog")); let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id));
let completedTasks = $derived(tasks.filter((t) => t.status === "completed")); 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<string, Task[]>();
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( let hasWorkspace = $derived(
config !== null && config !== null &&
config.current_workspace !== null && config.current_workspace !== null &&
@ -151,13 +168,14 @@ async function deleteList(id: string) {
} }
} }
async function createTask(title: string, description?: string): Promise<Task | null> { async function createTask(title: string, description?: string, parentId?: string): Promise<Task | null> {
if (!activeListId) return null; if (!activeListId) return null;
try { try {
const task = await invoke<Task>("create_task", { const task = await invoke<Task>("create_task", {
listId: activeListId, listId: activeListId,
title, title,
description: description ?? "", description: description ?? "",
parentId: parentId ?? null,
}); });
tasks = [...tasks, task]; tasks = [...tasks, task];
error = null; error = null;
@ -350,6 +368,7 @@ export const app = {
get hasWorkspace() { get hasWorkspace() {
return hasWorkspace; return hasWorkspace;
}, },
getSubtasks,
loadConfig, loadConfig,
addWorkspace, addWorkspace,
switchWorkspace, switchWorkspace,