diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx
new file mode 100644
index 0000000..24e9ea2
--- /dev/null
+++ b/apps/web/src/hooks/useTasks.test.tsx
@@ -0,0 +1,625 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { getCalendarDateRange } from "@tascal/shared/calendar";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Task } from "../types/task";
+import { useTasks, useUnscheduledTasks, useUpdateTask } from "./useTasks";
+
+const mockFetchTasks = vi.fn();
+const mockFetchUnscheduledTasks = vi.fn();
+const mockUpdateTask = vi.fn();
+
+vi.mock("../api/tasks", () => ({
+ fetchTasks: (...args: unknown[]) => mockFetchTasks(...args) as unknown,
+ fetchUnscheduledTasks: (...args: unknown[]) =>
+ mockFetchUnscheduledTasks(...args) as unknown,
+ createTask: vi.fn(),
+ updateTask: (...args: unknown[]) => mockUpdateTask(...args) as unknown,
+ deleteTask: vi.fn(),
+}));
+
+vi.mock("sonner", () => ({
+ toast: Object.assign(vi.fn(), { error: vi.fn() }),
+}));
+
+const scheduledTask: Task = {
+ id: "scheduled-task",
+ userId: "user-1",
+ title: "予定あり",
+ description: null,
+ date: "2026-08-12",
+ status: "todo",
+ categoryId: null,
+ createdAt: "2026-08-01T00:00:00.000Z",
+ updatedAt: "2026-08-01T00:00:00.000Z",
+};
+
+const unscheduledTask: Task = {
+ ...scheduledTask,
+ id: "unscheduled-task",
+ title: "未スケジュール",
+ date: null,
+};
+
+const { startDate, endDate } = getCalendarDateRange(2026, 8);
+const rangeKey = ["tasks", "range", startDate, endDate] as const;
+const unscheduledKey = ["tasks", "unscheduled"] as const;
+
+function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { gcTime: Infinity, retry: false },
+ mutations: { gcTime: Infinity, retry: false },
+ },
+ });
+}
+
+function createWrapper(queryClient: QueryClient) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+ };
+}
+
+function createPendingUpdate() {
+ let resolve!: (task: Task) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ mockUpdateTask.mockReturnValue(promise);
+ return { promise, reject, resolve };
+}
+
+function createDeferredUpdate() {
+ let resolve!: (task: Task) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, reject, resolve };
+}
+
+describe("useUpdateTask", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetchTasks.mockResolvedValue([]);
+ mockFetchUnscheduledTasks.mockResolvedValue([]);
+ });
+
+ it("API応答前にカレンダーから未スケジュールへ移し、失敗時に両cacheを戻す", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ queryClient.setQueryData(unscheduledKey, [unscheduledTask]);
+ const pendingUpdate = createPendingUpdate();
+ const { result } = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryClient.getQueryData(rangeKey)).toEqual([]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([
+ unscheduledTask,
+ { ...scheduledTask, date: null },
+ ]);
+ });
+
+ pendingUpdate.reject(new Error("update failed"));
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]);
+ });
+
+ it("API応答前に未スケジュールからカレンダーへ移し、失敗時に両cacheを戻す", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ queryClient.setQueryData(unscheduledKey, [unscheduledTask]);
+ const pendingUpdate = createPendingUpdate();
+ const { result } = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+ const targetDate = "2026-08-20";
+
+ act(() => {
+ result.current.mutate({
+ id: unscheduledTask.id,
+ data: { date: targetDate },
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryClient.getQueryData(rangeKey)).toEqual([
+ scheduledTask,
+ { ...unscheduledTask, date: targetDate },
+ ]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+ });
+
+ pendingUpdate.reject(new Error("update failed"));
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]);
+ });
+
+ it.each([
+ ["表示範囲の開始日", startDate, true],
+ ["表示範囲の終了日", endDate, true],
+ ["表示範囲より前", "2026-07-26", false],
+ ["表示範囲より後", "2026-09-07", false],
+ ])("%sへの移動でrange cache所属を判定する", async (_label, date, inRange) => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, []);
+ queryClient.setQueryData(unscheduledKey, [unscheduledTask]);
+ mockUpdateTask.mockResolvedValue({ ...unscheduledTask, date });
+ const { result } = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ await act(async () => {
+ await result.current.mutateAsync({
+ id: unscheduledTask.id,
+ data: { date },
+ });
+ });
+
+ expect(queryClient.getQueryData(rangeKey)).toEqual(
+ inRange ? [{ ...unscheduledTask, date }] : [],
+ );
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+ });
+
+ it("失敗時に対象IDだけを戻し、別taskの並行cache更新を維持する", async () => {
+ const otherTask = {
+ ...scheduledTask,
+ id: "other-task",
+ title: "別タスク",
+ };
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask, otherTask]);
+ queryClient.setQueryData(unscheduledKey, [unscheduledTask]);
+ const pendingUpdate = createPendingUpdate();
+ const { result } = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ });
+ await waitFor(() =>
+ expect(queryClient.getQueryData(rangeKey)).toEqual([otherTask]),
+ );
+
+ const externallyUpdatedTask = { ...otherTask, title: "並行更新済み" };
+ queryClient.setQueryData(rangeKey, [externallyUpdatedTask]);
+ pendingUpdate.reject(new Error("update failed"));
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(queryClient.getQueryData(rangeKey)).toEqual([
+ scheduledTask,
+ externallyUpdatedTask,
+ ]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]);
+ });
+
+ it("異なるIDのmutationは並行実行し、一方のrollbackでも他方を維持する", async () => {
+ const otherTask = {
+ ...scheduledTask,
+ id: "other-task",
+ title: "別タスク",
+ };
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask, otherTask]);
+ queryClient.setQueryData(unscheduledKey, []);
+ const failedUpdate = createDeferredUpdate();
+ const successfulUpdate = createDeferredUpdate();
+ mockUpdateTask.mockImplementation((id: string) =>
+ id === scheduledTask.id ? failedUpdate.promise : successfulUpdate.promise,
+ );
+ const first = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+ const second = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ first.result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ second.result.current.mutate({
+ id: otherTask.id,
+ data: { title: "別タスク更新済み" },
+ });
+ });
+
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2));
+ failedUpdate.reject(new Error("first update failed"));
+ await waitFor(() => expect(first.result.current.isError).toBe(true));
+
+ expect(queryClient.getQueryData(rangeKey)).toEqual([
+ scheduledTask,
+ { ...otherTask, title: "別タスク更新済み" },
+ ]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+
+ successfulUpdate.resolve({ ...otherTask, title: "別タスク更新済み" });
+ await waitFor(() => expect(second.result.current.isSuccess).toBe(true));
+ });
+
+ it("同一IDの先行移動失敗後に後続更新を正しい所属へ反映する", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ queryClient.setQueryData(unscheduledKey, []);
+ const firstUpdate = createDeferredUpdate();
+ const secondUpdate = createDeferredUpdate();
+ mockUpdateTask
+ .mockReturnValueOnce(firstUpdate.promise)
+ .mockReturnValueOnce(secondUpdate.promise);
+ const first = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+ const second = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ first.result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ second.result.current.mutate({
+ id: scheduledTask.id,
+ data: { title: "後続更新" },
+ });
+ });
+
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(1));
+ firstUpdate.reject(new Error("first update failed"));
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2));
+
+ expect(queryClient.getQueryData(rangeKey)).toEqual([
+ { ...scheduledTask, title: "後続更新" },
+ ]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+
+ secondUpdate.resolve({ ...scheduledTask, title: "後続更新" });
+ await waitFor(() => expect(second.result.current.isSuccess).toBe(true));
+ expect(queryClient.getQueryData(rangeKey)).toEqual([
+ { ...scheduledTask, title: "後続更新" },
+ ]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+ });
+
+ it("同一IDの先行移動と後続更新が両方失敗した場合に元の所属へ戻す", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ queryClient.setQueryData(unscheduledKey, []);
+ const firstUpdate = createDeferredUpdate();
+ const secondUpdate = createDeferredUpdate();
+ mockUpdateTask
+ .mockReturnValueOnce(firstUpdate.promise)
+ .mockReturnValueOnce(secondUpdate.promise);
+ const first = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+ const second = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ first.result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ second.result.current.mutate({
+ id: scheduledTask.id,
+ data: { title: "後続更新" },
+ });
+ });
+
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(1));
+ firstUpdate.reject(new Error("first update failed"));
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2));
+ secondUpdate.reject(new Error("second update failed"));
+ await waitFor(() => expect(second.result.current.isError).toBe(true));
+
+ expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+ });
+
+ it("失敗時に元がundefinedだったcacheを未取得状態へ戻す", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toBeUndefined();
+ const pendingUpdate = createPendingUpdate();
+ const { result } = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ });
+ await waitFor(() =>
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([
+ { ...scheduledTask, date: null },
+ ]),
+ );
+
+ pendingUpdate.reject(new Error("update failed"));
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toBeUndefined();
+ });
+
+ it("onMutate内部例外後も部分更新を戻し同一IDの後続mutationを完了できる", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ queryClient.setQueryData(unscheduledKey, []);
+ const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
+ const originalSetQueryData = queryClient.setQueryData.bind(queryClient);
+ vi.spyOn(queryClient, "setQueryData")
+ .mockImplementationOnce(originalSetQueryData)
+ .mockImplementationOnce(() => {
+ throw new Error("optimistic cache update failed");
+ });
+ const first = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ act(() => {
+ first.result.current.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ });
+
+ await waitFor(() => expect(first.result.current.isError).toBe(true));
+ expect(mockUpdateTask).not.toHaveBeenCalled();
+ expect(queryClient.getQueryData(rangeKey)).toEqual([scheduledTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+ expect(invalidateQueries).toHaveBeenCalledTimes(1);
+
+ const updatedTask = { ...scheduledTask, title: "例外後の更新" };
+ mockUpdateTask.mockResolvedValue(updatedTask);
+ const second = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ await act(async () => {
+ await second.result.current.mutateAsync({
+ id: scheduledTask.id,
+ data: { title: updatedTask.title },
+ });
+ });
+
+ expect(mockUpdateTask).toHaveBeenCalledWith(scheduledTask.id, {
+ title: updatedTask.title,
+ });
+ await waitFor(() => expect(second.result.current.isSuccess).toBe(true));
+ expect(queryClient.getQueryData(rangeKey)).toEqual([updatedTask]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([]);
+ expect(invalidateQueries).toHaveBeenCalledTimes(2);
+ });
+
+ it("active queryで異なるIDの一方settle後も他方のoptimistic値を維持する", async () => {
+ const otherTask = {
+ ...scheduledTask,
+ id: "other-task",
+ title: "別タスク",
+ };
+ const updatedOtherTask = { ...otherTask, title: "別タスク更新済み" };
+ const movedTask = { ...scheduledTask, date: null };
+ mockFetchTasks
+ .mockResolvedValueOnce([scheduledTask, otherTask])
+ .mockResolvedValue([updatedOtherTask]);
+ mockFetchUnscheduledTasks
+ .mockResolvedValueOnce([])
+ .mockResolvedValue([movedTask]);
+ const firstUpdate = createDeferredUpdate();
+ const secondUpdate = createDeferredUpdate();
+ mockUpdateTask.mockImplementation((id: string) =>
+ id === scheduledTask.id ? firstUpdate.promise : secondUpdate.promise,
+ );
+ const queryClient = createQueryClient();
+ const { result } = renderHook(
+ () => ({
+ tasks: useTasks(2026, 8),
+ unscheduledTasks: useUnscheduledTasks(),
+ first: useUpdateTask(2026, 8),
+ second: useUpdateTask(2026, 8),
+ }),
+ { wrapper: createWrapper(queryClient) },
+ );
+
+ await waitFor(() => {
+ expect(result.current.tasks.isSuccess).toBe(true);
+ expect(result.current.unscheduledTasks.isSuccess).toBe(true);
+ });
+ act(() => {
+ result.current.first.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ result.current.second.mutate({
+ id: otherTask.id,
+ data: { title: updatedOtherTask.title },
+ });
+ });
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2));
+
+ firstUpdate.resolve(movedTask);
+ await waitFor(() => expect(result.current.first.isSuccess).toBe(true));
+
+ expect(mockFetchTasks).toHaveBeenCalledTimes(1);
+ expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(1);
+ expect(result.current.tasks.data).toEqual([updatedOtherTask]);
+ expect(result.current.unscheduledTasks.data).toEqual([movedTask]);
+
+ secondUpdate.resolve(updatedOtherTask);
+ await waitFor(() => {
+ expect(result.current.second.isSuccess).toBe(true);
+ expect(mockFetchTasks).toHaveBeenCalledTimes(2);
+ expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(2);
+ });
+ expect(result.current.tasks.data).toEqual([updatedOtherTask]);
+ expect(result.current.unscheduledTasks.data).toEqual([movedTask]);
+ });
+
+ it("active queryで同一IDの後続値を先行settle中のrefetchで上書きしない", async () => {
+ const movedTask = { ...scheduledTask, date: null };
+ const finalTask = { ...movedTask, title: "後続更新" };
+ mockFetchTasks.mockResolvedValueOnce([scheduledTask]).mockResolvedValue([]);
+ mockFetchUnscheduledTasks
+ .mockResolvedValueOnce([])
+ .mockResolvedValue([finalTask]);
+ const firstUpdate = createDeferredUpdate();
+ const secondUpdate = createDeferredUpdate();
+ mockUpdateTask
+ .mockReturnValueOnce(firstUpdate.promise)
+ .mockReturnValueOnce(secondUpdate.promise);
+ const queryClient = createQueryClient();
+ const { result } = renderHook(
+ () => ({
+ tasks: useTasks(2026, 8),
+ unscheduledTasks: useUnscheduledTasks(),
+ first: useUpdateTask(2026, 8),
+ second: useUpdateTask(2026, 8),
+ }),
+ { wrapper: createWrapper(queryClient) },
+ );
+
+ await waitFor(() => {
+ expect(result.current.tasks.isSuccess).toBe(true);
+ expect(result.current.unscheduledTasks.isSuccess).toBe(true);
+ });
+ act(() => {
+ result.current.first.mutate({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ result.current.second.mutate({
+ id: scheduledTask.id,
+ data: { title: finalTask.title },
+ });
+ });
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(1));
+
+ firstUpdate.resolve(movedTask);
+ await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledTimes(2));
+
+ expect(mockFetchTasks).toHaveBeenCalledTimes(1);
+ expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(1);
+ expect(result.current.tasks.data).toEqual([]);
+ expect(result.current.unscheduledTasks.data).toEqual([finalTask]);
+
+ secondUpdate.resolve(finalTask);
+ await waitFor(() => {
+ expect(result.current.second.isSuccess).toBe(true);
+ expect(mockFetchTasks).toHaveBeenCalledTimes(2);
+ expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(2);
+ });
+ expect(result.current.tasks.data).toEqual([]);
+ expect(result.current.unscheduledTasks.data).toEqual([finalTask]);
+ });
+
+ it("成功時は購読queryの再取得完了を待ちserver確定値を一意に反映する", async () => {
+ const confirmedTask = {
+ ...scheduledTask,
+ title: "サーバー確定値",
+ date: null,
+ };
+ mockFetchTasks.mockResolvedValueOnce([scheduledTask]).mockResolvedValue([]);
+ mockFetchUnscheduledTasks
+ .mockResolvedValueOnce([])
+ .mockResolvedValue([confirmedTask]);
+ mockUpdateTask.mockResolvedValue(confirmedTask);
+ const queryClient = createQueryClient();
+ const { result } = renderHook(
+ () => ({
+ tasks: useTasks(2026, 8),
+ unscheduledTasks: useUnscheduledTasks(),
+ updateTask: useUpdateTask(2026, 8),
+ }),
+ { wrapper: createWrapper(queryClient) },
+ );
+
+ await waitFor(() => {
+ expect(result.current.tasks.isSuccess).toBe(true);
+ expect(result.current.unscheduledTasks.isSuccess).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.updateTask.mutateAsync({
+ id: scheduledTask.id,
+ data: { date: null },
+ });
+ });
+
+ expect(mockFetchTasks).toHaveBeenCalledTimes(2);
+ expect(mockFetchUnscheduledTasks).toHaveBeenCalledTimes(2);
+ expect(queryClient.getQueryData(rangeKey)).toEqual([]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([confirmedTask]);
+ await waitFor(() => {
+ expect(result.current.tasks.data).toEqual([]);
+ expect(result.current.unscheduledTasks.data).toEqual([confirmedTask]);
+ });
+ expect(
+ [
+ ...(result.current.tasks.data ?? []),
+ ...(result.current.unscheduledTasks.data ?? []),
+ ].filter((task) => task.id === scheduledTask.id),
+ ).toHaveLength(1);
+ });
+
+ it("日付を変えない更新では所属を維持して対象cacheの内容を更新する", async () => {
+ const queryClient = createQueryClient();
+ queryClient.setQueryData(rangeKey, [scheduledTask]);
+ queryClient.setQueryData(unscheduledKey, [unscheduledTask]);
+ mockUpdateTask.mockResolvedValue({
+ ...scheduledTask,
+ title: "更新済み",
+ description: "説明",
+ status: "done",
+ categoryId: "11111111-1111-4111-8111-111111111111",
+ });
+ const { result } = renderHook(() => useUpdateTask(2026, 8), {
+ wrapper: createWrapper(queryClient),
+ });
+ const data = {
+ title: "更新済み",
+ description: "説明",
+ status: "done" as const,
+ categoryId: "11111111-1111-4111-8111-111111111111",
+ };
+
+ await act(async () => {
+ await result.current.mutateAsync({ id: scheduledTask.id, data });
+ });
+
+ expect(queryClient.getQueryData(rangeKey)).toEqual([
+ { ...scheduledTask, ...data },
+ ]);
+ expect(queryClient.getQueryData(unscheduledKey)).toEqual([unscheduledTask]);
+ });
+});
diff --git a/apps/web/src/hooks/useTasks.ts b/apps/web/src/hooks/useTasks.ts
index 02ef60a..98b8f8e 100644
--- a/apps/web/src/hooks/useTasks.ts
+++ b/apps/web/src/hooks/useTasks.ts
@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import type { QueryClient } from "@tanstack/react-query";
import { getCalendarDateRange } from "@tascal/shared/calendar";
import type {
TaskCreateInput,
@@ -19,6 +20,46 @@ function tasksQueryKey(startDate: string, endDate: string) {
}
const unscheduledTasksQueryKey = ["tasks", "unscheduled"] as const;
+type TaskUpdateCoordinator = {
+ activeCount: number;
+ locks: Map>;
+};
+type TaskUpdateRegistration = {
+ coordinator: TaskUpdateCoordinator;
+ id: string;
+ releaseLock: () => void;
+ released: boolean;
+ tail: Promise;
+};
+type TaskSnapshot = {
+ hadData: boolean;
+ index: number;
+ task: Task | undefined;
+};
+const taskUpdateCoordinators = new WeakMap<
+ QueryClient,
+ TaskUpdateCoordinator
+>();
+
+function releaseTaskUpdate(
+ queryClient: QueryClient,
+ registration: TaskUpdateRegistration,
+) {
+ if (registration.released) return;
+ registration.released = true;
+ registration.releaseLock();
+
+ const { coordinator, id, tail } = registration;
+ if (coordinator.locks.get(id) === tail) {
+ coordinator.locks.delete(id);
+ }
+ coordinator.activeCount -= 1;
+
+ if (coordinator.activeCount === 0) {
+ taskUpdateCoordinators.delete(queryClient);
+ return queryClient.invalidateQueries({ queryKey: ["tasks"] });
+ }
+}
export function useTasks(year: number, month: number) {
const { startDate, endDate } = getCalendarDateRange(year, month);
@@ -82,30 +123,158 @@ export function useUpdateTask(year: number, month: number) {
const { startDate, endDate } = getCalendarDateRange(year, month);
const key = tasksQueryKey(startDate, endDate);
+ const isInCurrentRange = (date: string | null) =>
+ date !== null && startDate <= date && date <= endDate;
+
+ const snapshotTask = (tasks: Task[] | undefined, id: string) => {
+ const index = tasks?.findIndex((task) => task.id === id) ?? -1;
+ return {
+ hadData: tasks !== undefined,
+ index,
+ task: index >= 0 ? tasks?.[index] : undefined,
+ };
+ };
+
+ const restoreTask = (
+ queryKey: readonly string[],
+ id: string,
+ snapshot: TaskSnapshot,
+ ) => {
+ queryClient.setQueryData(queryKey, (current) => {
+ const tasks = (current ?? []).filter((task) => task.id !== id);
+ if (!snapshot.task) return tasks;
+
+ const index = Math.min(snapshot.index, tasks.length);
+ return [...tasks.slice(0, index), snapshot.task, ...tasks.slice(index)];
+ });
+
+ if (
+ !snapshot.hadData &&
+ queryClient.getQueryData(queryKey)?.length === 0
+ ) {
+ queryClient.removeQueries({ queryKey, exact: true });
+ }
+ };
+
return useMutation({
mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) =>
updateTask(id, data),
onMutate: async ({ id, data }) => {
- await queryClient.cancelQueries({ queryKey: key });
- const previous = queryClient.getQueryData(key);
+ const coordinator = taskUpdateCoordinators.get(queryClient) ?? {
+ activeCount: 0,
+ locks: new Map>(),
+ };
+ coordinator.activeCount += 1;
+ taskUpdateCoordinators.set(queryClient, coordinator);
- queryClient.setQueryData(key, (old) =>
- (old ?? []).map((task) =>
- task.id === id ? { ...task, ...data } : task,
- ),
- );
+ const previous = coordinator.locks.get(id) ?? Promise.resolve();
+ let release!: () => void;
+ const current = new Promise((resolve) => {
+ release = resolve;
+ });
+ const tail = previous.then(() => current);
+ coordinator.locks.set(id, tail);
+ const registration: TaskUpdateRegistration = {
+ coordinator,
+ id,
+ releaseLock: release,
+ released: false,
+ tail,
+ };
+ let previousTask: TaskSnapshot | undefined;
+ let previousUnscheduledTask: TaskSnapshot | undefined;
+
+ try {
+ await previous;
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: key }),
+ queryClient.cancelQueries({ queryKey: unscheduledTasksQueryKey }),
+ ]);
+
+ const previousTasks = queryClient.getQueryData(key);
+ const previousUnscheduledTasks = queryClient.getQueryData(
+ unscheduledTasksQueryKey,
+ );
+ const task = [
+ ...(previousTasks ?? []),
+ ...(previousUnscheduledTasks ?? []),
+ ].find((candidate) => candidate.id === id);
+ previousTask = snapshotTask(previousTasks, id);
+ previousUnscheduledTask = snapshotTask(previousUnscheduledTasks, id);
+
+ if (task && data.date !== undefined) {
+ const updatedTask = { ...task, ...data };
+ const removeUpdatedTask = (tasks: Task[] | undefined) =>
+ (tasks ?? []).filter((candidate) => candidate.id !== id);
+
+ queryClient.setQueryData(key, (old) => {
+ const tasks = removeUpdatedTask(old);
+ return isInCurrentRange(updatedTask.date)
+ ? [...tasks, updatedTask]
+ : tasks;
+ });
+ queryClient.setQueryData(unscheduledTasksQueryKey, (old) => {
+ const tasks = removeUpdatedTask(old);
+ return updatedTask.date ? tasks : [...tasks, updatedTask];
+ });
+ } else {
+ const applyUpdate = (tasks: Task[] | undefined) =>
+ tasks?.map((candidate) =>
+ candidate.id === id ? { ...candidate, ...data } : candidate,
+ ) ?? tasks;
+
+ queryClient.setQueryData(key, applyUpdate);
+ queryClient.setQueryData(
+ unscheduledTasksQueryKey,
+ applyUpdate,
+ );
+ }
- return { previous };
+ return { previousTask, previousUnscheduledTask, registration };
+ } catch (error) {
+ if (previousTask && previousUnscheduledTask) {
+ try {
+ restoreTask(key, id, previousTask);
+ } catch {
+ // Continue restoring the other cache and preserve the original error.
+ }
+ try {
+ restoreTask(unscheduledTasksQueryKey, id, previousUnscheduledTask);
+ } catch {
+ // Preserve the original onMutate error after best-effort rollback.
+ }
+ }
+ try {
+ await releaseTaskUpdate(queryClient, registration);
+ } catch {
+ // Preserve the original onMutate error if final invalidation fails.
+ }
+ throw error;
+ }
},
- onError: (_err, _variables, context) => {
+ onError: (_err, { id }, context) => {
if (context) {
- queryClient.setQueryData(key, context.previous);
+ try {
+ restoreTask(key, id, context.previousTask);
+ } catch {
+ // Keep the mutation error and continue restoring the other cache.
+ }
+ try {
+ restoreTask(
+ unscheduledTasksQueryKey,
+ id,
+ context.previousUnscheduledTask,
+ );
+ } catch {
+ // Keep the mutation error so onSettled can release the coordinator.
+ }
}
toast.error("タスクの更新に失敗しました");
},
- onSettled: () => {
- void queryClient.invalidateQueries({ queryKey: ["tasks"] });
- },
+ onSettled: (_data, _error, _variables, context) =>
+ context
+ ? releaseTaskUpdate(queryClient, context.registration)
+ : undefined,
});
}