diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 7a432e8..a734c93 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -35,26 +35,6 @@ async function updateTodoItem({ return updatedItem; } -export async function batchUpdateTodoItem({ - data, -}: { - data: (Partial & { id: string })[]; -}) { - const res = await fetch("/api/batch/todo-items", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!res.ok) { - throw new Error("Failed to batch update todo items"); - } - - await res.json(); -} - async function insertTodoItem({ data }: { data: TodoItemCreateDataType }) { const res = await fetch("/api/todo-items", { method: "POST", @@ -73,9 +53,11 @@ async function insertTodoItem({ data }: { data: TodoItemCreateDataType }) { return response; } -export const todoItemsCollection = createCollection( +const todoItemsQueryKey = ["todo-items"]; + +export const todoItemsCollection = createCollection( queryCollectionOptions({ - queryKey: ["todo-items"], + queryKey: todoItemsQueryKey, queryFn: getTodoItems, queryClient: TanstackQuery.getContext().queryClient, onInsert: async ({ transaction }) => { @@ -86,25 +68,59 @@ export const todoItemsCollection = createCollection( data: newTodoItem, }); } catch (error) { - // TODO: handle error toast.error(`Failed to insert todo item "${newTodoItem.title}"`); console.error("Failed to insert todo item:", error); } }, onUpdate: async ({ transaction }) => { + /** + NOTE: This is a temporary solution for updating todo items. + **Do not use this in production code!** + + Update strategy: + 1. Optimistically update the local cache when a todo item is moved/updated + 2. Update the server via API call + 3. If the API call fails, refetch the data from the server and revert the local cache + + The server state is only fetched from the server if the update fails. + Proper synchronization of moving/reordering items requires a sync engine + to handle client-server conflicts effectively, which is outside the scope + of this demo app. + + Check out the available built-in sync collections here: + https://tanstack.com/db/latest/docs/overview#built-in-collection-types + */ + const { original, changes } = transaction.mutations[0]; try { + // Send the updates to the server await updateTodoItem({ data: { id: original.id, ...changes, }, }); - } catch (error) { - // TODO: handle error - console.error("Failed to update todo item:", error); + + // If successful, we can keep the optimistic update + todoItemsCollection.utils.writeUpdate({ + id: original.id, + ...changes, + }); + } catch (_) { + toast.error(`Failed to update todo item "${original.title}"`); + + // Do not sync if the collection is already refetching + if (todoItemsCollection.utils.isRefetching === false) { + // Sync back the server's data + todoItemsCollection.utils.refetch(); + } } + + // Do not sync back the server's data by default + return { + refetch: false, + }; }, getKey: (item) => item.id, }), diff --git a/src/components/TodoBoards.tsx b/src/components/TodoBoards.tsx index 52e347d..ab9d8e5 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -12,12 +12,7 @@ import { useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { - debounceStrategy, - eq, - useLiveQuery, - usePacedMutations, -} from "@tanstack/react-db"; +import { eq, useLiveQuery } from "@tanstack/react-db"; import { generateKeyBetween } from "fractional-indexing"; import { CircleCheckBigIcon, @@ -30,10 +25,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import { Virtualizer } from "virtua"; import { boardCollection } from "@/collections/boards"; import { projectsCollection } from "@/collections/projects"; -import { - batchUpdateTodoItem, - todoItemsCollection, -} from "@/collections/todoItems"; +import { todoItemsCollection } from "@/collections/todoItems"; import type { BoardRecord, TodoItemRecord } from "@/db/schema"; import { useScrollShadow } from "@/hooks/use-scroll-shadow"; import { cn } from "@/lib/utils"; @@ -312,49 +304,22 @@ function Board({ board }: { board: BoardRecord }) { export function TodoBoards({ projectId }: { projectId: string }) { const [activeId, setActiveId] = useState(null); - // Create paced mutation with 3 second debounce for updating todo positions - const updateTodoPosition = usePacedMutations< - { - itemId: string; - boardId?: string; - newPosition: string; - }, - TodoItemRecord - >({ - onMutate: ({ itemId, boardId, newPosition }) => { - // Apply optimistic update immediately - todoItemsCollection.update(itemId, (item) => { - if (boardId) { - item.boardId = boardId; - } - item.position = newPosition; - }); - }, - mutationFn: async ({ transaction }) => { - // Persist all position updates to the backend after debounce - const mutations = transaction.mutations; - - const updates = mutations.reduce( - (acc, mutation) => { - const { modified, changes } = mutation; - acc.push({ - id: modified.id, - ...changes, - }); - return acc; - }, - [] as (Partial & { id: string })[], - ); - - await batchUpdateTodoItem({ - data: updates, - }); - - // Refetch to ensure consistency with backend - await todoItemsCollection.utils.refetch(); - }, - strategy: debounceStrategy({ wait: 1_000 }), - }); + const updateTodoPosition = ({ + itemId, + boardId, + newPosition, + }: { + itemId: string; + boardId?: string; + newPosition: string; + }) => { + todoItemsCollection.update(itemId, (item) => { + if (boardId) { + item.boardId = boardId; + } + item.position = newPosition; + }); + }; const { data: boards, isLoading: isLoadingBoards } = useLiveQuery( (q) => diff --git a/src/local-api/api.batch.todo-items.ts b/src/local-api/api.batch.todo-items.ts deleted file mode 100644 index 45ccb86..0000000 --- a/src/local-api/api.batch.todo-items.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { eq } from "drizzle-orm"; -import z from "zod"; -import { db } from "@/db"; -import { todoItemsTable } from "@/db/schema"; -import { todoItemUpdateData } from "./api.todo-items"; -import { type APIRouteHandler, json } from "./helpers"; - -const todoItemBatchUpdateData = z.array(todoItemUpdateData); - -export default { - PATCH: async ({ request }) => { - // Update multiple todo items at once - - let updatedData: z.infer[]; - - // biome-ignore lint/suspicious/noExplicitAny: it can be any here - let bodyObj: any; - - try { - bodyObj = await request.json(); - } catch (e) { - console.error("Error parsing JSON body:", e); - return new Response(`Invalid JSON body`, { status: 400 }); - } - - try { - updatedData = todoItemBatchUpdateData.parse(bodyObj); - } catch (e) { - console.error("Validation error:", e); - if (e instanceof z.ZodError) { - return new Response(`Invalid request data: ${z.prettifyError(e)}`, { - status: 400, - }); - } - console.error("Bad format", e); - return new Response(`Validation error`, { status: 400 }); - } - - await db.transaction(async (tx) => { - for (const todoItemData of updatedData) { - await tx - .update(todoItemsTable) - .set(todoItemData) - .where(eq(todoItemsTable.id, todoItemData.id)); - } - }); - - return json({ updated: "ok" }); - }, -} satisfies APIRouteHandler; diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index 8fa78c9..3771c3c 100644 --- a/src/local-api/api.todo-items.ts +++ b/src/local-api/api.todo-items.ts @@ -15,7 +15,7 @@ const todoItemCreateData = z.object({ export type TodoItemCreateDataType = z.infer; -export const todoItemUpdateData = z.object({ +const todoItemUpdateData = z.object({ id: z.string(), boardId: z.string().optional(), priority: z.number().nullable().optional(), @@ -109,11 +109,16 @@ export default { return new Response(`No columns to update`, { status: 400 }); } - const results = await db + const [updatedTodoItemData] = await db .update(todoItemsTable) .set(updatedData) - .where(eq(todoItemsTable.id, updatedData.id)); + .where(eq(todoItemsTable.id, updatedData.id)) + .returning(); - return json(results); + if (!updatedTodoItemData) { + return new Response(`Todo item not found`, { status: 404 }); + } + + return json(updatedTodoItemData); }, } satisfies APIRouteHandler; diff --git a/src/local-api/index.ts b/src/local-api/index.ts index d82ed00..7b5b816 100644 --- a/src/local-api/index.ts +++ b/src/local-api/index.ts @@ -1,4 +1,3 @@ -import todoItemsBatchRoutes from "./api.batch.todo-items"; import boardRoutes from "./api.boards"; import projectRoutes from "./api.projects"; import todoRoutes from "./api.todo-items"; @@ -8,7 +7,6 @@ export const API = { "/api/projects": projectRoutes, "/api/boards": boardRoutes, "/api/todo-items": todoRoutes, - "/api/batch/todo-items": todoItemsBatchRoutes, } satisfies APIType; // type APIRoutePath = keyof typeof API;