Fix: Full N-depth recursive subtask tree rendering in main list, universal custom context menu on all subtask levels, and bidirectional realtime sync

This commit is contained in:
2026-08-20 14:45:55 +09:00
parent 9c01516124
commit 2913ba1889
4 changed files with 255 additions and 323 deletions
+48 -117
View File
@@ -3,7 +3,16 @@ import { useState, useCallback, useEffect } from "react";
import { Sidebar } from "./Sidebar";
import { TaskList, Task, List, User } from "../tasks/TaskList";
import { TaskDetail } from "../tasks/TaskDetail";
import { getDemoStore, saveDemoStore, MockList, MockTask } from "@/lib/mockData";
import {
getDemoStore,
saveDemoStore,
MockList,
MockTask,
updateTaskInTree,
deleteTaskInTree,
addTaskToTree,
findTaskInTree,
} from "@/lib/mockData";
interface AppShellProps {
user: User;
@@ -47,22 +56,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setSelectedTask(task);
}, []);
// Update a task and sync with main list and selected panel
// Update a task in full tree (supports unlimited N-depth nesting)
const handleTaskUpdate = useCallback((updated: Task) => {
setTasks((prev) =>
prev.map((t) => {
if (t.id === updated.id) {
return { ...updated, children: updated.children || t.children || [] };
}
// If a sub-task was updated directly, update parent's children
if (t.children && t.children.some((c) => c.id === updated.id)) {
const newChildren = t.children.map((c) => (c.id === updated.id ? updated : c));
return { ...t, children: newChildren };
}
return t;
})
);
setTasks((prev) => updateTaskInTree(prev as MockTask[], updated as MockTask) as Task[]);
setSelectedTask((prev) => {
if (prev && prev.id === updated.id) {
return { ...updated, children: updated.children || prev.children || [] };
@@ -105,7 +101,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
}
};
// Demo Handlers
// Demo Handlers with Full N-Depth Recursive Tree Support
const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = {
id: "demo-list-" + Date.now(),
@@ -150,79 +146,41 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
tags: [],
};
const store = getDemoStore();
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
saveDemoStore(store.lists, newTasks);
if (parentId) {
// Add as subtask to parent
const newTasks = store.tasks.map((t) => {
if (t.id === parentId) {
const currentChildren = t.children || [];
return { ...t, children: [...currentChildren, newTask as MockTask] };
}
return t;
});
saveDemoStore(store.lists, newTasks);
setTasks((prev) =>
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), newTask] } : t))
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
// If currently selected task is the parent, update its children in the detail panel immediately
if (selectedTask && selectedTask.id === parentId) {
setSelectedTask((prev) =>
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
);
if (selectedTask && selectedTask.id === parentId) {
setSelectedTask((prev) => (prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev));
}
} else {
// Top-level task
const newTasks = [...store.tasks, newTask as MockTask];
saveDemoStore(store.lists, newTasks);
setTasks((prev) => [...prev, newTask]);
}
refresh();
};
const handleDemoToggleTask = (id: string, completed: boolean) => {
const store = getDemoStore();
const newTasks = store.tasks.map((t) => {
if (t.id === id) {
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
}
if (t.children && t.children.some((c) => c.id === id)) {
const newChildren = t.children.map((c) =>
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
);
return { ...t, children: newChildren };
}
return t;
});
saveDemoStore(store.lists, newTasks);
const target = findTaskInTree(store.tasks, id);
if (target) {
const updated = {
...target,
completed,
completedAt: completed ? new Date().toISOString() : null,
};
const newTasks = updateTaskInTree(store.tasks, updated);
saveDemoStore(store.lists, newTasks);
setTasks((prev) =>
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
);
setTasks((prev) =>
prev
.map((t) => {
if (t.id === id) {
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
}
if (t.children && t.children.some((c) => c.id === id)) {
const newChildren = t.children.map((c) =>
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
);
return { ...t, children: newChildren };
}
return t;
})
.filter((t) => showCompleted || !t.completed)
);
if (selectedTask) {
if (selectedTask.id === id) {
setSelectedTask((prev) => (prev ? { ...prev, completed, completedAt: completed ? new Date().toISOString() : null } : prev));
} else if (selectedTask.children && selectedTask.children.some((c) => c.id === id)) {
setSelectedTask((prev) =>
prev
? {
...prev,
children: prev.children.map((c) =>
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
),
}
: prev
);
// Keep selectedTask 100% in sync with the tree
if (selectedTask) {
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
if (latestSelected) {
setSelectedTask(latestSelected as Task);
}
}
}
refresh();
@@ -230,32 +188,18 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const handleDemoUpdateTask = (updated: Task) => {
const store = getDemoStore();
const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t));
const newTasks = updateTaskInTree(store.tasks, updated as MockTask);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated);
};
const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore();
// Remove if main task or remove from parent children
const newTasks = store.tasks
.filter((t) => t.id !== id)
.map((t) => {
if (t.children && t.children.some((c) => c.id === id)) {
return { ...t, children: t.children.filter((c) => c.id !== id) };
}
return t;
});
const newTasks = deleteTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) =>
prev
.filter((t) => t.id !== id)
.map((t) => (t.children ? { ...t, children: t.children.filter((c) => c.id !== id) } : t))
);
setTasks((prev) => deleteTaskInTree(prev as MockTask[], id) as Task[]);
if (selectedTask?.id === id) {
setSelectedTask(null);
} else if (selectedTask?.children?.some((c) => c.id === id)) {
setSelectedTask((prev) => (prev ? { ...prev, children: prev.children.filter((c) => c.id !== id) } : prev));
}
refresh();
};
@@ -267,25 +211,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
if (isDemo) {
const store = getDemoStore();
const newTasks = store.tasks.map((t) => {
if (t.id === taskId) return { ...t, title: trimmed };
if (t.children && t.children.some((c) => c.id === taskId)) {
return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) };
}
return t;
});
saveDemoStore(store.lists, newTasks);
setTasks((prev) =>
prev.map((t) => {
if (t.id === taskId) return { ...t, title: trimmed };
if (t.children && t.children.some((c) => c.id === taskId)) {
return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) };
}
return t;
})
);
if (selectedTask?.id === taskId) {
setSelectedTask((prev) => (prev ? { ...prev, title: trimmed } : prev));
const target = findTaskInTree(store.tasks, taskId);
if (target) {
const updated = { ...target, title: trimmed };
const newTasks = updateTaskInTree(store.tasks, updated);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated as Task);
}
return;
}