Merge pull request #18 from SteelDynamite/claude/review-project-plan-GNI5v
feat: add subtask hierarchy UI to Tauri GUI
This commit is contained in:
commit
e8500e57f1
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
PLAN.md
2
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)
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ fn create_task(
|
|||
list_id: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
parent_id: Option<String>,
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
) -> Result<Task, String> {
|
||||
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<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())
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
{/if}
|
||||
</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 -->
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -217,6 +235,59 @@
|
|||
</button>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
<!-- Date picker overlay -->
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
|
||||
<!-- Task content -->
|
||||
<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"
|
||||
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
||||
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="padding-left: {1 + depth * 1.5}rem; transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
||||
onclick={() => onopen?.(task)}
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
|
|
@ -143,6 +146,14 @@
|
|||
{formatDate(task.due_date)}
|
||||
</span>
|
||||
{/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>
|
||||
|
||||
<!-- Chevron -->
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
let wsMenuName = $state<string | null>(null);
|
||||
let renamingListId = $state<string | null>(null);
|
||||
let renameValue = $state("");
|
||||
let expandedTasks = $state(new Set<string>());
|
||||
let dragId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
let resizing = $state(false);
|
||||
|
|
@ -481,8 +482,33 @@
|
|||
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}
|
||||
<!-- 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} />
|
||||
</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}
|
||||
|
||||
{#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'}">
|
||||
{#each app.completedTasks as task (task.id)}
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,25 @@ let error = $state<string | null>(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<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(
|
||||
config !== 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;
|
||||
try {
|
||||
const task = await invoke<Task>("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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue