diff --git a/.cdp/verify-project-sync.cjs b/.cdp/verify-project-sync.cjs new file mode 100644 index 0000000..01b3db3 --- /dev/null +++ b/.cdp/verify-project-sync.cjs @@ -0,0 +1,109 @@ +/* Same sync check as verify-sync.cjs, but on a Project board (verifies the + projectId branch of getBoardSnapshot + the provider's scoping). */ +const { spawn } = require("node:child_process"); +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const http = require("node:http"); + +const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; +const PORT = 9342; +const BASE = "http://localhost:3210"; + +function httpJson(p) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => { + let d = ""; res.on("data", (c) => (d += c)); + res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } }); + }); + req.on("error", reject); req.end(); + }); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function sql(statement) { + execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", statement]); +} + +(async () => { + const devUserId = execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-tAc", "select id from \"User\" where email='dev@example.com'"]).toString().trim(); + + // Test project with one open to-do (clean slate regardless of prior runs). + sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`); + sql(`insert into "Project" (id, title, "ownerId", "createdAt", "updatedAt") values ('ptest-proj', 'Sync Test Project', '${devUserId}', now(), now());`); + sql(`insert into "Category" (id, name, "order", "projectId", "createdAt", "updatedAt") values ('ptest-cat', 'Lane A', 0, 'ptest-proj', now(), now());`); + sql(`insert into "Group" (id, title, color, "order", "noteContent", "categoryId", "createdAt", "updatedAt") values ('ptest-grp', 'P group', 'ocean', 0, '', 'ptest-cat', now(), now());`); + sql(`insert into "Todo" (id, title, "order", "groupId", "createdAt", "updatedAt") values ('ptest-todo', 'Project milk', 0, 'ptest-grp', now(), now());`); + + const profile = fs.mkdtempSync("/tmp/edge-psync-"); + const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" }); + let targets = null; + for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} } + if (!targets) { console.error("no targets"); process.exit(1); } + const page = targets.find((t) => t.type === "page"); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((r) => (ws.onopen = r)); + let id = 0; + const pending = new Map(); + const send = (method, params = {}) => new Promise((resolve) => { + const myId = ++id; pending.set(myId, resolve); + ws.send(JSON.stringify({ id: myId, method, params })); + }); + const errors = []; + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; } + if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") { + const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" "); + if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) errors.push(txt.slice(0, 200)); + } + }; + const evalJs = (expression) => + send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }) + .then((r) => (r?.result ? r.result.value : r)); + await send("Runtime.enable"); + + await send("Page.enable"); + await send("Page.navigate", { url: `${BASE}/login` }); + for (let i = 0; i < 60; i++) { await sleep(500); if (await evalJs("document.readyState") === "complete") break; } + const login = await evalJs(`(async () => { + const c = await (await fetch('/api/auth/csrf')).json(); + const r = await fetch('/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', + body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }) }); + return r.status; })()`); + console.log("login status:", login); + + await send("Page.navigate", { url: `${BASE}/projects/ptest-proj` }); + let checkboxFound = false; + for (let i = 0; i < 60; i++) { + await sleep(500); + checkboxFound = await evalJs(`!!Array.from(document.querySelectorAll('[data-todo-checkbox]')) + .some((el) => (el.getAttribute('aria-label') || '').includes('Project milk'))`); + if (checkboxFound) break; + } + if (!checkboxFound) { console.error("FAIL: project board checkbox not found"); process.exit(1); } + console.log("Project milk before:", await evalJs(`Array.from(document.querySelectorAll('[data-todo-checkbox]')) + .find((el) => (el.getAttribute('aria-label') || '').includes('Project milk')).getAttribute('aria-checked')`)); + + const t0 = Date.now(); + console.log("simulating phone: completing the project's to-do in the DB..."); + sql(`update "Todo" set "completed"=true, "completedAt"=now() where id='ptest-todo';`); + let done = false; + for (let i = 0; i < 60; i++) { + await sleep(1000); + const state = await evalJs(`(() => { + const el = Array.from(document.querySelectorAll('[data-todo-checkbox]')) + .find((el) => (el.getAttribute('aria-label') || '').includes('Project milk')); + if (el) return el.getAttribute('aria-checked'); + return document.querySelector('[aria-label="Expand P group"]') ? "collapsed-done" : "missing"; + })()`); + if (state === "true" || state === "collapsed-done") { done = true; break; } + } + const elapsed = Math.round((Date.now() - t0) / 1000); + console.log(`project to-do after (${elapsed}s): ${done ? "synced" : "NOT synced"}`); + + sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`); + console.log("console errors:", errors.length ? errors : "none"); + const ok = done && errors.length === 0; + console.log(ok ? "ALL PASS" : "FAILURES PRESENT"); + ws.close(); proc.kill(); + process.exit(ok ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/.cdp/verify-sync.cjs b/.cdp/verify-sync.cjs new file mode 100644 index 0000000..5cc63ab --- /dev/null +++ b/.cdp/verify-sync.cjs @@ -0,0 +1,153 @@ +/* Verifies cross-device board sync: a "second device" (direct DB write) + changes a to-do, and the open desktop tab must pick it up via the 30s + poll -- and immediately when the tab regains visibility. */ +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const http = require("node:http"); + +const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; +const PORT = 9341; +const BASE = "http://localhost:3210"; + +function httpJson(p) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => { + let d = ""; res.on("data", (c) => (d += c)); + res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } }); + }); + req.on("error", reject); req.end(); + }); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function dbTodo(title, completed) { + // Simulates the phone: a change that lands in the DB, not this tab. + return new Promise((resolve) => { + const { execFile } = require("node:child_process"); + const sql = `UPDATE "Todo" SET "completed"=${completed}, "completedAt"=${completed ? "now()" : "NULL"} WHERE title='${title}' AND "completed"=${!completed};`; + execFile("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", sql], (err, out, errOut) => { + resolve({ ok: !err, out, errOut }); + }); + }); +} + +(async () => { + const profile = fs.mkdtempSync("/tmp/edge-sync-"); + const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" }); + let targets = null; + for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} } + if (!targets) { console.error("no targets"); process.exit(1); } + const page = targets.find((t) => t.type === "page"); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((r) => (ws.onopen = r)); + let id = 0; + const pending = new Map(); + const send = (method, params = {}) => new Promise((resolve) => { + const myId = ++id; pending.set(myId, resolve); + ws.send(JSON.stringify({ id: myId, method, params })); + }); + const consoleErrors = []; + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; } + if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") { + const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" "); + if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) consoleErrors.push(txt.slice(0, 200)); + } + }; + + const evalJs = (expression) => + send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }) + .then((r) => (r?.result ? r.result.value : r)); + const goto = async (url) => { + await send("Page.enable"); + await send("Page.navigate", { url }); + // Wait for load to settle. + for (let i = 0; i < 60; i++) { + await sleep(500); + const s = await evalJs("document.readyState"); + if (s === "complete") return; + } + throw new Error("page did not load: " + url); + }; + const checkboxState = (title) => + evalJs(`(() => { + const el = Array.from(document.querySelectorAll('[data-todo-checkbox]')) + .find((el) => (el.getAttribute('aria-label') || '').includes(${JSON.stringify(title)})); + return el ? el.getAttribute('aria-checked') : "missing"; + })()`); + + await send("Runtime.enable"); + + // --- Login --- + await goto(`${BASE}/login`); + const login = await evalJs(`(async () => { + const c = await (await fetch('/api/auth/csrf')).json(); + const r = await fetch('/api/auth/callback/credentials', { + method: 'POST', redirect: 'manual', + body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }), + }); + return r.status; + })()`); + console.log("login status:", login); + + // --- Reset test data before the board loads (idempotent) --- + await dbTodo("Milk", false); + await dbTodo("Eggs", false); + + // --- Open the Home board --- + await goto(`${BASE}/`); + for (let i = 0; i < 60; i++) { + await sleep(500); + if (await evalJs(`!!document.querySelector('[data-todo-checkbox]')`)) break; + } + console.log("Milk before:", await checkboxState('Milk')); + if ((await checkboxState("Milk")) === "missing") { console.error("FAIL: Milk checkbox not found"); process.exit(1); } + + // --- Test 1: 30s poll picks up a remote change --- + const t0 = Date.now(); + console.log("simulating phone: checking off 'Milk' in the DB..."); + await dbTodo("Milk", true); + let saw = "missing"; + for (let i = 0; i < 60; i++) { + await sleep(1000); + saw = await checkboxState("Milk"); + if (saw === "true") break; + } + const elapsed = Math.round((Date.now() - t0) / 1000); + console.log(`Milk after (${elapsed}s):`, saw); + const test1 = saw === "true" && elapsed <= 45; + console.log(test1 ? "PASS: 30s poll applied the remote change" : "FAIL: poll did not apply the remote change"); + + // --- Test 2: hidden tab skips, visibilitychange catches up --- + await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })`); + // Let a poll tick fire while "hidden" (interval is 30s; wait 32s). + await sleep(32000); + console.log("simulating phone: checking off 'Eggs' in the DB (tab hidden)..."); + await dbTodo("Eggs", true); + await sleep(2000); + const whileHidden = await checkboxState("Eggs"); + console.log("Eggs while still hidden:", whileHidden); + await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); document.dispatchEvent(new Event('visibilitychange'))`); + // When the sync lands, 'Groceries' becomes fully completed and the card + // collapses (its checkboxes disappear from the DOM, replaced by an + // "Expand Groceries" disclosure) -- that is exactly how a fully-checked + // group renders, so assert the collapsed card rather than the checkbox. + let settled = false; + for (let i = 0; i < 15; i++) { + await sleep(1000); + const state = await checkboxState("Eggs"); + const collapsed = await evalJs(`!!document.querySelector('[aria-label="Expand Groceries"]')`); + if (state === "missing" && collapsed) { settled = true; break; } + if (state === "true") { settled = true; break; } + } + const test2 = settled && whileHidden === "false"; + console.log(`Eggs group after tab shown: ${settled ? "synced (card collapsed as fully-complete)" : "NOT synced"}`); + console.log(test2 ? "PASS: visibilitychange triggered immediate catch-up" : "FAIL: visibility catch-up"); + + console.log("console errors:", consoleErrors.length ? consoleErrors : "none"); + const ok = test1 && test2 && consoleErrors.length === 0; + console.log(ok ? "ALL PASS" : "FAILURES PRESENT"); + ws.close(); proc.kill(); + process.exit(ok ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/components/board/board-context.tsx b/components/board/board-context.tsx index 14d2d96..1751764 100644 --- a/components/board/board-context.tsx +++ b/components/board/board-context.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useState, useCallback } from "react"; +import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board"; @@ -25,6 +25,13 @@ import { deleteTodo as deleteTodoAction, } from "@/lib/actions/todos"; import { updateGroupNote } from "@/lib/actions/notes"; +import { getBoardSnapshot } from "@/lib/actions/board"; + +// How often a visible tab checks for changes made on another device (see +// the sync effect in BoardProvider). Deliberately not real time: a phone +// tap lands here within a tick at most -- and immediately if the tab only +// just came to the foreground. +const POLL_INTERVAL_MS = 30_000; interface BoardContextValue { categories: CategoryDTO[]; @@ -75,6 +82,10 @@ interface BoardContextValue { completed: boolean ) => Promise; removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise; + // True while a dnd-kit drag is live -- a state replacement mid-drag would + // swap the SortableContext items out from under it, so remote sync + // defers until the drag ends. + suspendRemoteSync: (suspended: boolean) => void; } const BoardContext = createContext(null); @@ -96,6 +107,94 @@ export function BoardProvider({ }) { const [categories, setCategories] = useState(initialCategories); + // -- Cross-device sync --------------------------------------------------- + // The board is seeded once from the server and otherwise only changed by + // this tab's own optimistic mutations, so edits made on a second device + // (checking off a to-do on a phone, renaming a lane, ...) never reach + // this tab on their own. The poll below closes that gap: fetch a fresh + // snapshot and, if it differs from local state and no local write is + // pending or just landed, apply it. The refs are the guards that keep a + // poll from ever clobbering local work. + // Always mirrors `categories` for the diff below (updated by the effect + // under it rather than every setCategories call site). + const categoriesRef = useRef(categories); + // Local mutations in flight (optimistic state ahead of the server). + const inflightRef = useRef(0); + // True while a drag is live (set from Board's drag start/end). + const remoteSyncSuspendedRef = useRef(false); + // A poll is already running. + const pollingRef = useRef(false); + // When the most recent local mutation settled (success or rollback). + const lastLocalWriteAtRef = useRef(0); + + useEffect(() => { + categoriesRef.current = categories; + }, [categories]); + + // Marks a server action as "local work in progress" for the poll's + // duration, and stamps when it settled. Every mutation below goes + // through this so the sync effect can tell "state we are about to lose" + // from "state we will happily overwrite with the server's copy". + const track = useCallback( + (promise: Promise): Promise => { + inflightRef.current += 1; + return promise.finally(() => { + inflightRef.current -= 1; + lastLocalWriteAtRef.current = Date.now(); + }); + }, + [] + ); + + const suspendRemoteSync = useCallback((suspended: boolean) => { + remoteSyncSuspendedRef.current = suspended; + }, []); + + useEffect(() => { + let disposed = false; + + async function refreshBoard() { + if (disposed || pollingRef.current) return; + if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return; + // No point fetching while the tab is hidden; the visibilitychange + // handler below catches up the moment it is shown again. + if (document.visibilityState !== "visible") return; + pollingRef.current = true; + try { + const board = await getBoardSnapshot(projectId ?? null); + if (disposed) return; + // Re-check the guards at apply time, not just at start: a mutation + // (or drag) that began while the fetch was in flight makes this + // snapshot stale relative to local state. A write that settled in + // the last couple of seconds may post-date the snapshot's read on + // the server too, so defer it -- the next tick picks everything up. + if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return; + if (Date.now() - lastLocalWriteAtRef.current < 2000) return; + // The common case is "nothing changed" -- skip the setState so the + // whole board doesn't re-render every tick. + if (JSON.stringify(board) === JSON.stringify(categoriesRef.current)) return; + setCategories(board); + } catch { + // Offline or a server hiccup -- the next tick retries. (If the + // session itself is gone, the page's own navigation handles it.) + } finally { + pollingRef.current = false; + } + } + + function handleVisibilityChange() { + if (document.visibilityState === "visible") void refreshBoard(); + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + const timer = window.setInterval(() => void refreshBoard(), POLL_INTERVAL_MS); + return () => { + disposed = true; + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.clearInterval(timer); + }; + }, [projectId]); + const updateCategory = useCallback( (categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => { setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c))); @@ -116,13 +215,13 @@ export function BoardProvider({ const addCategory = useCallback( async (name: string) => { try { - const category = await createCategory(name, projectId); + const category = await track(createCategory(name, projectId)); setCategories((prev) => [...prev, category]); } catch { toast.error("Couldn't create category. Try again."); } }, - [projectId] + [projectId, track] ); const removeCategory = useCallback(async (categoryId: string) => { @@ -132,7 +231,7 @@ export function BoardProvider({ return prev.filter((c) => c.id !== categoryId); }); try { - const result = await deleteCategoryAction(categoryId); + const result = await track(deleteCategoryAction(categoryId)); if (result?.error) { setCategories(prevState); toast.error(result.error); @@ -141,7 +240,7 @@ export function BoardProvider({ setCategories(prevState); toast.error("Couldn't delete category. Try again."); } - }, []); + }, [track]); const reorderLanes = useCallback( async (orderedIds: string[]) => { @@ -152,36 +251,36 @@ export function BoardProvider({ return orderedIds.map((id, order) => ({ ...byId.get(id)!, order })); }); try { - await reorderCategories(orderedIds, projectId); + await track(reorderCategories(orderedIds, projectId)); } catch { setCategories(prevState); toast.error("Couldn't reorder categories. Try again."); } }, - [projectId] + [projectId, track] ); const addGroup = useCallback(async (categoryId: string, title: string, color: string) => { try { - const group = await createGroup(categoryId, title, color); + const group = await track(createGroup(categoryId, title, color)); updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] })); return group; } catch { toast.error("Couldn't create group. Try again."); return undefined; } - }, [updateCategory]); + }, [updateCategory, track]); const editGroup = useCallback( async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => { try { - await updateGroupAction(groupId, data); + await track(updateGroupAction(groupId, data)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data })); } catch { toast.error("Couldn't update group. Try again."); } }, - [updateGroupInState] + [updateGroupInState, track] ); const removeGroup = useCallback(async (groupId: string, categoryId: string) => { @@ -193,12 +292,12 @@ export function BoardProvider({ ); }); try { - await deleteGroupAction(groupId); + await track(deleteGroupAction(groupId)); } catch { setCategories(prevState); toast.error("Couldn't delete group. Try again."); } - }, []); + }, [track]); const archiveGroup = useCallback(async (groupId: string, categoryId: string) => { let prevState: CategoryDTO[] = []; @@ -209,12 +308,12 @@ export function BoardProvider({ ); }); try { - await archiveGroupAction(groupId); + await track(archiveGroupAction(groupId)); } catch { setCategories(prevState); toast.error("Couldn't archive group. Try again."); } - }, []); + }, [track]); const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => { let prevState: CategoryDTO[] = []; @@ -227,12 +326,12 @@ export function BoardProvider({ }); }); try { - await reorderGroupsInCategory(categoryId, orderedIds); + await track(reorderGroupsInCategory(categoryId, orderedIds)); } catch { setCategories(prevState); toast.error("Couldn't reorder groups. Try again."); } - }, []); + }, [track]); const moveGroup = useCallback( async ( @@ -273,19 +372,19 @@ export function BoardProvider({ }); }); try { - await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource); + await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource)); } catch { setCategories(prevState); toast.error("Couldn't move group. Try again."); } }, - [] + [track] ); const saveNote = useCallback( async (groupId: string, categoryId: string, noteContent: string) => { try { - await updateGroupNote(groupId, noteContent); + await track(updateGroupNote(groupId, noteContent)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent })); return true; } catch { @@ -293,13 +392,13 @@ export function BoardProvider({ return false; } }, - [updateGroupInState] + [updateGroupInState, track] ); const addTodo = useCallback( async (groupId: string, categoryId: string, title: string, details?: string) => { try { - const todo = await createTodo(groupId, title, details); + const todo = await track(createTodo(groupId, title, details)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] })); return true; } catch { @@ -307,13 +406,13 @@ export function BoardProvider({ return false; } }, - [updateGroupInState] + [updateGroupInState, track] ); const addTodos = useCallback( async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => { try { - const created = await createTodos(groupId, todos); + const created = await track(createTodos(groupId, todos)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] })); return true; } catch { @@ -321,7 +420,7 @@ export function BoardProvider({ return false; } }, - [updateGroupInState] + [updateGroupInState, track] ); const editTodo = useCallback( @@ -332,7 +431,7 @@ export function BoardProvider({ data: { title?: string; details?: string | null } ) => { try { - await updateTodoAction(todoId, data); + await track(updateTodoAction(todoId, data)); // Approximates the server's own `updatedAt` (set by the same write, // a moment later) closely enough for display purposes, without // waiting on a round trip just to read it back. @@ -347,7 +446,7 @@ export function BoardProvider({ return false; } }, - [updateGroupInState] + [updateGroupInState, track] ); const toggleTodoDone = useCallback( @@ -370,13 +469,13 @@ export function BoardProvider({ }), })); try { - await toggleTodoAction(todoId, completed); + await track(toggleTodoAction(todoId, completed)); } catch { updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos })); toast.error("Couldn't update to-do. Try again."); } }, - [updateCategory, updateGroupInState] + [updateCategory, updateGroupInState, track] ); const removeTodo = useCallback( @@ -391,13 +490,13 @@ export function BoardProvider({ }), })); try { - await deleteTodoAction(todoId); + await track(deleteTodoAction(todoId)); } catch { updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos })); toast.error("Couldn't delete to-do. Try again."); } }, - [updateCategory, updateGroupInState] + [updateCategory, updateGroupInState, track] ); return ( @@ -420,6 +519,7 @@ export function BoardProvider({ editTodo, toggleTodoDone, removeTodo, + suspendRemoteSync, }} > {children} diff --git a/components/board/kanban-board.tsx b/components/board/kanban-board.tsx index 151c4a7..f7fbb45 100644 --- a/components/board/kanban-board.tsx +++ b/components/board/kanban-board.tsx @@ -38,7 +38,7 @@ function Board({ projectId?: string; aiConfigured: boolean; }) { - const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); + const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = useBoard(); const [draggedGroup, setDraggedGroup] = useState(null); const [quickAddOpen, setQuickAddOpen] = useState(false); const pagerRef = useRef(null); @@ -67,6 +67,11 @@ function Board({ } function handleDragStart(event: DragStartEvent) { + // Hold cross-device sync off for the drag's duration: replacing the + // board state mid-drag would swap the SortableContext items out from + // under dnd-kit. Drag ends (including cancelled ones) always fire + // handleDragEnd, which resumes it. + suspendRemoteSync(true); const data = event.active.data.current; if (data?.type === "group") { const category = findCategory(data.categoryId as string); @@ -76,6 +81,7 @@ function Board({ } function handleDragEnd(event: DragEndEvent) { + suspendRemoteSync(false); const { active, over } = event; setDraggedGroup(null); if (!over) return; diff --git a/lib/actions/board.ts b/lib/actions/board.ts new file mode 100644 index 0000000..99d0c94 --- /dev/null +++ b/lib/actions/board.ts @@ -0,0 +1,27 @@ +"use server"; + +import { prisma } from "@/lib/db"; +import { requireUserId } from "@/lib/auth-helpers"; +import { projectAccessFilter } from "@/lib/access"; +import { getBoard } from "@/lib/board"; +import type { CategoryDTO } from "@/types/board"; + +/** + * A fresh read of one board for the client's cross-device sync (see the + * poll in board-context.tsx). Returns the exact same shape the page's + * server component renders, so BoardProvider can diff it against its own + * state and apply it only if something actually changed. `projectId` null + * is Home scope -- same convention as getScheduledBoard. + */ +export async function getBoardSnapshot(projectId: string | null): Promise { + const userId = await requireUserId(); + if (projectId) { + const project = await prisma.project.findFirst({ + where: { id: projectId, ...projectAccessFilter(userId) }, + select: { id: true }, + }); + if (!project) throw new Error("Project not found"); + return getBoard({ projectId: project.id }); + } + return getBoard({ userId, projectId: null }); +}