Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion apps/web/src/components/Calendar.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(<Calendar />);

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(<Calendar />);

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(<Calendar />);
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export function Calendar() {

const {
data: tasks = [],
isFetching,
isLoading,
isPlaceholderData,
error: queryError,
} = useTasks(year, month);

Expand Down Expand Up @@ -207,6 +209,13 @@ export function Calendar() {
queryError || unscheduledError
? "タスクの取得に失敗しました"
: mutationError;
const taskDataState = queryError
? "error"
: isPlaceholderData
? "placeholder"
: isLoading
? "loading"
: "settled";

return (
<DndContext
Expand Down Expand Up @@ -288,6 +297,8 @@ export function Calendar() {
onAddClick={() => setAddUnscheduled(true)}
/>
<div
aria-busy={isFetching}
data-task-data-state={taskDataState}
className={`min-w-0 flex-1 overflow-hidden rounded-lg border border-border-light ${isLoading ? "opacity-50" : ""}`}
>
<div className="grid grid-cols-7">
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/hooks/useTasks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task[]>((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();
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/hooks/useTasks.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
});
}

Expand Down
Loading