diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..56309bf --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,9 @@ +[mcp_servers.codemogger] +command = "npx" +args = ["-y", "codemogger", "mcp"] +enabled = true +[mcp_servers.memelord] +command = "memelord" +args = ["serve"] +env = { MEMELORD_DIR = "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord" } +enabled = true diff --git a/.gitignore b/.gitignore index 0936b38..e3d34df 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ todos.json # My local notes notes.md +.memelord/ +.codemogger diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..4c4ff34 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "codemogger": { + "command": "npx", + "args": ["-y", "codemogger", "mcp"] + }, + "memelord": { + "command": "memelord", + "args": ["serve"], + "env": { + "MEMELORD_DIR": "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord" + } + } + } +} diff --git a/config/mcporter.json b/config/mcporter.json new file mode 100644 index 0000000..89cff7f --- /dev/null +++ b/config/mcporter.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "codemogger": { + "command": "npx", + "args": [ + "-y", + "codemogger", + "mcp" + ] + }, + "memelord": { + "command": "memelord", + "args": [ + "serve" + ], + "env": { + "MEMELORD_DIR": "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord" + } + } + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..e2344ee --- /dev/null +++ b/opencode.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "codemogger": { + "type": "local", + "command": ["npx", "-y", "codemogger", "mcp"], + "enabled": true + }, + "memelord": { + "type": "local", + "command": ["memelord", "serve"], + "environment": { + "MEMELORD_DIR": "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord" + }, + "enabled": true + } + } +} diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index f3b522e..411651e 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -4,6 +4,236 @@ import { toast } from "sonner"; import type { TodoItemRecord } from "@/db/schema"; import * as TanstackQuery from "@/integrations/tanstack-query/root-provider"; import type { TodoItemCreateDataType } from "@/local-api/api.todo-items"; +import { + TODO_ITEMS_SYNC_STATE_ID, + todoItemsSyncCollection, +} from "./todoItemsSync"; + +type TodoItemSyncPayload = Pick< + TodoItemRecord, + "id" | "boardId" | "priority" | "title" | "description" | "position" +>; + +const UNSYNCED_TOAST_ID = "todo-items-unsynced"; + +const desiredPayloadById = new Map(); +const inFlightItemIds = new Set(); +const lastSyncedSignatureById = new Map(); +const retryAttemptById = new Map(); +const retryTimeoutById = new Map>(); +const unsyncedItemIds = new Set(); +const failedItemIds = new Set(); + +function syncStateCollection() { + todoItemsSyncCollection.update(TODO_ITEMS_SYNC_STATE_ID, (draft) => { + draft.unsyncedItemIds = [...unsyncedItemIds].sort(); + draft.inFlightItemIds = [...inFlightItemIds].sort(); + draft.failedItemIds = [...failedItemIds].sort(); + }); +} + +function updateUnsyncedToast() { + const failedCount = failedItemIds.size; + + if (failedCount === 0) { + toast.dismiss(UNSYNCED_TOAST_ID); + return; + } + + toast.error( + failedCount === 1 + ? "1 todo change failed to sync. Local state is preserved." + : `${failedCount} todo changes failed to sync. Local state is preserved.`, + { + id: UNSYNCED_TOAST_ID, + action: { + label: "Retry now", + onClick: () => { + void retryUnsyncedTodoItemsSync(); + }, + }, + }, + ); +} + +function syncUiIndicators() { + syncStateCollection(); + updateUnsyncedToast(); +} + +function buildPayload(item: TodoItemRecord): TodoItemSyncPayload { + return { + id: item.id, + boardId: item.boardId, + priority: item.priority, + title: item.title, + description: item.description, + position: item.position, + }; +} + +function payloadSignature(payload: TodoItemSyncPayload): string { + return JSON.stringify(payload); +} + +function clearRetryTimeout(itemId: string) { + const timeoutId = retryTimeoutById.get(itemId); + if (timeoutId) { + clearTimeout(timeoutId); + retryTimeoutById.delete(itemId); + } +} + +function getRetryDelayMs(attempt: number): number { + return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1)); +} + +function getTodoItem(itemId: string): TodoItemRecord | undefined { + return todoItemsCollection.get(itemId); +} + +function syncTodoItemsQueryCache(modified: TodoItemRecord) { + const queryClient = TanstackQuery.getContext().queryClient; + queryClient.setQueriesData( + { queryKey: todoItemsQueryKey }, + (oldData) => { + if (!oldData) { + return oldData; + } + + return oldData.map((item) => (item.id === modified.id ? modified : item)); + }, + ); +} + +function markUnsynced(itemId: string) { + unsyncedItemIds.add(itemId); + syncUiIndicators(); +} + +function markSynced(itemId: string) { + unsyncedItemIds.delete(itemId); + failedItemIds.delete(itemId); + syncUiIndicators(); +} + +function clearSyncStateForItem(itemId: string) { + clearRetryTimeout(itemId); + desiredPayloadById.delete(itemId); + inFlightItemIds.delete(itemId); + lastSyncedSignatureById.delete(itemId); + retryAttemptById.delete(itemId); + unsyncedItemIds.delete(itemId); + failedItemIds.delete(itemId); + syncUiIndicators(); +} + +async function flushItemSync(itemId: string) { + if (inFlightItemIds.has(itemId)) { + return; + } + + const desiredPayload = desiredPayloadById.get(itemId); + + if (!desiredPayload) { + clearSyncStateForItem(itemId); + return; + } + + const desiredSignature = payloadSignature(desiredPayload); + + if (lastSyncedSignatureById.get(itemId) === desiredSignature) { + markSynced(itemId); + return; + } + + inFlightItemIds.add(itemId); + syncUiIndicators(); + + try { + await updateTodoItem({ data: desiredPayload }); + lastSyncedSignatureById.set(itemId, desiredSignature); + retryAttemptById.set(itemId, 0); + } catch (error) { + const nextAttempt = (retryAttemptById.get(itemId) ?? 0) + 1; + retryAttemptById.set(itemId, nextAttempt); + unsyncedItemIds.add(itemId); + failedItemIds.add(itemId); + syncUiIndicators(); + + const delay = getRetryDelayMs(nextAttempt); + const timeoutId = setTimeout(() => { + retryTimeoutById.delete(itemId); + void flushItemSync(itemId); + }, delay); + + retryTimeoutById.set(itemId, timeoutId); + + console.error(`Failed to sync todo item ${itemId}:`, error); + return; + } finally { + inFlightItemIds.delete(itemId); + syncUiIndicators(); + } + + const latestLocalItem = getTodoItem(itemId); + + if (!latestLocalItem) { + clearSyncStateForItem(itemId); + return; + } + + const latestPayload = buildPayload(latestLocalItem); + const latestSignature = payloadSignature(latestPayload); + + desiredPayloadById.set(itemId, latestPayload); + + if (lastSyncedSignatureById.get(itemId) !== latestSignature) { + markUnsynced(itemId); + void flushItemSync(itemId); + return; + } + + markSynced(itemId); +} + +function queueLatestSync(itemId: string) { + const todoItem = getTodoItem(itemId); + + if (!todoItem) { + clearSyncStateForItem(itemId); + return; + } + + clearRetryTimeout(itemId); + + const nextPayload = buildPayload(todoItem); + desiredPayloadById.set(itemId, nextPayload); + + const nextSignature = payloadSignature(nextPayload); + const lastSyncedSignature = lastSyncedSignatureById.get(itemId); + + if (lastSyncedSignature !== nextSignature) { + markUnsynced(itemId); + } + + void flushItemSync(itemId); +} + +export async function retryUnsyncedTodoItemsSync() { + const idsToRetry = [...failedItemIds]; + + idsToRetry.forEach((itemId) => { + failedItemIds.delete(itemId); + clearRetryTimeout(itemId); + retryAttemptById.set(itemId, 0); + void flushItemSync(itemId); + }); + + syncUiIndicators(); + + return idsToRetry.length; +} async function updateTodoItem({ data, @@ -94,60 +324,16 @@ export const todoItemsCollection = createCollection( } }, 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]; + const { modified } = transaction.mutations[0]; - try { - // Send the updates to the server - await updateTodoItem({ - data: { - id: original.id, - ...changes, - }, - }); + // Keep TanStack Query cache aligned with local optimistic state + // so switching projects still shows the latest local edits. + syncTodoItemsQueryCache(modified); - // Update the TanStack Query cache so switching projects shows correct data - const queryClient = TanstackQuery.getContext().queryClient; - queryClient.setQueriesData( - { queryKey: todoItemsQueryKey }, - (oldData) => { - if (!oldData) return oldData; - return oldData.map((item) => - item.id === original.id ? { ...item, ...changes } : item, - ); - }, - ); - } catch (error) { - toast.error(`Failed to update todo item "${original.title}"`); - - // TODO: handle this one later properly - // with queryClient.invalidateQueries(todoItemsQueryKey); - // // Do not sync if the collection is already refetching - // if (todoItemsCollection.utils.isRefetching === false) { - // // Sync back the server's data - // todoItemsCollection.utils.refetch(); - // } - throw error; - } + // Local-first, latest-wins sync queue per item. + // We never throw here, so local optimistic state is never rolled back. + queueLatestSync(modified.id); - // Do not sync back the server's data by default return { refetch: false, }; diff --git a/src/collections/todoItemsSync.ts b/src/collections/todoItemsSync.ts new file mode 100644 index 0000000..6b15ebc --- /dev/null +++ b/src/collections/todoItemsSync.ts @@ -0,0 +1,30 @@ +import { + createCollection, + localOnlyCollectionOptions, +} from "@tanstack/react-db"; + +export const TODO_ITEMS_SYNC_STATE_ID = "todo-items-sync-state"; + +export type TodoItemsSyncState = { + id: string; + unsyncedItemIds: string[]; + inFlightItemIds: string[]; + failedItemIds: string[]; +}; + +const initialTodoItemsSyncState: TodoItemsSyncState[] = [ + { + id: TODO_ITEMS_SYNC_STATE_ID, + unsyncedItemIds: [], + inFlightItemIds: [], + failedItemIds: [], + }, +]; + +export const todoItemsSyncCollection = createCollection( + localOnlyCollectionOptions({ + id: "todo-items-sync", + getKey: (item) => item.id, + initialData: initialTodoItemsSyncState, + }), +); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 9dedb18..411a44a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,11 @@ +import { eq, useLiveQuery } from "@tanstack/react-db"; import { ClientOnly } from "@tanstack/react-router"; -import { BugIcon, GithubIcon } from "lucide-react"; +import { BugIcon, GithubIcon, LoaderIcon, RotateCwIcon } from "lucide-react"; +import { retryUnsyncedTodoItemsSync } from "@/collections/todoItems"; +import { + TODO_ITEMS_SYNC_STATE_ID, + todoItemsSyncCollection, +} from "@/collections/todoItemsSync"; import { ApiLatencyConfigurator } from "./ApiLatencyConfigurator"; import { ApiPanelToggle } from "./ApiPanelToggle"; import { ConfigureDB } from "./ConfigureDB"; @@ -7,6 +13,53 @@ import { ModeToggle } from "./mode-toggle"; import { ResetTheDbDialog } from "./ResetTheDbDialog"; import { Button } from "./ui/button"; import { SidebarTrigger } from "./ui/sidebar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +function RetryTodoItemsSyncButton() { + const { data: syncState } = useLiveQuery((q) => + q + .from({ syncState: todoItemsSyncCollection }) + .where(({ syncState }) => eq(syncState.id, TODO_ITEMS_SYNC_STATE_ID)) + .findOne(), + ); + + const failedCount = syncState?.failedItemIds.length ?? 0; + const inFlightCount = syncState?.inFlightItemIds.length ?? 0; + + if (failedCount === 0) { + return null; + } + + const tooltipText = `Retries failed sync requests (${failedCount} pending failure${failedCount > 1 ? "s" : ""}). Local changes stay in the UI.`; + + return ( + + + + + + + +

