fix(trash & sidebar): isolate trash view, sync list task counts in real-time, and fix restore/delete actions
Build and Push Docker Image / build-and-push (push) Successful in 10m24s

This commit is contained in:
2026-08-21 22:32:02 +09:00
parent baf13a069a
commit 4d1da38085
6 changed files with 75 additions and 14 deletions
+3
View File
@@ -39,6 +39,9 @@
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings. - Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
- **Next Steps (Todo):** - **Next Steps (Todo):**
- Fix sidebar list item counts calculation in demo & API modes.
- Completely isolate Trash view and Tag view from My Tasks list state in TaskList.tsx.
- Fix Restore and Permanent Delete in Trash view for both Demo and API modes.
- Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings). - Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings).
- Add optional automatic synchronization background polling / webhook integration if requested. - Add optional automatic synchronization background polling / webhook integration if requested.
- Plan next feature iterations or custom integrations as requested by user. - Plan next feature iterations or custom integrations as requested by user.
+3 -2
View File
@@ -11,12 +11,13 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const listId = searchParams.get("listId"); const listId = searchParams.get("listId");
const showCompleted = searchParams.get("showCompleted") === "true"; const showCompleted = searchParams.get("showCompleted") === "true";
const tagName = searchParams.get("tag");
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
userId: session.user.id, userId: session.user.id,
parentId: null, ...(listId ? { listId, parentId: null } : tagName ? {} : { parentId: null }),
...(listId ? { listId } : {}),
...(showCompleted ? {} : { completed: false }), ...(showCompleted ? {} : { completed: false }),
...(tagName ? { tags: { some: { tag: { name: tagName } } } } : {}),
}; };
const tasks = await prisma.task.findMany({ const tasks = await prisma.task.findMany({
+28 -4
View File
@@ -116,7 +116,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
return () => document.removeEventListener("checkflow:openCommandPalette", handler); return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []); }, []);
// Demo tasks filter & reconstruct hierarchical structure // Task filter & reconstruct hierarchical structure for Demo and API modes
useEffect(() => { useEffect(() => {
if (isDemo) { if (isDemo) {
const store = getDemoStore(); const store = getDemoStore();
@@ -130,6 +130,23 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
(t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed) (t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed)
); );
setTasks(listTasks); setTasks(listTasks);
} else {
setTasks([]);
}
} else {
// Authenticated API mode
if (isTrashActive) {
setTasks([]);
} else if (selectedTag) {
fetch(`/api/tasks?tag=${encodeURIComponent(selectedTag)}&showCompleted=${showCompleted}`)
.then((r) => (r.ok ? r.json() : []))
.then((data) => setTasks(Array.isArray(data) ? data : []))
.catch((err) => console.error("Failed to load tag tasks", err));
} else if (selectedListId) {
fetch(`/api/tasks?listId=${selectedListId}&showCompleted=${showCompleted}`)
.then((r) => (r.ok ? r.json() : []))
.then((data) => setTasks(Array.isArray(data) ? data : []))
.catch((err) => console.error("Failed to load list tasks", err));
} }
} }
}, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]); }, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]);
@@ -161,18 +178,25 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const handleTagSelect = useCallback((tagName: string | null) => { const handleTagSelect = useCallback((tagName: string | null) => {
setIsTrashActive(false); setIsTrashActive(false);
setSelectedListId(null);
setSelectedTag(tagName); setSelectedTag(tagName);
setSelectedTask(null); setSelectedTask(null);
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, []);
const handleTrashSelect = useCallback(() => { const handleTrashSelect = useCallback(() => {
setIsTrashActive(true);
setSelectedTag(null);
setSelectedListId(null); setSelectedListId(null);
setSelectedTag(null);
setIsTrashActive(true);
setSelectedTask(null); setSelectedTask(null);
setSidebarOpen(false); setSidebarOpen(false);
}, []); if (isDemo) {
const store = getDemoStore();
setTasks(getAllTrashTasks(store.tasks) as Task[]);
} else {
setTasks([]);
}
}, [isDemo]);
// Update List Name // Update List Name
const handleUpdateListName = async (id: string, newName: string) => { const handleUpdateListName = async (id: string, newName: string) => {
+22 -3
View File
@@ -573,7 +573,16 @@ export function Sidebar({
<span className="item-label">{list.name}</span> <span className="item-label">{list.name}</span>
)} )}
<span className="item-count">{list._count?.tasks || ""}</span> <span className="item-count">
{isDemo
? (() => {
if (typeof window === "undefined") return "";
const store = getDemoStore();
const cnt = store.tasks.filter((t) => t.listId === list.id && !t.isDeleted && !t.completed).length;
return cnt > 0 ? cnt : "";
})()
: list._count?.tasks || ""}
</span>
<button <button
className="icon-btn" className="icon-btn"
style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }} style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }}
@@ -616,14 +625,24 @@ export function Sidebar({
<div <div
className={`sidebar-item${isTrashActive ? " active" : ""}`} className={`sidebar-item${isTrashActive ? " active" : ""}`}
id="trash-menu-btn" id="trash-menu-btn"
onClick={onTrashSelect} onClick={() => {
onTagSelect(null);
onTrashSelect();
}}
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }} style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.7 }}> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.7 }}>
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" /> <polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg> </svg>
<span className="item-label">{t("trash") || "Trash"}</span> <span className="item-label">{t("trash") || "Trash"}</span>
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>} {(() => {
const count = isDemo && typeof window !== "undefined"
? getAllTrashTasks(getDemoStore().tasks).length
: (externalTrashCount !== undefined ? externalTrashCount : internalTrashCount);
return count > 0 ? (
<span className="item-count" style={{ color: "var(--danger)", fontWeight: 600 }}>{count}</span>
) : null;
})()}
</div> </div>
</div> </div>
+1 -1
View File
@@ -1026,7 +1026,7 @@ export function TaskList({
)} )}
{/* Tasks Tree */} {/* Tasks Tree */}
{incompleteTasks.map((task) => ( {(isTrashActive ? tasks : incompleteTasks).map((task) => (
<RecursiveTaskItem <RecursiveTaskItem
key={task.id} key={task.id}
task={task} task={task}
+18 -4
View File
@@ -258,11 +258,18 @@ export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[
}); });
} }
// Soft delete to Trash // Soft delete to Trash (recursively mark node and its children)
export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] { export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
const markDeleted = (node: MockTask): MockTask => ({
...node,
isDeleted: true,
deletedAt: new Date().toISOString(),
children: node.children ? node.children.map(markDeleted) : [],
});
return tree.map((node) => { return tree.map((node) => {
if (node.id === id) { if (node.id === id) {
return { ...node, isDeleted: true, deletedAt: new Date().toISOString() }; return markDeleted(node);
} }
if (node.children && node.children.length > 0) { if (node.children && node.children.length > 0) {
return { ...node, children: moveToTrashInTree(node.children, id) }; return { ...node, children: moveToTrashInTree(node.children, id) };
@@ -271,11 +278,18 @@ export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
}); });
} }
// Restore from Trash // Restore from Trash (recursively unmark node and its children)
export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] { export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] {
const unmarkDeleted = (node: MockTask): MockTask => ({
...node,
isDeleted: false,
deletedAt: null,
children: node.children ? node.children.map(unmarkDeleted) : [],
});
return tree.map((node) => { return tree.map((node) => {
if (node.id === id) { if (node.id === id) {
return { ...node, isDeleted: false, deletedAt: null }; return unmarkDeleted(node);
} }
if (node.children && node.children.length > 0) { if (node.children && node.children.length > 0) {
return { ...node, children: restoreTaskInTree(node.children, id) }; return { ...node, children: restoreTaskInTree(node.children, id) };