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
748 changes: 748 additions & 0 deletions viewer/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
"react-router-dom": "^7.13.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^28.1.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^4.0.18"
Expand Down
35 changes: 13 additions & 22 deletions viewer/src/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type React from "react";
import { useEffect, useState } from "react";
import { Outlet, useMatch, useNavigate, useParams } from "react-router-dom";
import { fetchEvents, fetchExclusions } from "./api";
import { formatPeriod } from "./formatters";
import { useFetchData } from "./hooks/useFetchData";
import { getHighestQuest, parseLevel } from "./routeUtils";
import type { EventData, ExclusionsMap } from "./types";

Expand All @@ -21,33 +21,24 @@ export interface LayoutContext {
}

export function AppLayout() {
const [events, setEvents] = useState<EventData[]>([]);
const [exclusions, setExclusions] = useState<ExclusionsMap>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { data, loading, error } = useFetchData(
async (signal) => {
const [eventsRes, exclusionsRes] = await Promise.all([
fetchEvents(signal),
fetchExclusions(signal),
]);
return { events: eventsRes.events, exclusions: exclusionsRes };
},
[],
{ events: [] as EventData[], exclusions: {} as ExclusionsMap },
);
const { events, exclusions } = data;

const navigate = useNavigate();
const { eventId, questId } = useParams<{ eventId: string; questId: string }>();
const reportersMatch = useMatch("/events/:eventId/reporters");
const eventItemSummaryMatch = useMatch("/events/:eventId/event-items");

useEffect(() => {
const controller = new AbortController();
Promise.all([fetchEvents(controller.signal), fetchExclusions(controller.signal)])
.then(([eventsRes, exclusionsRes]) => {
setEvents(eventsRes.events);
setExclusions(exclusionsRes);
})
.catch((e: unknown) => {
if (e instanceof DOMException && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);

if (loading) return <p>読み込み中...</p>;
if (error) return <p style={{ color: "red" }}>エラー: {error}</p>;
if (events.length === 0) return <p>イベントが登録されていません。</p>;
Expand Down
29 changes: 8 additions & 21 deletions viewer/src/components/QuestView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { aggregate, calcOutlierStats, createExcludedIdSet } from "../aggregate";
import { fetchQuestData } from "../api";
import { formatTimestamp } from "../formatters";
import type { Exclusion, QuestData } from "../types";
import { useFetchData } from "../hooks/useFetchData";
import type { Exclusion } from "../types";
import { LoadingError } from "./LoadingError";
import { ReportTable } from "./ReportTable";
import { StatsBar } from "./StatsBar";
Expand All @@ -15,25 +16,11 @@ interface Props {
}

export function QuestView({ eventId, questId, exclusions }: Props) {
const [data, setData] = useState<QuestData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetchQuestData(eventId, questId, controller.signal)
.then(setData)
.catch((e: unknown) => {
if (e instanceof DOMException && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [eventId, questId]);
const { data, loading, error } = useFetchData(
(signal) => fetchQuestData(eventId, questId, signal),
[eventId, questId],
null,
);

const stats = useMemo(
() => (data ? aggregate(data.reports, exclusions) : []),
Expand Down
34 changes: 14 additions & 20 deletions viewer/src/components/ReporterSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo } from "react";
import { fetchQuestData } from "../api";
import { formatTimestamp } from "../formatters";
import { useFetchData } from "../hooks/useFetchData";
import { useFixedSortState } from "../hooks/useSortState";
import { useToggleSet } from "../hooks/useToggleSet";
import type { ReportDetail, SortKey } from "../reporterSummaryUtils";
Expand Down Expand Up @@ -96,28 +97,21 @@ function DetailTable({ details }: { details: ReportDetail[] }) {
}

export function ReporterSummary({ eventId, quests, exclusions }: Props) {
const [questData, setQuestData] = useState<QuestData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
data: questData,
loading,
error,
} = useFetchData(
(signal) =>
Promise.all(quests.map((q) => fetchQuestData(eventId, q.questId, signal))).then((results) =>
results.filter((d): d is QuestData => d !== null),
),
[eventId, quests],
[] as QuestData[],
);
const { sort, toggleSort } = useFixedSortState<SortKey>(DEFAULT_SORT);
const { set: expanded, toggle: toggleExpanded } = useToggleSet();

useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
Promise.all(quests.map((q) => fetchQuestData(eventId, q.questId, controller.signal)))
.then((results) => setQuestData(results.filter((d): d is QuestData => d !== null)))
.catch((e: unknown) => {
if (e instanceof DOMException && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [eventId, quests]);

const rawRows = useMemo(() => aggregateReporters(questData, exclusions), [questData, exclusions]);
const rows = useMemo(() => sortRows(rawRows, sort), [rawRows, sort]);

Expand Down
69 changes: 69 additions & 0 deletions viewer/src/hooks/useFetchData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useFetchData } from "./useFetchData";

describe("useFetchData", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("初期状態は loading: true, data: initialData", () => {
const fetcher = vi.fn((_signal: AbortSignal) => new Promise<string>(() => {}));
const { result } = renderHook(() => useFetchData(fetcher, [], "initial"));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe("initial");
expect(result.current.error).toBeNull();
});

test("fetch 成功時に data が更新され loading が false になる", async () => {
const fetcher = vi.fn((_signal: AbortSignal) => Promise.resolve("fetched"));
const { result } = renderHook(() => useFetchData(fetcher, [], "initial"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toBe("fetched");
expect(result.current.error).toBeNull();
});

test("fetch 失敗時に error が設定され loading が false になる", async () => {
const fetcher = vi.fn((_signal: AbortSignal) => Promise.reject(new Error("fetch failed")));
const { result } = renderHook(() => useFetchData(fetcher, [], "initial"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("fetch failed");
expect(result.current.data).toBe("initial");
});

test("AbortError は error に設定されない", async () => {
const abortError = new DOMException("Aborted", "AbortError");
const fetcher = vi.fn((_signal: AbortSignal) => Promise.reject(abortError));
const { result } = renderHook(() => useFetchData(fetcher, [], "initial"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
});

test("deps が変わると fetcher が再実行される", async () => {
let questId = "q1";
const fetcher = vi.fn((_signal: AbortSignal) => Promise.resolve(`data-${questId}`));
const { result, rerender } = renderHook(() => useFetchData(fetcher, [questId], "initial"));
await waitFor(() => expect(result.current.data).toBe("data-q1"));

questId = "q2";
rerender();
await waitFor(() => expect(result.current.data).toBe("data-q2"));
});

test("アンマウント時に fetch が中断される", async () => {
let aborted = false;
const fetcher = vi.fn(
(signal: AbortSignal) =>
new Promise<string>((resolve) => {
signal.addEventListener("abort", () => {
aborted = true;
});
setTimeout(() => resolve("done"), 1000);
}),
);
const { unmount } = renderHook(() => useFetchData(fetcher, [], "initial"));
unmount();
expect(aborted).toBe(true);
});
});
38 changes: 38 additions & 0 deletions viewer/src/hooks/useFetchData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type React from "react";
import { useEffect, useState } from "react";

/**
* AbortController・loading/error 状態管理を共通化する汎用フェッチフック。
* @param fetcher AbortSignal を受け取り Promise を返す非同期関数
* @param deps fetcher の再実行トリガーとなる依存配列(呼び出し側が管理する)
* @param initialData フェッチ完了前に返す初期値
* @returns data・loading・error の状態オブジェクト
*/
export function useFetchData<T>(
fetcher: (signal: AbortSignal) => Promise<T>,
deps: React.DependencyList,
initialData: T,
): { data: T; loading: boolean; error: string | null } {
Comment on lines +11 to +15
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing JSDoc documentation. The codebase consistently uses JSDoc comments for exported functions and custom hooks (see useSortState, useToggleSet, formatNote, etc.). Add a JSDoc comment describing the hook's purpose, parameters, and behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘のとおりです。useSortState・useToggleSet と同様に JSDoc コメントを追加しました。

const [data, setData] = useState<T>(initialData);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetcher(controller.signal)
.then(setData)
.catch((e: unknown) => {
if (e instanceof DOMException && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
// biome-ignore lint/correctness/useExhaustiveDependencies: caller controls deps
}, deps);

return { data, loading, error };
}
75 changes: 33 additions & 42 deletions viewer/src/pages/EventItemSummaryPage.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,54 @@
import { useEffect, useState } from "react";
import { Navigate, useOutletContext, useParams } from "react-router-dom";
import type { LayoutContext } from "../AppLayout";
import { aggregate } from "../aggregate";
import { fetchQuestData } from "../api";
import { EventItemSummaryView, type QuestExpected } from "../components/EventItemSummaryView";
import { LoadingError } from "../components/LoadingError";
import { useFetchData } from "../hooks/useFetchData";
import { parseLevel } from "../routeUtils";
import { calcEventItemExpected, classifyStats } from "../summaryUtils";

export function EventItemSummaryPage() {
const { eventId } = useParams<{ eventId: string }>();
const { events, exclusions } = useOutletContext<LayoutContext>();

const [questExpected, setQuestExpected] = useState<QuestExpected[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
data: questExpected,
loading,
error,
} = useFetchData(
async (signal) => {
if (!eventId) return [];
const event = events.find((e) => e.eventId === eventId);
if (!event) return [];

useEffect(() => {
if (!eventId) return;
const event = events.find((e) => e.eventId === eventId);
if (!event) return;
const sortedQuests = [...event.quests].sort(
(a, b) => parseLevel(a.level) - parseLevel(b.level),
);

const controller = new AbortController();
setLoading(true);
setError(null);
const results = await Promise.all(
sortedQuests.map((q) => fetchQuestData(event.eventId, q.questId, signal)),
);

const sortedQuests = [...event.quests].sort(
(a, b) => parseLevel(a.level) - parseLevel(b.level),
);
const qe: QuestExpected[] = [];
for (let i = 0; i < sortedQuests.length; i++) {
const data = results[i];
if (data === null) continue;

Promise.all(
sortedQuests.map((q) => fetchQuestData(event.eventId, q.questId, controller.signal)),
)
.then((results) => {
const qe: QuestExpected[] = [];
for (let i = 0; i < sortedQuests.length; i++) {
const data = results[i];
if (data === null) continue;

const quest = sortedQuests[i];
const questExclusions = exclusions[quest.questId] ?? [];
const stats = aggregate(data.reports, questExclusions);
const { eventItems } = classifyStats(stats);
const expected = calcEventItemExpected(eventItems);
if (expected.length > 0) {
qe.push({ quest, data: expected });
}
const quest = sortedQuests[i];
const questExclusions = exclusions[quest.questId] ?? [];
const stats = aggregate(data.reports, questExclusions);
const { eventItems } = classifyStats(stats);
const expected = calcEventItemExpected(eventItems);
if (expected.length > 0) {
qe.push({ quest, data: expected });
}
setQuestExpected(qe);
})
.catch((e: unknown) => {
if (e instanceof DOMException && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [eventId, events, exclusions]);
}
return qe;
},
[eventId, events, exclusions],
[],
);

if (!eventId) return <Navigate to="/" replace />;
const event = events.find((e) => e.eventId === eventId);
Expand Down