{tooltipText}

+
+
+ ); +} export function Header() { return ( @@ -49,6 +102,9 @@ export function Header() { + + + ({ return prev?.boardId === target.boardId ? prev : null; } +function sortByBoardAndPosition(a: TodoItemRecord, b: TodoItemRecord): number { + if (a.boardId !== b.boardId) { + return a.boardId.localeCompare(b.boardId); + } + + if (a.position < b.position) { + return -1; + } + + if (a.position > b.position) { + return 1; + } + + return 0; +} + function handlePriorityPointerDown(e: React.PointerEvent) { e.stopPropagation(); e.preventDefault(); @@ -397,32 +413,29 @@ export function TodoBoards({ projectId }: { projectId: string }) { ? todoItemsCollection.toArray.find((item) => item.id === activeId) : undefined; - // Derive ordered todo items from already-loaded collection data - // instead of making a separate query - const orderedTodoItems = useMemo(() => { - const boardIdSet = new Set(boards.map((b) => b.id)); - return todoItemsCollection.toArray - .filter((item) => boardIdSet.has(item.boardId)) - .sort((a, b) => { - // Sort by boardId first, then by position (lexical for fractional indexing) - if (a.boardId !== b.boardId) { - return a.boardId.localeCompare(b.boardId); - } - return a.position < b.position ? -1 : a.position > b.position ? 1 : 0; - }); - }, [boards]); - const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id); }; - const handleDragEnd = async (event: DragEndEvent) => { + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); if (!over) return; - // Find which column the dragged task is in + const boardIdSet = new Set(boards.map((board) => board.id)); + + const orderedTodoItems = todoItemsCollection.toArray + .filter((item) => boardIdSet.has(item.boardId)) + .sort(sortByBoardAndPosition); + + const activeTodoItem = orderedTodoItems.find( + (item) => item.id === active.id, + ); + + if (!activeTodoItem) { + return; + } if (boards.some((board) => board.id === over.id)) { // This is either an empty column or the last place of a column @@ -441,9 +454,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { newPosition, }); } else { - const overTodoItem = todoItemsCollection.toArray.find( - (item) => item.id === over.id, - ); + const overTodoItem = orderedTodoItems.find((item) => item.id === over.id); if (!overTodoItem) { console.error("overTodoId not found"); @@ -500,7 +511,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { const sortedBoards = useMemo( () => - boards.sort((a) => + [...boards].sort((a) => a.name === "Todo" ? -1 : a.name === "In Progress" ? -1 : 1, ), [boards],