diff --git a/apps/web/src/components/Calendar.test.tsx b/apps/web/src/components/Calendar.test.tsx index 3c21314..0070db8 100644 --- a/apps/web/src/components/Calendar.test.tsx +++ b/apps/web/src/components/Calendar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen, waitFor } from "@testing-library/react"; +import { act, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Calendar } from "./Calendar"; import { renderWithQueryClient } from "../test/helpers"; @@ -110,6 +110,18 @@ function getAdjacentDate(year: number, month: number) { return start.getMonth() !== month - 1 ? start : end; } +function createDeferredTasks() { + let resolve!: (tasks: (typeof mockTask)[]) => void; + let reject!: (error: Error) => void; + const promise = new Promise<(typeof mockTask)[]>( + (resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }, + ); + return { promise, reject, resolve }; +} + describe("Calendar", () => { beforeEach(() => { vi.clearAllMocks(); @@ -313,6 +325,92 @@ describe("Calendar", () => { }); }); + it("月切り替え直後は見出しとセルを更新しつつ直前のタスクを維持し、取得成功後に置き換える", async () => { + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth() + 1; + const nextYear = currentMonth === 12 ? currentYear + 1 : currentYear; + const nextMonth = currentMonth === 12 ? 1 : currentMonth + 1; + const nextRange = getDisplayedRange(nextYear, nextMonth); + const previousTask = { + ...mockTask, + title: "直前の範囲のタスク", + date: formatDate(nextRange.start), + }; + const nextTask = { + ...mockTask, + id: "next-range-task", + title: "新しい範囲のタスク", + date: formatDate(new Date(nextYear, nextMonth - 1, 15)), + }; + const nextFetch = createDeferredTasks(); + mockFetchTasks + .mockResolvedValueOnce([previousTask]) + .mockReturnValueOnce(nextFetch.promise); + const user = userEvent.setup(); + renderWithQueryClient(); + + expect(await screen.findByText(previousTask.title)).toBeInTheDocument(); + + await user.click(screen.getByText("→")); + + expect(screen.getByText(`${nextYear}年${nextMonth}月`)).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: `${formatDate(nextRange.end)}にタスクを追加`, + }), + ).toBeInTheDocument(); + expect(screen.getByText(previousTask.title)).toBeInTheDocument(); + + const calendar = screen.getByText("月").parentElement?.parentElement; + expect(calendar).toHaveAttribute("aria-busy", "true"); + expect(calendar).toHaveAttribute("data-task-data-state", "placeholder"); + expect(calendar).not.toHaveClass("opacity-50"); + + act(() => nextFetch.resolve([nextTask])); + + await waitFor(() => { + expect(screen.getByText(nextTask.title)).toBeInTheDocument(); + expect(screen.queryByText(previousTask.title)).not.toBeInTheDocument(); + expect(calendar).toHaveAttribute("aria-busy", "false"); + expect(calendar).toHaveAttribute("data-task-data-state", "settled"); + }); + }); + + it("月切り替え後の取得失敗を表示し、直前のタスクを確定データとして残さない", async () => { + const now = new Date(); + const currentMonth = now.getMonth() + 1; + const nextYear = + currentMonth === 12 ? now.getFullYear() + 1 : now.getFullYear(); + const nextMonth = currentMonth === 12 ? 1 : currentMonth + 1; + const nextRange = getDisplayedRange(nextYear, nextMonth); + const previousTask = { + ...mockTask, + title: "取得失敗時に維持されるタスク", + date: formatDate(nextRange.start), + }; + const nextFetch = createDeferredTasks(); + mockFetchTasks + .mockResolvedValueOnce([previousTask]) + .mockReturnValueOnce(nextFetch.promise); + const user = userEvent.setup(); + renderWithQueryClient(); + + expect(await screen.findByText(previousTask.title)).toBeInTheDocument(); + await user.click(screen.getByText("→")); + + act(() => nextFetch.reject(new Error("API error"))); + + expect( + await screen.findByText("タスクの取得に失敗しました"), + ).toBeInTheDocument(); + expect(screen.queryByText(previousTask.title)).not.toBeInTheDocument(); + + const calendar = screen.getByText("月").parentElement?.parentElement; + expect(calendar).toHaveAttribute("aria-busy", "false"); + expect(calendar).toHaveAttribute("data-task-data-state", "error"); + }); + it("「今日」ボタンで今月に戻れる", async () => { const user = userEvent.setup(); renderWithQueryClient(); diff --git a/apps/web/src/components/Calendar.tsx b/apps/web/src/components/Calendar.tsx index f05bf3b..f764147 100644 --- a/apps/web/src/components/Calendar.tsx +++ b/apps/web/src/components/Calendar.tsx @@ -83,7 +83,9 @@ export function Calendar() { const { data: tasks = [], + isFetching, isLoading, + isPlaceholderData, error: queryError, } = useTasks(year, month); @@ -207,6 +209,13 @@ export function Calendar() { queryError || unscheduledError ? "タスクの取得に失敗しました" : mutationError; + const taskDataState = queryError + ? "error" + : isPlaceholderData + ? "placeholder" + : isLoading + ? "loading" + : "settled"; return ( setAddUnscheduled(true)} />
diff --git a/apps/web/src/hooks/useTasks.test.tsx b/apps/web/src/hooks/useTasks.test.tsx index 24e9ea2..1db0e22 100644 --- a/apps/web/src/hooks/useTasks.test.tsx +++ b/apps/web/src/hooks/useTasks.test.tsx @@ -84,6 +84,75 @@ function createDeferredUpdate() { return { promise, reject, resolve }; } +function createDeferredTasks() { + let resolve!: (tasks: Task[]) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +describe("useTasks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("初回取得中は存在しない前回データをplaceholderとして表示しない", () => { + const initialFetch = createDeferredTasks(); + mockFetchTasks.mockReturnValue(initialFetch.promise); + const queryClient = createQueryClient(); + const { result } = renderHook(() => useTasks(2026, 8), { + wrapper: createWrapper(queryClient), + }); + + expect(result.current.data).toBeUndefined(); + expect(result.current.isPlaceholderData).toBe(false); + expect(result.current.isFetching).toBe(true); + }); + + it("範囲変更中は直前データを維持し、成功時に新しいデータへ置き換える", async () => { + const initialTask = scheduledTask; + const nextTask = { + ...scheduledTask, + id: "next-task", + title: "次の範囲", + date: "2026-09-15", + }; + const nextFetch = createDeferredTasks(); + mockFetchTasks + .mockResolvedValueOnce([initialTask]) + .mockReturnValueOnce(nextFetch.promise); + const queryClient = createQueryClient(); + const { result, rerender } = renderHook( + ({ month }) => useTasks(2026, month), + { + initialProps: { month: 8 }, + wrapper: createWrapper(queryClient), + }, + ); + + await waitFor(() => expect(result.current.data).toEqual([initialTask])); + + rerender({ month: 9 }); + + await waitFor(() => { + expect(result.current.data).toEqual([initialTask]); + expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.isFetching).toBe(true); + }); + + nextFetch.resolve([nextTask]); + + await waitFor(() => { + expect(result.current.data).toEqual([nextTask]); + expect(result.current.isPlaceholderData).toBe(false); + expect(result.current.isFetching).toBe(false); + }); + }); +}); + describe("useUpdateTask", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/web/src/hooks/useTasks.ts b/apps/web/src/hooks/useTasks.ts index 98b8f8e..58b8502 100644 --- a/apps/web/src/hooks/useTasks.ts +++ b/apps/web/src/hooks/useTasks.ts @@ -1,4 +1,9 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query"; import { getCalendarDateRange } from "@tascal/shared/calendar"; import type { @@ -67,6 +72,7 @@ export function useTasks(year: number, month: number) { return useQuery({ queryKey: tasksQueryKey(startDate, endDate), queryFn: ({ signal }) => fetchTasks(startDate, endDate, signal), + placeholderData: keepPreviousData, }); }