"use client"; import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board"; import { createCategory, deleteCategory as deleteCategoryAction, reorderCategories, } from "@/lib/actions/categories"; import { createGroup, updateGroup as updateGroupAction, deleteGroup as deleteGroupAction, archiveGroup as archiveGroupAction, reorderGroupsInCategory, moveGroupToCategory, } from "@/lib/actions/groups"; import { createTodo, createTodos, updateTodo as updateTodoAction, toggleTodo as toggleTodoAction, 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[]; // Whether the admin-configured AI provider has an API URL + model saved // -- gates AI-powered actions like the "Add using AI" to-do button. aiConfigured: boolean; addCategory: (name: string) => Promise; removeCategory: (categoryId: string) => Promise; reorderLanes: (orderedIds: string[]) => Promise; addGroup: (categoryId: string, title: string, color: string) => Promise; editGroup: ( groupId: string, categoryId: string, data: { title?: string; color?: string } ) => Promise; removeGroup: (groupId: string, categoryId: string) => Promise; archiveGroup: (groupId: string, categoryId: string) => Promise; reorderGroups: (categoryId: string, orderedIds: string[]) => Promise; moveGroup: ( groupId: string, fromCategoryId: string, toCategoryId: string, orderedTarget: string[], orderedSource: string[] ) => Promise; saveNote: (groupId: string, categoryId: string, noteContent: string) => Promise; addTodo: ( groupId: string, categoryId: string, title: string, details?: string ) => Promise; addTodos: ( groupId: string, categoryId: string, todos: { title: string; details?: string }[] ) => Promise; editTodo: ( todoId: string, groupId: string, categoryId: string, data: { title?: string; details?: string | null } ) => Promise; toggleTodoDone: ( todoId: string, groupId: string, categoryId: string, 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); export function BoardProvider({ initialCategories, projectId, aiConfigured, children, }: { initialCategories: CategoryDTO[]; // Undefined means Home. The Project-scoped board reuses every bit of // this provider -- the only calls that need to know which board they're // on are the two that operate over "all of this board's categories" // rather than a specific existing category/group/todo id. projectId?: string; aiConfigured: boolean; children: React.ReactNode; }) { 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))); }, [] ); const updateGroupInState = useCallback( (categoryId: string, groupId: string, updater: (g: GroupDTO) => GroupDTO) => { updateCategory(categoryId, (c) => ({ ...c, groups: c.groups.map((g) => (g.id === groupId ? updater(g) : g)), })); }, [updateCategory] ); const addCategory = useCallback( async (name: string) => { try { const category = await track(createCategory(name, projectId)); setCategories((prev) => [...prev, category]); } catch { toast.error("Couldn't create category. Try again."); } }, [projectId, track] ); const removeCategory = useCallback(async (categoryId: string) => { let prevState: CategoryDTO[] = []; setCategories((prev) => { prevState = prev; return prev.filter((c) => c.id !== categoryId); }); try { const result = await track(deleteCategoryAction(categoryId)); if (result?.error) { setCategories(prevState); toast.error(result.error); } } catch { setCategories(prevState); toast.error("Couldn't delete category. Try again."); } }, [track]); const reorderLanes = useCallback( async (orderedIds: string[]) => { let prevState: CategoryDTO[] = []; setCategories((prev) => { prevState = prev; const byId = new Map(prev.map((c) => [c.id, c])); return orderedIds.map((id, order) => ({ ...byId.get(id)!, order })); }); try { await track(reorderCategories(orderedIds, projectId)); } catch { setCategories(prevState); toast.error("Couldn't reorder categories. Try again."); } }, [projectId, track] ); const addGroup = useCallback(async (categoryId: string, title: string, color: string) => { try { 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, track]); const editGroup = useCallback( async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => { try { await track(updateGroupAction(groupId, data)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data })); } catch { toast.error("Couldn't update group. Try again."); } }, [updateGroupInState, track] ); const removeGroup = useCallback(async (groupId: string, categoryId: string) => { let prevState: CategoryDTO[] = []; setCategories((prev) => { prevState = prev; return prev.map((c) => c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c ); }); try { 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[] = []; setCategories((prev) => { prevState = prev; return prev.map((c) => c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c ); }); try { 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[] = []; setCategories((prev) => { prevState = prev; return prev.map((c) => { if (c.id !== categoryId) return c; const byId = new Map(c.groups.map((g) => [g.id, g])); return { ...c, groups: orderedIds.map((id, order) => ({ ...byId.get(id)!, order })) }; }); }); try { await track(reorderGroupsInCategory(categoryId, orderedIds)); } catch { setCategories(prevState); toast.error("Couldn't reorder groups. Try again."); } }, [track]); const moveGroup = useCallback( async ( groupId: string, fromCategoryId: string, toCategoryId: string, orderedTarget: string[], orderedSource: string[] ) => { let prevState: CategoryDTO[] = []; setCategories((prev) => { prevState = prev; const sourceCat = prev.find((c) => c.id === fromCategoryId); const movedGroup = sourceCat?.groups.find((g) => g.id === groupId); if (!movedGroup) return prev; return prev.map((c) => { if (c.id === fromCategoryId) { const byId = new Map(c.groups.map((g) => [g.id, g])); return { ...c, groups: orderedSource.map((id, order) => ({ ...byId.get(id)!, order })), }; } if (c.id === toCategoryId) { const byId = new Map(c.groups.map((g) => [g.id, g])); byId.set(groupId, movedGroup); return { ...c, groups: orderedTarget.map((id, order) => ({ ...byId.get(id)!, categoryId: toCategoryId, order, })), }; } return c; }); }); try { 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 track(updateGroupNote(groupId, noteContent)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent })); return true; } catch { toast.error("Couldn't save note. Try again."); return false; } }, [updateGroupInState, track] ); const addTodo = useCallback( async (groupId: string, categoryId: string, title: string, details?: string) => { try { const todo = await track(createTodo(groupId, title, details)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] })); return true; } catch { toast.error("Couldn't add to-do. Try again."); return false; } }, [updateGroupInState, track] ); const addTodos = useCallback( async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => { try { const created = await track(createTodos(groupId, todos)); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] })); return true; } catch { toast.error("Couldn't add to-dos. Try again."); return false; } }, [updateGroupInState, track] ); const editTodo = useCallback( async ( todoId: string, groupId: string, categoryId: string, data: { title?: string; details?: string | null } ) => { try { 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. const updatedAt = new Date().toISOString(); updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: g.todos.map((t) => (t.id === todoId ? { ...t, ...data, updatedAt } : t)), })); return true; } catch { toast.error("Couldn't update to-do. Try again."); return false; } }, [updateGroupInState, track] ); const toggleTodoDone = useCallback( async (todoId: string, groupId: string, categoryId: string, completed: boolean) => { const now = new Date().toISOString(); let prevTodos: TodoDTO[] = []; updateCategory(categoryId, (c) => ({ ...c, groups: c.groups.map((g) => { if (g.id !== groupId) return g; prevTodos = g.todos; return { ...g, todos: g.todos.map((t) => t.id === todoId ? { ...t, completed, completedAt: completed ? now : null, updatedAt: now } : t ), }; }), })); try { await track(toggleTodoAction(todoId, completed)); } catch { updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos })); toast.error("Couldn't update to-do. Try again."); } }, [updateCategory, updateGroupInState, track] ); const removeTodo = useCallback( async (todoId: string, groupId: string, categoryId: string) => { let prevTodos: TodoDTO[] = []; updateCategory(categoryId, (c) => ({ ...c, groups: c.groups.map((g) => { if (g.id !== groupId) return g; prevTodos = g.todos; return { ...g, todos: g.todos.filter((t) => t.id !== todoId) }; }), })); try { await track(deleteTodoAction(todoId)); } catch { updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos })); toast.error("Couldn't delete to-do. Try again."); } }, [updateCategory, updateGroupInState, track] ); return ( {children} ); } export function useBoard() { const ctx = useContext(BoardContext); if (!ctx) throw new Error("useBoard must be used within a BoardProvider"); return ctx; }