Browse Source

feat(tasks): add real-time progress updates and visual progress bar

- Update query cache directly for progress events instead of refetching
- Add visual progress bar with smooth transitions for task progress
- Display percentage alongside progress bar with tabular numbers
- Prevent UI flickering during progress updates
Timothy Pomeroy 1 tháng trước cách đây
mục cha
commit
8138ed5108
1 tập tin đã thay đổi với 137 bổ sung17 xóa
  1. 137 17
      apps/web/src/app/components/TaskList.tsx

+ 137 - 17
apps/web/src/app/components/TaskList.tsx

@@ -32,17 +32,41 @@ export default function TaskList({
   useEffect(() => {
     const handleTaskUpdate = (event: CustomEvent) => {
       const taskData = event.detail;
-      // Invalidate and refetch tasks when task updates occur
-      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+
+      // For progress updates, update the cache directly instead of refetching
+      if (taskData.type === "progress" && taskData.taskId !== undefined) {
+        queryClient.setQueryData(["tasks"], (oldData: any) => {
+          if (!oldData) return oldData;
+          return oldData.map((task: any) =>
+            task.id === taskData.taskId
+              ? { ...task, progress: taskData.progress }
+              : task
+          );
+        });
+      } else {
+        // For other task updates (created, completed, failed), do a full refetch
+        queryClient.refetchQueries({ queryKey: ["tasks"] });
+      }
+    };
+
+    const handleFileUpdate = (event: CustomEvent) => {
+      const fileData = event.detail;
+      // Refetch tasks when file updates occur (e.g., new files detected)
+      queryClient.refetchQueries({ queryKey: ["tasks"] });
     };
 
     window.addEventListener("taskUpdate", handleTaskUpdate as EventListener);
+    window.addEventListener("fileUpdate", handleFileUpdate as EventListener);
 
     return () => {
       window.removeEventListener(
         "taskUpdate",
         handleTaskUpdate as EventListener
       );
+      window.removeEventListener(
+        "fileUpdate",
+        handleFileUpdate as EventListener
+      );
     };
   }, [queryClient]);
 
@@ -60,24 +84,103 @@ export default function TaskList({
   // Expanded rows state
   const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
 
-  // Dataset and status filter state
-  const [enabledDatasets, setEnabledDatasets] = useState<Set<string>>(
-    new Set()
-  );
-  const [enabledStatuses, setEnabledStatuses] = useState<Set<string>>(
-    new Set(["pending", "processing", "failed", "skipped"])
-  );
+  // Dataset and status filter state - initialize from localStorage
+  const [enabledDatasets, setEnabledDatasets] = useState<Set<string>>(() => {
+    if (typeof window !== "undefined") {
+      const saved = localStorage.getItem("taskList:enabledDatasets");
+      return saved ? new Set(JSON.parse(saved)) : new Set();
+    }
+    return new Set();
+  });
+  const [enabledStatuses, setEnabledStatuses] = useState<Set<string>>(() => {
+    if (typeof window !== "undefined") {
+      const saved = localStorage.getItem("taskList:enabledStatuses");
+      return saved
+        ? new Set(JSON.parse(saved))
+        : new Set(["pending", "processing", "failed", "skipped"]);
+    }
+    return new Set(["pending", "processing", "failed", "skipped"]);
+  });
 
-  // State for filters and search
-  const [searchTerm, setSearchTerm] = useState("");
+  // State for filters and search - initialize from localStorage
+  const [searchTerm, setSearchTerm] = useState(() => {
+    if (typeof window !== "undefined") {
+      return localStorage.getItem("taskList:searchTerm") || "";
+    }
+    return "";
+  });
   const [sortField, setSortField] = useState<
     "id" | "status" | "progress" | "dataset" | "updated_at"
-  >("updated_at");
-  const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
+  >(() => {
+    if (typeof window !== "undefined") {
+      const saved = localStorage.getItem("taskList:sortField");
+      return (
+        (saved as "id" | "status" | "progress" | "dataset" | "updated_at") ||
+        "updated_at"
+      );
+    }
+    return "updated_at";
+  });
+  const [sortDirection, setSortDirection] = useState<"asc" | "desc">(() => {
+    if (typeof window !== "undefined") {
+      const saved = localStorage.getItem("taskList:sortDirection");
+      return (saved as "asc" | "desc") || "desc";
+    }
+    return "desc";
+  });
 
   // Pagination state
   const [currentPage, setCurrentPage] = useState(1);
-  const [pageSize, setPageSize] = useState(25);
+  const [pageSize, setPageSize] = useState(() => {
+    if (typeof window !== "undefined") {
+      const saved = localStorage.getItem("taskList:pageSize");
+      return saved ? parseInt(saved) : 25;
+    }
+    return 25;
+  });
+
+  // Save filters to localStorage
+  useEffect(() => {
+    if (typeof window !== "undefined") {
+      localStorage.setItem(
+        "taskList:enabledDatasets",
+        JSON.stringify(Array.from(enabledDatasets))
+      );
+    }
+  }, [enabledDatasets]);
+
+  useEffect(() => {
+    if (typeof window !== "undefined") {
+      localStorage.setItem(
+        "taskList:enabledStatuses",
+        JSON.stringify(Array.from(enabledStatuses))
+      );
+    }
+  }, [enabledStatuses]);
+
+  useEffect(() => {
+    if (typeof window !== "undefined") {
+      localStorage.setItem("taskList:searchTerm", searchTerm);
+    }
+  }, [searchTerm]);
+
+  useEffect(() => {
+    if (typeof window !== "undefined") {
+      localStorage.setItem("taskList:sortField", sortField);
+    }
+  }, [sortField]);
+
+  useEffect(() => {
+    if (typeof window !== "undefined") {
+      localStorage.setItem("taskList:sortDirection", sortDirection);
+    }
+  }, [sortDirection]);
+
+  useEffect(() => {
+    if (typeof window !== "undefined") {
+      localStorage.setItem("taskList:pageSize", pageSize.toString());
+    }
+  }, [pageSize]);
 
   const deleteMutation = useMutation({
     mutationFn: (params: { task?: string; tasks?: any[] }) => {
@@ -93,7 +196,7 @@ export default function TaskList({
       throw new Error("Invalid delete parameters");
     },
     onSuccess: (_, params) => {
-      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      queryClient.refetchQueries({ queryKey: ["tasks"] });
       if (params.tasks) {
         toast.success(`${params.tasks.length} tasks deleted successfully`);
         addNotification({
@@ -343,7 +446,7 @@ export default function TaskList({
           There was an error loading the tasks data.
         </p>
         <button
-          onClick={() => queryClient.invalidateQueries({ queryKey: ["tasks"] })}
+          onClick={() => queryClient.refetchQueries({ queryKey: ["tasks"] })}
           className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
         >
           Try Again
@@ -629,7 +732,24 @@ export default function TaskList({
                           {truncateText(task.status)}
                         </td>
                         <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
-                          {truncateText(task.progress ?? "-")}
+                          {task.progress !== null &&
+                          task.progress !== undefined ? (
+                            <div className="flex items-center gap-2">
+                              <div className="flex-1 min-w-[60px]">
+                                <div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
+                                  <div
+                                    className="h-full bg-indigo-600 transition-all duration-300"
+                                    style={{ width: `${task.progress}%` }}
+                                  />
+                                </div>
+                              </div>
+                              <span className="text-xs font-medium tabular-nums">
+                                {task.progress}%
+                              </span>
+                            </div>
+                          ) : (
+                            "-"
+                          )}
                         </td>
                         <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
                           {truncateText(task.dataset ?? "-")}