Organize/components/board/board-context.tsx

535 lines
18 KiB
TypeScript

"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<void>;
removeCategory: (categoryId: string) => Promise<void>;
reorderLanes: (orderedIds: string[]) => Promise<void>;
addGroup: (categoryId: string, title: string, color: string) => Promise<GroupDTO | undefined>;
editGroup: (
groupId: string,
categoryId: string,
data: { title?: string; color?: string }
) => Promise<void>;
removeGroup: (groupId: string, categoryId: string) => Promise<void>;
archiveGroup: (groupId: string, categoryId: string) => Promise<void>;
reorderGroups: (categoryId: string, orderedIds: string[]) => Promise<void>;
moveGroup: (
groupId: string,
fromCategoryId: string,
toCategoryId: string,
orderedTarget: string[],
orderedSource: string[]
) => Promise<void>;
saveNote: (groupId: string, categoryId: string, noteContent: string) => Promise<boolean>;
addTodo: (
groupId: string,
categoryId: string,
title: string,
details?: string
) => Promise<boolean>;
addTodos: (
groupId: string,
categoryId: string,
todos: { title: string; details?: string }[]
) => Promise<boolean>;
editTodo: (
todoId: string,
groupId: string,
categoryId: string,
data: { title?: string; details?: string | null }
) => Promise<boolean>;
toggleTodoDone: (
todoId: string,
groupId: string,
categoryId: string,
completed: boolean
) => Promise<void>;
removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
// 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<BoardContextValue | null>(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(
<T,>(promise: Promise<T>): Promise<T> => {
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 (
<BoardContext.Provider
value={{
categories,
aiConfigured,
addCategory,
removeCategory,
reorderLanes,
addGroup,
editGroup,
removeGroup,
archiveGroup,
reorderGroups,
moveGroup,
saveNote,
addTodo,
addTodos,
editTodo,
toggleTodoDone,
removeTodo,
suspendRemoteSync,
}}
>
{children}
</BoardContext.Provider>
);
}
export function useBoard() {
const ctx = useContext(BoardContext);
if (!ctx) throw new Error("useBoard must be used within a BoardProvider");
return ctx;
}