Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1e5b7a6
正規表現定数を constants.ts に集約
max747 Feb 19, 2026
ee24331
excludedIds 生成をユーティリティ関数に抽出
max747 Feb 19, 2026
73c9132
ローディング/エラー表示を LoadingError コンポーネントに共通化
max747 Feb 19, 2026
ae09394
ソート管理ロジックを useSortState / useFixedSortState フックに抽出
max747 Feb 19, 2026
b89e337
useMemo で再計算をメモ化(ReportTable・ReporterSummary)
max747 Feb 19, 2026
239bb6a
MAX_EVENT_BONUS を constants.ts に集約
max747 Feb 19, 2026
a5917a0
formatNote/formatItemHeader を formatters.tsx に移動
max747 Feb 19, 2026
049426f
SortDir 型を types.ts に集約
max747 Feb 19, 2026
19d10fb
アコーディオン展開状態を useToggleSet フックに抽出
max747 Feb 19, 2026
1288888
XIdLink コンポーネントを抽出(ReporterSummary)
max747 Feb 19, 2026
bd6ef66
update biome to v2
max747 Feb 19, 2026
005cc8f
fix lint error
max747 Feb 19, 2026
fa12547
DetailTable の key を配列 index から reportId に変更
max747 Feb 19, 2026
ef3faed
getHighestQuest: "90+" レベル文字列の NaN を修正
max747 Feb 19, 2026
a3cab66
QuestView: 集計計算を useMemo でキャッシュして不要な再計算を防ぐ
max747 Feb 19, 2026
b8e5287
useEffect の fetch に AbortController を導入してメモリリークを防ぐ
max747 Feb 19, 2026
db5342e
finally: abort 済みの場合は setLoading(false) をスキップ
max747 Feb 19, 2026
bcb348d
parseLevel を export し、クエストソートの NaN 問題を修正
max747 Feb 19, 2026
91e3075
formatNote / formatItemHeader のテストを追加
max747 Feb 19, 2026
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
4 changes: 2 additions & 2 deletions admin/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type SignInInput, getCurrentUser, signIn, signOut } from "aws-amplify/auth";
import { type ReactNode, createContext, useContext, useEffect, useState } from "react";
import { getCurrentUser, type SignInInput, signIn, signOut } from "aws-amplify/auth";
import { createContext, type ReactNode, useContext, useEffect, useState } from "react";

interface AuthContextValue {
isAuthenticated: boolean;
Expand Down
4 changes: 2 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
"files": {
"include": ["viewer/src/**", "admin/src/**"]
"includes": ["**/viewer/src/**", "**/admin/src/**"]
},
"formatter": {
"indentStyle": "space",
Expand Down
73 changes: 36 additions & 37 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"fix": "biome check --write ."
},
"devDependencies": {
"@biomejs/biome": "^1"
"@biomejs/biome": "^2"
}
}
17 changes: 12 additions & 5 deletions viewer/src/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { Outlet, useMatch, useNavigate, useParams } from "react-router-dom";
import { fetchEvents, fetchExclusions } from "./api";
import { formatPeriod } from "./formatters";
import { getHighestQuest } from "./routeUtils";
import { getHighestQuest, parseLevel } from "./routeUtils";
import type { EventData, ExclusionsMap } from "./types";

