Fix: Real-time 2-level subtask sync between panels and inline title editing for list and tasks

This commit is contained in:
2026-08-20 14:27:57 +09:00
parent 0dfd7991ee
commit ed8c9187a7
29 changed files with 1811 additions and 96 deletions
+183 -17
View File
@@ -30,7 +30,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
}
}, [isDemo]);
// Demo tasks filter
// Demo tasks filter & reconstruct hierarchical structure
useEffect(() => {
if (isDemo && selectedListId) {
const store = getDemoStore();
@@ -47,9 +47,28 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setSelectedTask(task);
}, []);
// Update a task and sync with main list and selected panel
const handleTaskUpdate = useCallback((updated: Task) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
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;
})
);
setSelectedTask((prev) => {
if (prev && prev.id === updated.id) {
return { ...updated, children: updated.children || prev.children || [] };
}
return prev;
});
}, []);
const handleListSelect = useCallback((id: string) => {
@@ -58,6 +77,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setSidebarOpen(false);
}, []);
// Update List Name (Inline edit on header or sidebar)
const handleUpdateListName = async (id: string, newName: string) => {
const trimmed = newName.trim();
if (!trimmed) return;
if (isDemo) {
const store = getDemoStore();
const newLists = store.lists.map((l) => (l.id === id ? { ...l, name: trimmed } : l));
saveDemoStore(newLists, store.tasks);
setLists(newLists);
return;
}
try {
const res = await fetch(`/api/lists/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (res.ok) {
const updated = await res.json();
setLists((prev) => prev.map((l) => (l.id === id ? { ...l, name: updated.name } : l)));
}
} catch (err) {
console.error("Failed to update list name", err);
}
};
// Demo Handlers
const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = {
@@ -85,11 +132,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
refresh();
};
const handleDemoAddTask = (title: string, listId: string) => {
const handleDemoAddTask = (title: string, listId: string, parentId: string | null = null) => {
const newTask: Task = {
id: "demo-task-" + Date.now(),
listId,
parentId: null,
parentId: parentId || null,
title,
note: null,
completed: false,
@@ -103,9 +150,29 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
tags: [],
};
const store = getDemoStore();
const newTasks = [...store.tasks, newTask as MockTask];
saveDemoStore(store.lists, newTasks);
setTasks((prev) => [...prev, newTask]);
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))
);
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();
};
@@ -115,12 +182,49 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
if (t.id === id) {
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
}
return {
...t,
children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [],
};
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);
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
);
}
}
refresh();
};
@@ -128,19 +232,79 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const store = getDemoStore();
const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t));
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
handleTaskUpdate(updated);
};
const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore();
const newTasks = store.tasks.filter((t) => t.id !== id);
// 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;
});
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
setSelectedTask(null);
setTasks((prev) =>
prev
.filter((t) => t.id !== id)
.map((t) => (t.children ? { ...t, children: t.children.filter((c) => c.id !== id) } : t))
);
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();
};
// Inline update task title from main list
const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => {
const trimmed = newTitle.trim();
if (!trimmed) return;
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));
}
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) {
const updated = await res.json();
handleTaskUpdate(updated);
}
} catch (err) {
console.error("Failed to update task title", err);
}
};
return (
<div className="app-layout">
{/* Mobile overlay */}
@@ -182,6 +346,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
isDemo={isDemo}
onDemoAddTask={handleDemoAddTask}
onDemoToggleTask={handleDemoToggleTask}
onUpdateTaskTitle={handleUpdateTaskTitle}
onUpdateListName={handleUpdateListName}
/>
</div>