fix(trash & tags): fix subtasks hierarchy restoration in trash and real-time tag counts sync
Build and Push Docker Image / build-and-push (push) Successful in 9m16s

This commit is contained in:
2026-08-21 23:00:50 +09:00
parent 4d1da38085
commit fc70b1e8cb
4 changed files with 44 additions and 29 deletions
+3 -3
View File
@@ -410,13 +410,13 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
} }
}; };
// Restore from Trash // Restore from Trash (Restores task and all its nested subtasks)
const handleRestoreTask = (id: string) => { const handleRestoreTask = (id: string) => {
if (isDemo) { if (isDemo) {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = restoreTaskInTree(store.tasks, id); const newTasks = restoreTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id)); setTasks(getAllTrashTasks(newTasks) as Task[]);
refresh(); refresh();
} }
}; };
@@ -427,7 +427,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = deleteTaskInTree(store.tasks, id); const newTasks = deleteTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id)); setTasks(getAllTrashTasks(newTasks) as Task[]);
refresh(); refresh();
} }
}; };
+17 -11
View File
@@ -7,7 +7,7 @@ import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector"; import { LanguageSelector } from "@/components/ui/LanguageSelector";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu"; import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
import { SettingsModal } from "@/components/settings/SettingsModal"; import { SettingsModal } from "@/components/settings/SettingsModal";
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mockData"; import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore, getTagTaskCount } from "@/lib/mockData";
interface User { id: string; name?: string | null; email?: string | null } interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } } interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
@@ -607,16 +607,22 @@ export function Sidebar({
{tags.length > 0 && ( {tags.length > 0 && (
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section-label">🏷 {t("tags") || "Tags"}</div> <div className="sidebar-section-label">🏷 {t("tags") || "Tags"}</div>
{tags.map((tag) => ( {tags.map((tag) => {
<div const tagCount = isDemo && typeof window !== "undefined"
key={tag.id} ? getTagTaskCount(getDemoStore().tasks, tag.name)
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`} : 0;
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)} return (
> <div
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span> key={tag.id}
<span className="item-label">{tag.name}</span> className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
</div> onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
))} >
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
<span className="item-label">{tag.name}</span>
{tagCount > 0 && <span className="item-count">{tagCount}</span>}
</div>
);
})}
</div> </div>
)} )}
+8 -3
View File
@@ -160,8 +160,8 @@ export function TaskDetail({
const updatedTask: Task = { const updatedTask: Task = {
...task, ...task,
...data, ...data,
children: subtasks, children: (data.children as Task[]) || subtasks,
tags, tags: (data.tags as { tag: Tag }[]) || tags,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} as Task; } as Task;
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask); if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
@@ -251,7 +251,12 @@ export function TaskDetail({
name: trimmed, name: trimmed,
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)], color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)],
}; };
setAllAvailableTags((p) => [...p, newTag]); const updatedAvailable = [...allAvailableTags, newTag];
setAllAvailableTags(updatedAvailable);
if (typeof window !== "undefined") {
const { saveCustomTags } = require("@/lib/mockData");
saveCustomTags(updatedAvailable);
}
toggleTag(newTag); toggleTag(newTag);
setNewTagName(""); setNewTagName("");
}; };
+16 -12
View File
@@ -280,16 +280,16 @@ export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
// Restore from Trash (recursively unmark node and its children) // 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 => ({ const markRestored = (node: MockTask): MockTask => ({
...node, ...node,
isDeleted: false, isDeleted: false,
deletedAt: null, deletedAt: null,
children: node.children ? node.children.map(unmarkDeleted) : [], children: node.children ? node.children.map(markRestored) : [],
}); });
return tree.map((node) => { return tree.map((node) => {
if (node.id === id) { if (node.id === id) {
return unmarkDeleted(node); return markRestored(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) };
@@ -348,25 +348,24 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
return null; return null;
} }
// Get all active tasks or all trash tasks flattened (strictly deduplicated) // Get all top-level trash tasks preserving intact nested sub-tasks hierarchy
export function getAllTrashTasks(tree: MockTask[]): MockTask[] { export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
const map = new Map<string, MockTask>(); const result: MockTask[] = [];
function collect(nodes: MockTask[]) { function collect(nodes: MockTask[]) {
for (const node of nodes) { for (const node of nodes) {
if (node.isDeleted) { if (node.isDeleted) {
if (!map.has(node.id)) { // Collect top-level deleted item with its full subtasks tree intact
map.set(node.id, { ...node, children: [] }); result.push(node);
} } else if (node.children && node.children.length > 0) {
} // If parent is not deleted, check if any subtask was individually deleted
if (node.children && node.children.length > 0) {
collect(node.children); collect(node.children);
} }
} }
} }
collect(tree); collect(tree);
return Array.from(map.values()); return result;
} }
// Filter tasks by custom tag // Filter tasks by custom tag
@@ -381,4 +380,9 @@ export function filterTasksByTag(tree: MockTask[], tagName: string): MockTask[]
} }
} }
return matched; return matched;
} }
// Get count of active tasks for a tag
export function getTagTaskCount(tree: MockTask[], tagName: string): number {
return filterTasksByTag(tree, tagName).filter((t) => !t.completed).length;
}