const navBtnStyle: React.CSSProperties = {
Expand Down Expand Up @@ -32,13 +32,20 @@ export function AppLayout() {
const eventItemSummaryMatch = useMatch("/events/:eventId/event-items");

useEffect(() => {
Promise.all([fetchEvents(), fetchExclusions()])
const controller = new AbortController();
Promise.all([fetchEvents(controller.signal), fetchExclusions(controller.signal)])
.then(([eventsRes, exclusionsRes]) => {
setEvents(eventsRes.events);
setExclusions(exclusionsRes);
})
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
.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>;
Expand Down Expand Up @@ -92,7 +99,7 @@ export function AppLayout() {
{selectedEvent && selectedEvent.quests.length > 0 && (
<div style={{ marginBottom: "1rem" }}>
{[...selectedEvent.quests]
.sort((a, b) => Number(a.level) - Number(b.level))
.sort((a, b) => parseLevel(a.level) - parseLevel(b.level))
.map((q) => (
<button
type="button"
Expand Down
13 changes: 7 additions & 6 deletions viewer/src/aggregate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RE_EVENT_ITEM, RE_POINT, RE_QP } from "./constants";
import type { Exclusion, ItemOutlierStats, ItemStats, Report } from "./types";

const Z = 1.96; // 95% confidence
Expand All @@ -15,8 +16,12 @@ function wilsonCI(successes: number, n: number): { lower: number; upper: number
};
}

export function createExcludedIdSet(exclusions: Exclusion[]): Set<string> {
return new Set(exclusions.map((e) => e.reportId));
}

export function aggregate(reports: Report[], exclusions: Exclusion[]): ItemStats[] {
const excludedIds = new Set(exclusions.map((e) => e.reportId));
const excludedIds = createExcludedIdSet(exclusions);
const validReports = reports.filter((r) => !excludedIds.has(r.id));

const itemNames = new Set<string>();
Expand Down Expand Up @@ -52,16 +57,12 @@ export function aggregate(reports: Report[], exclusions: Exclusion[]): ItemStats
return stats;
}

const RE_EVENT_ITEM = /\(x(\d+)\)$/;
const RE_POINT = /^ポイント\(\+(\d+)\)$/;
const RE_QP = /^QP\(\+(\d+)\)$/;

function isAlwaysTargetItem(itemName: string): boolean {
return RE_EVENT_ITEM.test(itemName) || RE_POINT.test(itemName) || RE_QP.test(itemName);
}

export function calcOutlierStats(reports: Report[], exclusions: Exclusion[]): ItemOutlierStats[] {
const excludedIds = new Set(exclusions.map((e) => e.reportId));
const excludedIds = createExcludedIdSet(exclusions);
const validReports = reports.filter((r) => !excludedIds.has(r.id));

const itemNames = new Set<string>();
Expand Down
16 changes: 10 additions & 6 deletions viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import type { EventsResponse, ExclusionsMap, QuestData } from "./types";

const DATA_URL = import.meta.env.VITE_DATA_URL as string;

export async function fetchEvents(): Promise<EventsResponse> {
const res = await fetch(`${DATA_URL}/events.json`);
export async function fetchEvents(signal?: AbortSignal): Promise<EventsResponse> {
const res = await fetch(`${DATA_URL}/events.json`, { signal });
if (!res.ok) throw new Error(`Failed to fetch events: ${res.status}`);
return res.json();
}

export async function fetchExclusions(): Promise<ExclusionsMap> {
const res = await fetch(`${DATA_URL}/exclusions.json`);
export async function fetchExclusions(signal?: AbortSignal): Promise<ExclusionsMap> {
const res = await fetch(`${DATA_URL}/exclusions.json`, { signal });
if (!res.ok) return {};
return res.json();
}

export async function fetchQuestData(eventId: string, questId: string): Promise<QuestData | null> {
const res = await fetch(`${DATA_URL}/${eventId}/${questId}.json`);
export async function fetchQuestData(
eventId: string,
questId: string,
signal?: AbortSignal,
): Promise<QuestData | null> {
const res = await fetch(`${DATA_URL}/${eventId}/${questId}.json`, { signal });
// S3 + CloudFront では未作成のオブジェクトに対して 403 が返るため、
// 404 と同様に「データ未登録」として扱い、エラーではなく null を返す
if (res.status === 403 || res.status === 404) return null;
Expand Down
3 changes: 1 addition & 2 deletions viewer/src/components/EventItemSummaryView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MAX_EVENT_BONUS } from "../constants";
import type { EventItemExpected } from "../summaryUtils";
import type { Quest } from "../types";
import { tableStyle, tdStyle, tdStyleRight, thStyle } from "./tableUtils";
Expand All @@ -11,8 +12,6 @@ interface Props {
questExpected: QuestExpected[];
}

const MAX_EVENT_BONUS = 12;

const thStyleNarrow: React.CSSProperties = {
...thStyle,
width: "4em",
Expand Down
10 changes: 10 additions & 0 deletions viewer/src/components/LoadingError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
interface Props {
loading: boolean;
error: string | null;
}

export function LoadingError({ loading, error }: Props) {
if (loading) return <p>読み込み中...</p>;
if (error) return <p style={{ color: "red" }}>エラー: {error}</p>;
return null;
}
68 changes: 45 additions & 23 deletions viewer/src/components/QuestView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { aggregate, calcOutlierStats } from "../aggregate";
import { useEffect, useMemo, useState } from "react";
import { aggregate, calcOutlierStats, createExcludedIdSet } from "../aggregate";
import { fetchQuestData } from "../api";
import { formatTimestamp } from "../formatters";
import type { Exclusion, QuestData } from "../types";
import { LoadingError } from "./LoadingError";
import { ReportTable } from "./ReportTable";
import { StatsBar } from "./StatsBar";
import { SummaryTable } from "./SummaryTable";
Expand All @@ -19,33 +20,54 @@ export function QuestView({ eventId, questId, exclusions }: Props) {
const [loading, setLoading] = useState(true);

useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetchQuestData(eventId, questId)
fetchQuestData(eventId, questId, controller.signal)
.then(setData)
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
.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]);

if (loading) return <p>読み込み中...</p>;
if (error) return <p style={{ color: "red" }}>エラー: {error}</p>;
if (!data) return <p>このクエストのデータはまだ登録されていません。</p>;

const stats = aggregate(data.reports, exclusions);
const outlierStats = calcOutlierStats(data.reports, exclusions);
const excludedIds = new Set(exclusions.map((e) => e.reportId));
const totalRuns = data.reports
.filter((r) => !excludedIds.has(r.id))
.reduce((sum, r) => sum + r.runcount, 0);
const validCount = data.reports.filter((r) => !excludedIds.has(r.id)).length;

const itemNames = new Set<string>();
for (const report of data.reports) {
for (const key of Object.keys(report.items)) {
itemNames.add(key);
const stats = useMemo(
() => (data ? aggregate(data.reports, exclusions) : []),
[data, exclusions],
);
const outlierStats = useMemo(
() => (data ? calcOutlierStats(data.reports, exclusions) : []),
[data, exclusions],
);
const excludedIds = useMemo(() => createExcludedIdSet(exclusions), [exclusions]);
const totalRuns = useMemo(
() =>
data
? data.reports.filter((r) => !excludedIds.has(r.id)).reduce((sum, r) => sum + r.runcount, 0)
: 0,
[data, excludedIds],
);
const validCount = useMemo(
() => (data ? data.reports.filter((r) => !excludedIds.has(r.id)).length : 0),
[data, excludedIds],
);
const sortedItemNames = useMemo(() => {
if (!data) return [];
const itemNames = new Set<string>();
for (const report of data.reports) {
for (const key of Object.keys(report.items)) {
itemNames.add(key);
}
}
}
const sortedItemNames = [...itemNames];
return [...itemNames];
}, [data]);

if (loading || error) return <LoadingError loading={loading} error={error} />;
if (!data) return <p>このクエストのデータはまだ登録されていません。</p>;

return (
<div>
Expand Down
Loading