Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 42 additions & 26 deletions src/collections/todoItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,6 @@ async function updateTodoItem({
return updatedItem;
}

export async function batchUpdateTodoItem({
data,
}: {
data: (Partial<TodoItemRecord> & { 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",
Expand All @@ -73,9 +53,11 @@ async function insertTodoItem({ data }: { data: TodoItemCreateDataType }) {
return response;
}

export const todoItemsCollection = createCollection(
const todoItemsQueryKey = ["todo-items"];

export const todoItemsCollection = createCollection<TodoItemRecord>(
queryCollectionOptions({
queryKey: ["todo-items"],
queryKey: todoItemsQueryKey,
queryFn: getTodoItems,
queryClient: TanstackQuery.getContext().queryClient,
onInsert: async ({ transaction }) => {
Expand All @@ -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,
}),
Expand Down
71 changes: 18 additions & 53 deletions src/components/TodoBoards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -312,49 +304,22 @@ function Board({ board }: { board: BoardRecord }) {
export function TodoBoards({ projectId }: { projectId: string }) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(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<TodoItemRecord> & { 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) =>
Expand Down
50 changes: 0 additions & 50 deletions src/local-api/api.batch.todo-items.ts

This file was deleted.

13 changes: 9 additions & 4 deletions src/local-api/api.todo-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const todoItemCreateData = z.object({

export type TodoItemCreateDataType = z.infer<typeof todoItemCreateData>;

export const todoItemUpdateData = z.object({
const todoItemUpdateData = z.object({
id: z.string(),
boardId: z.string().optional(),
priority: z.number().nullable().optional(),
Expand Down Expand Up @@ -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;
2 changes: 0 additions & 2 deletions src/local-api/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down