From 1e5b7a6467b26f767065c2612d0105c7cf0703e1 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 20:49:21 +0900 Subject: [PATCH 01/19] =?UTF-8?q?=E6=AD=A3=E8=A6=8F=E8=A1=A8=E7=8F=BE?= =?UTF-8?q?=E5=AE=9A=E6=95=B0=E3=82=92=20constants.ts=20=E3=81=AB=E9=9B=86?= =?UTF-8?q?=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aggregate.ts と summaryUtils.ts に重複定義されていたアイテム分類用の 正規表現(イベントアイテム・ポイント・QP)を constants.ts に一元化した。 変数名を RE_EVENT_ITEM / RE_POINT / RE_QP に統一。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/aggregate.ts | 5 +---- viewer/src/constants.ts | 8 ++++++++ viewer/src/summaryUtils.ts | 23 ++++++++++------------- 3 files changed, 19 insertions(+), 17 deletions(-) create mode 100644 viewer/src/constants.ts diff --git a/viewer/src/aggregate.ts b/viewer/src/aggregate.ts index 14eda31..6b4e40a 100644 --- a/viewer/src/aggregate.ts +++ b/viewer/src/aggregate.ts @@ -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 @@ -52,10 +53,6 @@ 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); } diff --git a/viewer/src/constants.ts b/viewer/src/constants.ts new file mode 100644 index 0000000..7e72df7 --- /dev/null +++ b/viewer/src/constants.ts @@ -0,0 +1,8 @@ +/** イベントアイテム例: "ミトン(x3)" */ +export const RE_EVENT_ITEM = /\(x(\d+)\)$/; + +/** ポイント例: "ポイント(+600)" */ +export const RE_POINT = /^ポイント\(\+(\d+)\)$/; + +/** QP例: "QP(+150000)" */ +export const RE_QP = /^QP\(\+(\d+)\)$/; diff --git a/viewer/src/summaryUtils.ts b/viewer/src/summaryUtils.ts index 174f1d0..4883b6a 100644 --- a/viewer/src/summaryUtils.ts +++ b/viewer/src/summaryUtils.ts @@ -1,9 +1,6 @@ +import { RE_EVENT_ITEM, RE_POINT, RE_QP } from "./constants"; import type { ItemStats } from "./types"; -const RE_BOX_COUNT = /\(x(\d+)\)$/; -const RE_QP_BONUS = /^QP\(\+(\d+)\)$/; -const RE_POINT_BONUS = /^ポイント\(\+(\d+)\)$/; - export function classifyStats(stats: ItemStats[]) { const normal: ItemStats[] = []; const eventItems: ItemStats[] = []; @@ -11,11 +8,11 @@ export function classifyStats(stats: ItemStats[]) { const qp: ItemStats[] = []; for (const s of stats) { - if (RE_BOX_COUNT.test(s.itemName)) { + if (RE_EVENT_ITEM.test(s.itemName)) { eventItems.push(s); - } else if (RE_QP_BONUS.test(s.itemName)) { + } else if (RE_QP.test(s.itemName)) { qp.push(s); - } else if (RE_POINT_BONUS.test(s.itemName)) { + } else if (RE_POINT.test(s.itemName)) { points.push(s); } else { normal.push(s); @@ -26,19 +23,19 @@ export function classifyStats(stats: ItemStats[]) { } export function extractBaseName(name: string): string { - const mBox = RE_BOX_COUNT.exec(name); + const mBox = RE_EVENT_ITEM.exec(name); if (mBox) return name.slice(0, mBox.index); - if (RE_POINT_BONUS.test(name)) return "ポイント"; - if (RE_QP_BONUS.test(name)) return "QP"; + if (RE_POINT.test(name)) return "ポイント"; + if (RE_QP.test(name)) return "QP"; return name; } export function extractModifier(name: string): number { - const mBox = RE_BOX_COUNT.exec(name); + const mBox = RE_EVENT_ITEM.exec(name); if (mBox) return Number.parseInt(mBox[1], 10); - const mPoint = RE_POINT_BONUS.exec(name); + const mPoint = RE_POINT.exec(name); if (mPoint) return Number.parseInt(mPoint[1], 10); - const mQp = RE_QP_BONUS.exec(name); + const mQp = RE_QP.exec(name); if (mQp) return Number.parseInt(mQp[1], 10); return 0; } From ee24331fc69116a2e343379b44f8844e73590650 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 20:50:48 +0900 Subject: [PATCH 02/19] =?UTF-8?q?excludedIds=20=E7=94=9F=E6=88=90=E3=82=92?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=83=86=E3=82=A3=E3=83=AA=E3=83=86=E3=82=A3?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=81=AB=E6=8A=BD=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aggregate.ts・ReportTable.tsx・QuestView.tsx の4箇所で重複していた `new Set(exclusions.map((e) => e.reportId))` を createExcludedIdSet() として aggregate.ts に export し、 各利用箇所からその関数を呼び出すよう統一した。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/aggregate.ts | 8 ++++++-- viewer/src/components/QuestView.tsx | 4 ++-- viewer/src/components/ReportTable.tsx | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/viewer/src/aggregate.ts b/viewer/src/aggregate.ts index 6b4e40a..5c53aee 100644 --- a/viewer/src/aggregate.ts +++ b/viewer/src/aggregate.ts @@ -16,8 +16,12 @@ function wilsonCI(successes: number, n: number): { lower: number; upper: number }; } +export function createExcludedIdSet(exclusions: Exclusion[]): Set { + 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(); @@ -58,7 +62,7 @@ function isAlwaysTargetItem(itemName: string): boolean { } 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(); diff --git a/viewer/src/components/QuestView.tsx b/viewer/src/components/QuestView.tsx index cedb579..80238b9 100644 --- a/viewer/src/components/QuestView.tsx +++ b/viewer/src/components/QuestView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { aggregate, calcOutlierStats } from "../aggregate"; +import { aggregate, calcOutlierStats, createExcludedIdSet } from "../aggregate"; import { fetchQuestData } from "../api"; import { formatTimestamp } from "../formatters"; import type { Exclusion, QuestData } from "../types"; @@ -33,7 +33,7 @@ export function QuestView({ eventId, questId, exclusions }: Props) { const stats = aggregate(data.reports, exclusions); const outlierStats = calcOutlierStats(data.reports, exclusions); - const excludedIds = new Set(exclusions.map((e) => e.reportId)); + const excludedIds = createExcludedIdSet(exclusions); const totalRuns = data.reports .filter((r) => !excludedIds.has(r.id)) .reduce((sum, r) => sum + r.runcount, 0); diff --git a/viewer/src/components/ReportTable.tsx b/viewer/src/components/ReportTable.tsx index 9cc2052..50b3489 100644 --- a/viewer/src/components/ReportTable.tsx +++ b/viewer/src/components/ReportTable.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { isOutlier } from "../aggregate"; +import { createExcludedIdSet, isOutlier } from "../aggregate"; import { formatTimestamp } from "../formatters"; import { sortReports } from "../reportTableUtils"; import type { SortKey, SortState } from "../reportTableUtils"; @@ -55,7 +55,7 @@ function formatItemHeader(name: string): React.ReactNode { export function ReportTable({ reports, exclusions, itemNames, outlierStats, stats }: Props) { const [sort, setSort] = useState(null); - const excludedIds = new Set(exclusions.map((e) => e.reportId)); + const excludedIds = createExcludedIdSet(exclusions); const exclusionMap = new Map(exclusions.map((e) => [e.reportId, e.reason])); const outlierMap = new Map(outlierStats.map((s) => [s.itemName, s])); From 73c91320cf079e00f6d3787af97d5547721923ab Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 20:54:24 +0900 Subject: [PATCH 03/19] =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=87=E3=82=A3?= =?UTF-8?q?=E3=83=B3=E3=82=B0/=E3=82=A8=E3=83=A9=E3=83=BC=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=82=92=20LoadingError=20=E3=82=B3=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=81=AB=E5=85=B1=E9=80=9A?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuestView・ReporterSummary・EventItemSummaryPage の3箇所で 重複していたローディング/エラー表示を LoadingError コンポーネントに抽出した。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/LoadingError.tsx | 10 ++++++++++ viewer/src/components/QuestView.tsx | 4 ++-- viewer/src/components/ReporterSummary.tsx | 4 ++-- viewer/src/pages/EventItemSummaryPage.tsx | 4 ++-- 4 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 viewer/src/components/LoadingError.tsx diff --git a/viewer/src/components/LoadingError.tsx b/viewer/src/components/LoadingError.tsx new file mode 100644 index 0000000..cb1bcc2 --- /dev/null +++ b/viewer/src/components/LoadingError.tsx @@ -0,0 +1,10 @@ +interface Props { + loading: boolean; + error: string | null; +} + +export function LoadingError({ loading, error }: Props) { + if (loading) return

読み込み中...

; + if (error) return

エラー: {error}

; + return null; +} diff --git a/viewer/src/components/QuestView.tsx b/viewer/src/components/QuestView.tsx index 80238b9..49534ee 100644 --- a/viewer/src/components/QuestView.tsx +++ b/viewer/src/components/QuestView.tsx @@ -3,6 +3,7 @@ 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"; @@ -27,8 +28,7 @@ export function QuestView({ eventId, questId, exclusions }: Props) { .finally(() => setLoading(false)); }, [eventId, questId]); - if (loading) return

読み込み中...

; - if (error) return

エラー: {error}

; + if (loading || error) return ; if (!data) return

このクエストのデータはまだ登録されていません。

; const stats = aggregate(data.reports, exclusions); diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index eeb6f1a..af23f52 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -4,6 +4,7 @@ import { formatTimestamp } from "../formatters"; import { DEFAULT_SORT, aggregateReporters, sortRows } from "../reporterSummaryUtils"; import type { ReportDetail, SortKey, SortState } from "../reporterSummaryUtils"; import type { ExclusionsMap, Quest, QuestData } from "../types"; +import { LoadingError } from "./LoadingError"; import { StatsBar } from "./StatsBar"; import { sortIndicator, @@ -91,8 +92,7 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { .finally(() => setLoading(false)); }, [eventId, quests]); - if (loading) return

読み込み中...

; - if (error) return

エラー: {error}

; + if (loading || error) return ; const toggleSort = (key: SortKey) => { setSort((prev) => { diff --git a/viewer/src/pages/EventItemSummaryPage.tsx b/viewer/src/pages/EventItemSummaryPage.tsx index 4e2b27e..2975eea 100644 --- a/viewer/src/pages/EventItemSummaryPage.tsx +++ b/viewer/src/pages/EventItemSummaryPage.tsx @@ -4,6 +4,7 @@ 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 { calcEventItemExpected, classifyStats } from "../summaryUtils"; export function EventItemSummaryPage() { @@ -48,8 +49,7 @@ export function EventItemSummaryPage() { if (!eventId) return null; if (!event) return ; - if (loading) return

読み込み中...

; - if (error) return

エラー: {error}

; + if (loading || error) return ; return ( <> From ae093949bc408e1a321f4e34ee23632434bc6d67 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 20:56:31 +0900 Subject: [PATCH 04/19] =?UTF-8?q?=E3=82=BD=E3=83=BC=E3=83=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF=E3=82=92=20useSor?= =?UTF-8?q?tState=20/=20useFixedSortState=20=E3=83=95=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=81=AB=E6=8A=BD=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportTable と ReporterSummary のインラインだった toggleSort 実装を hooks/useSortState.ts に移動した。 - useSortState: null 許容、asc→desc→null サイクル(ReportTable 向け) - useFixedSortState: null 不可、desc↔asc トグル(ReporterSummary 向け) Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/ReportTable.tsx | 14 ++------- viewer/src/components/ReporterSummary.tsx | 12 ++----- viewer/src/hooks/useSortState.ts | 38 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 viewer/src/hooks/useSortState.ts diff --git a/viewer/src/components/ReportTable.tsx b/viewer/src/components/ReportTable.tsx index 50b3489..9f37469 100644 --- a/viewer/src/components/ReportTable.tsx +++ b/viewer/src/components/ReportTable.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; import { createExcludedIdSet, isOutlier } from "../aggregate"; import { formatTimestamp } from "../formatters"; +import { useSortState } from "../hooks/useSortState"; import { sortReports } from "../reportTableUtils"; -import type { SortKey, SortState } from "../reportTableUtils"; +import type { SortKey } from "../reportTableUtils"; import type { Exclusion, ItemOutlierStats, ItemStats, Report } from "../types"; import { sortIndicator, @@ -54,7 +54,7 @@ function formatItemHeader(name: string): React.ReactNode { } export function ReportTable({ reports, exclusions, itemNames, outlierStats, stats }: Props) { - const [sort, setSort] = useState(null); + const { sort, toggleSort } = useSortState(); const excludedIds = createExcludedIdSet(exclusions); const exclusionMap = new Map(exclusions.map((e) => [e.reportId, e.reason])); @@ -63,14 +63,6 @@ export function ReportTable({ reports, exclusions, itemNames, outlierStats, stat if (reports.length === 0) return

報告なし

; - const toggleSort = (key: SortKey) => { - setSort((prev) => { - if (!prev || prev.key !== key) return { key, dir: "asc" }; - if (prev.dir === "asc") return { key, dir: "desc" }; - return null; - }); - }; - const sorted = sortReports(reports, sort); return ( diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index af23f52..71c92b5 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; import { fetchQuestData } from "../api"; import { formatTimestamp } from "../formatters"; +import { useFixedSortState } from "../hooks/useSortState"; import { DEFAULT_SORT, aggregateReporters, sortRows } from "../reporterSummaryUtils"; -import type { ReportDetail, SortKey, SortState } from "../reporterSummaryUtils"; +import type { ReportDetail, SortKey } from "../reporterSummaryUtils"; import type { ExclusionsMap, Quest, QuestData } from "../types"; import { LoadingError } from "./LoadingError"; import { StatsBar } from "./StatsBar"; @@ -80,7 +81,7 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { const [questData, setQuestData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [sort, setSort] = useState(DEFAULT_SORT); + const { sort, toggleSort } = useFixedSortState(DEFAULT_SORT); const [expanded, setExpanded] = useState>(new Set()); useEffect(() => { @@ -94,13 +95,6 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { if (loading || error) return ; - const toggleSort = (key: SortKey) => { - setSort((prev) => { - if (prev.key !== key) return { key, dir: "desc" }; - return { key, dir: prev.dir === "desc" ? "asc" : "desc" }; - }); - }; - const rawRows = aggregateReporters(questData, exclusions); const rows = sortRows(rawRows, sort); const totalReporters = rows.length; diff --git a/viewer/src/hooks/useSortState.ts b/viewer/src/hooks/useSortState.ts new file mode 100644 index 0000000..40f2679 --- /dev/null +++ b/viewer/src/hooks/useSortState.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from "react"; + +/** + * null 許容のソート状態フック。 + * 同キー押下で asc → desc → null とサイクルし、別キー押下で asc から開始する。 + * ReportTable 向け。 + */ +export function useSortState() { + const [sort, setSort] = useState<{ key: K; dir: "asc" | "desc" } | null>(null); + + const toggleSort = useCallback((key: K) => { + setSort((prev) => { + if (!prev || prev.key !== key) return { key, dir: "asc" }; + if (prev.dir === "asc") return { key, dir: "desc" }; + return null; + }); + }, []); + + return { sort, toggleSort }; +} + +/** + * 常にソート状態を維持するフック(null に戻らない)。 + * 同キー押下で desc ↔ asc をトグルし、別キー押下で desc から開始する。 + * ReporterSummary 向け。 + */ +export function useFixedSortState(initial: { key: K; dir: "asc" | "desc" }) { + const [sort, setSort] = useState(initial); + + const toggleSort = useCallback((key: K) => { + setSort((prev) => { + if (prev.key !== key) return { key, dir: "desc" }; + return { key, dir: prev.dir === "desc" ? "asc" : "desc" }; + }); + }, []); + + return { sort, toggleSort }; +} From b89e3375128d8d78af40c3d58f2030d38e78f02c Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 21:27:51 +0900 Subject: [PATCH 05/19] =?UTF-8?q?useMemo=20=E3=81=A7=E5=86=8D=E8=A8=88?= =?UTF-8?q?=E7=AE=97=E3=82=92=E3=83=A1=E3=83=A2=E5=8C=96=EF=BC=88ReportTab?= =?UTF-8?q?le=E3=83=BBReporterSummary=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportTable の excludedIds/exclusionMap/outlierMap/statsMap/sorted と ReporterSummary の rawRows/rows を useMemo でメモ化した。 hooks ルールに合わせて全 useMemo を early return より前に配置。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/ReportTable.tsx | 19 ++++++++++++------- viewer/src/components/ReporterSummary.tsx | 6 +++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/viewer/src/components/ReportTable.tsx b/viewer/src/components/ReportTable.tsx index 9f37469..f0f5080 100644 --- a/viewer/src/components/ReportTable.tsx +++ b/viewer/src/components/ReportTable.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { createExcludedIdSet, isOutlier } from "../aggregate"; import { formatTimestamp } from "../formatters"; import { useSortState } from "../hooks/useSortState"; @@ -55,16 +56,20 @@ function formatItemHeader(name: string): React.ReactNode { export function ReportTable({ reports, exclusions, itemNames, outlierStats, stats }: Props) { const { sort, toggleSort } = useSortState(); - const excludedIds = createExcludedIdSet(exclusions); - const exclusionMap = new Map(exclusions.map((e) => [e.reportId, e.reason])); - - const outlierMap = new Map(outlierStats.map((s) => [s.itemName, s])); - const statsMap = new Map(stats.map((s) => [s.itemName, s])); + const excludedIds = useMemo(() => createExcludedIdSet(exclusions), [exclusions]); + const exclusionMap = useMemo( + () => new Map(exclusions.map((e) => [e.reportId, e.reason])), + [exclusions], + ); + const outlierMap = useMemo( + () => new Map(outlierStats.map((s) => [s.itemName, s])), + [outlierStats], + ); + const statsMap = useMemo(() => new Map(stats.map((s) => [s.itemName, s])), [stats]); + const sorted = useMemo(() => sortReports(reports, sort), [reports, sort]); if (reports.length === 0) return

報告なし

; - const sorted = sortReports(reports, sort); - return (
diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index 71c92b5..a21f977 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { fetchQuestData } from "../api"; import { formatTimestamp } from "../formatters"; import { useFixedSortState } from "../hooks/useSortState"; @@ -95,8 +95,8 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { if (loading || error) return ; - const rawRows = aggregateReporters(questData, exclusions); - const rows = sortRows(rawRows, sort); + const rawRows = useMemo(() => aggregateReporters(questData, exclusions), [questData, exclusions]); + const rows = useMemo(() => sortRows(rawRows, sort), [rawRows, sort]); const totalReporters = rows.length; const totalReports = rows.reduce((s, r) => s + r.reportCount, 0); const totalRuns = rows.reduce((s, r) => s + r.totalRuns, 0); From 239bb6a4d0a4563fb7a40591b35fbc6d9c983c57 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 21:28:30 +0900 Subject: [PATCH 06/19] =?UTF-8?q?MAX=5FEVENT=5FBONUS=20=E3=82=92=20constan?= =?UTF-8?q?ts.ts=20=E3=81=AB=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SummaryTable と EventItemSummaryView で重複定義されていた MAX_EVENT_BONUS = 12 を constants.ts に移動した。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/EventItemSummaryView.tsx | 3 +-- viewer/src/components/SummaryTable.tsx | 3 +-- viewer/src/constants.ts | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/viewer/src/components/EventItemSummaryView.tsx b/viewer/src/components/EventItemSummaryView.tsx index 0e8fb8e..14fea0d 100644 --- a/viewer/src/components/EventItemSummaryView.tsx +++ b/viewer/src/components/EventItemSummaryView.tsx @@ -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"; @@ -11,8 +12,6 @@ interface Props { questExpected: QuestExpected[]; } -const MAX_EVENT_BONUS = 12; - const thStyleNarrow: React.CSSProperties = { ...thStyle, width: "4em", diff --git a/viewer/src/components/SummaryTable.tsx b/viewer/src/components/SummaryTable.tsx index a259366..858d0bb 100644 --- a/viewer/src/components/SummaryTable.tsx +++ b/viewer/src/components/SummaryTable.tsx @@ -1,3 +1,4 @@ +import { MAX_EVENT_BONUS } from "../constants"; import { calcEventItemExpected, classifyStats, @@ -11,8 +12,6 @@ interface Props { stats: ItemStats[]; } -const MAX_EVENT_BONUS = 12; - function EventItemExpectedTable({ eventItems }: { eventItems: ItemStats[] }) { const rows = calcEventItemExpected(eventItems); if (rows.length === 0) return null; diff --git a/viewer/src/constants.ts b/viewer/src/constants.ts index 7e72df7..c4e6af6 100644 --- a/viewer/src/constants.ts +++ b/viewer/src/constants.ts @@ -1,3 +1,6 @@ +/** イベントボーナスの最大値(+0〜+12) */ +export const MAX_EVENT_BONUS = 12; + /** イベントアイテム例: "ミトン(x3)" */ export const RE_EVENT_ITEM = /\(x(\d+)\)$/; From a5917a0cea4d1f5b765c88595c9154c9a0d85262 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 21:30:39 +0900 Subject: [PATCH 07/19] =?UTF-8?q?formatNote/formatItemHeader=20=E3=82=92?= =?UTF-8?q?=20formatters.tsx=20=E3=81=AB=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportTable.tsx に埋め込まれていた formatNote・formatItemHeader と 関連定数(RE_FGOSCCNT・RE_MODIFIER)を formatters.tsx(.ts → .tsx リネーム)に移動した。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/ReportTable.tsx | 34 +--------------- viewer/src/formatters.ts | 21 ---------- viewer/src/formatters.tsx | 56 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 54 deletions(-) delete mode 100644 viewer/src/formatters.ts create mode 100644 viewer/src/formatters.tsx diff --git a/viewer/src/components/ReportTable.tsx b/viewer/src/components/ReportTable.tsx index f0f5080..20aba73 100644 --- a/viewer/src/components/ReportTable.tsx +++ b/viewer/src/components/ReportTable.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { createExcludedIdSet, isOutlier } from "../aggregate"; -import { formatTimestamp } from "../formatters"; +import { formatItemHeader, formatNote, formatTimestamp } from "../formatters"; import { useSortState } from "../hooks/useSortState"; import { sortReports } from "../reportTableUtils"; import type { SortKey } from "../reportTableUtils"; @@ -22,38 +22,6 @@ interface Props { stats: ItemStats[]; } -const RE_FGOSCCNT = /https:\/\/fgojunks\.max747\.org\/fgosccnt\/results\/\S+/; -const RE_MODIFIER = /(\((?:x|\+)\d+\))$/; - -function formatNote(note: string): React.ReactNode { - const m = RE_FGOSCCNT.exec(note); - if (!m) return note; - const before = note.slice(0, m.index); - const after = note.slice(m.index + m[0].length); - return ( - <> - {before} - - fgosccnt - - {after} - - ); -} - -function formatItemHeader(name: string): React.ReactNode { - const m = RE_MODIFIER.exec(name); - if (!m) return name; - const base = name.slice(0, m.index); - return ( - <> - {base} -
- {m[1]} - - ); -} - export function ReportTable({ reports, exclusions, itemNames, outlierStats, stats }: Props) { const { sort, toggleSort } = useSortState(); const excludedIds = useMemo(() => createExcludedIdSet(exclusions), [exclusions]); diff --git a/viewer/src/formatters.ts b/viewer/src/formatters.ts deleted file mode 100644 index 22229f6..0000000 --- a/viewer/src/formatters.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { EventPeriod } from "./types"; - -export function formatDateTime(iso: string): string { - const d = new Date(iso); - return d.toLocaleString("ja-JP", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - timeZone: "Asia/Tokyo", - }); -} - -export function formatTimestamp(iso: string): string { - return new Date(iso).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }); -} - -export function formatPeriod(period: EventPeriod): string { - return `${formatDateTime(period.start)} 〜 ${formatDateTime(period.end)}`; -} diff --git a/viewer/src/formatters.tsx b/viewer/src/formatters.tsx new file mode 100644 index 0000000..26bab7b --- /dev/null +++ b/viewer/src/formatters.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import type { EventPeriod } from "./types"; + +const RE_FGOSCCNT = /https:\/\/fgojunks\.max747\.org\/fgosccnt\/results\/\S+/; +const RE_MODIFIER = /(\((?:x|\+)\d+\))$/; + +/** メモ欄の fgosccnt URL をリンク化して返す */ +export function formatNote(note: string): ReactNode { + const m = RE_FGOSCCNT.exec(note); + if (!m) return note; + const before = note.slice(0, m.index); + const after = note.slice(m.index + m[0].length); + return ( + <> + {before} + + fgosccnt + + {after} + + ); +} + +/** アイテム名の修飾子部分("(x3)" 等)を改行で区切って返す */ +export function formatItemHeader(name: string): ReactNode { + const m = RE_MODIFIER.exec(name); + if (!m) return name; + const base = name.slice(0, m.index); + return ( + <> + {base} +
+ {m[1]} + + ); +} + +export function formatDateTime(iso: string): string { + const d = new Date(iso); + return d.toLocaleString("ja-JP", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + timeZone: "Asia/Tokyo", + }); +} + +export function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }); +} + +export function formatPeriod(period: EventPeriod): string { + return `${formatDateTime(period.start)} 〜 ${formatDateTime(period.end)}`; +} From 049426f48ec26e9986753036eeedd1d1bb8ad3f1 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 21:45:45 +0900 Subject: [PATCH 08/19] =?UTF-8?q?SortDir=20=E5=9E=8B=E3=82=92=20types.ts?= =?UTF-8?q?=20=E3=81=AB=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reportTableUtils と reporterSummaryUtils で重複定義されていた SortDir = "asc" | "desc" を types.ts に一元化した。 useSortState.ts も SortDir を import して型注釈を統一。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/hooks/useSortState.ts | 5 +++-- viewer/src/reportTableUtils.ts | 3 +-- viewer/src/reporterSummaryUtils.ts | 3 +-- viewer/src/types.ts | 2 ++ 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/viewer/src/hooks/useSortState.ts b/viewer/src/hooks/useSortState.ts index 40f2679..994530f 100644 --- a/viewer/src/hooks/useSortState.ts +++ b/viewer/src/hooks/useSortState.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import type { SortDir } from "../types"; /** * null 許容のソート状態フック。 @@ -6,7 +7,7 @@ import { useCallback, useState } from "react"; * ReportTable 向け。 */ export function useSortState() { - const [sort, setSort] = useState<{ key: K; dir: "asc" | "desc" } | null>(null); + const [sort, setSort] = useState<{ key: K; dir: SortDir } | null>(null); const toggleSort = useCallback((key: K) => { setSort((prev) => { @@ -24,7 +25,7 @@ export function useSortState() { * 同キー押下で desc ↔ asc をトグルし、別キー押下で desc から開始する。 * ReporterSummary 向け。 */ -export function useFixedSortState(initial: { key: K; dir: "asc" | "desc" }) { +export function useFixedSortState(initial: { key: K; dir: SortDir }) { const [sort, setSort] = useState(initial); const toggleSort = useCallback((key: K) => { diff --git a/viewer/src/reportTableUtils.ts b/viewer/src/reportTableUtils.ts index 4b910d7..7e513ce 100644 --- a/viewer/src/reportTableUtils.ts +++ b/viewer/src/reportTableUtils.ts @@ -1,7 +1,6 @@ -import type { Report } from "./types"; +import type { Report, SortDir } from "./types"; export type SortKey = "reporter" | "runcount" | "timestamp"; -export type SortDir = "asc" | "desc"; export type SortState = { key: SortKey; dir: SortDir } | null; export function getReporterName(r: Report): string { diff --git a/viewer/src/reporterSummaryUtils.ts b/viewer/src/reporterSummaryUtils.ts index e890d51..0dac7c6 100644 --- a/viewer/src/reporterSummaryUtils.ts +++ b/viewer/src/reporterSummaryUtils.ts @@ -1,4 +1,4 @@ -import type { ExclusionsMap, QuestData } from "./types"; +import type { ExclusionsMap, QuestData, SortDir } from "./types"; export interface ReportDetail { reportId: string; @@ -17,7 +17,6 @@ export interface ReporterRow { } export type SortKey = "reportCount" | "totalRuns"; -export type SortDir = "asc" | "desc"; export type SortState = { key: SortKey; dir: SortDir }; export const DEFAULT_SORT: SortState = { key: "totalRuns", dir: "desc" }; diff --git a/viewer/src/types.ts b/viewer/src/types.ts index 88e3f84..420df5d 100644 --- a/viewer/src/types.ts +++ b/viewer/src/types.ts @@ -46,6 +46,8 @@ export interface Exclusion { export type ExclusionsMap = Record; +export type SortDir = "asc" | "desc"; + export interface ItemStats { itemName: string; totalDrops: number; From 19d10fbca7d438640c4b1affa4dbb9a039f97fb8 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 21:46:39 +0900 Subject: [PATCH 09/19] =?UTF-8?q?=E3=82=A2=E3=82=B3=E3=83=BC=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=82=AA=E3=83=B3=E5=B1=95=E9=96=8B=E7=8A=B6=E6=85=8B?= =?UTF-8?q?=E3=82=92=20useToggleSet=20=E3=83=95=E3=83=83=E3=82=AF=E3=81=AB?= =?UTF-8?q?=E6=8A=BD=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReporterSummary.tsx のインライン Set トグルロジックを hooks/useToggleSet.ts に移動した。 展開ボタンの onClick が toggleExpanded(r.reporter) の1行になりすっきりした。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/ReporterSummary.tsx | 12 +++--------- viewer/src/hooks/useToggleSet.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 viewer/src/hooks/useToggleSet.ts diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index a21f977..3351369 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { fetchQuestData } from "../api"; import { formatTimestamp } from "../formatters"; import { useFixedSortState } from "../hooks/useSortState"; +import { useToggleSet } from "../hooks/useToggleSet"; import { DEFAULT_SORT, aggregateReporters, sortRows } from "../reporterSummaryUtils"; import type { ReportDetail, SortKey } from "../reporterSummaryUtils"; import type { ExclusionsMap, Quest, QuestData } from "../types"; @@ -82,7 +83,7 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { sort, toggleSort } = useFixedSortState(DEFAULT_SORT); - const [expanded, setExpanded] = useState>(new Set()); + const { set: expanded, toggle: toggleExpanded } = useToggleSet(); useEffect(() => { setLoading(true); @@ -144,14 +145,7 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) {
From bd6ef6640593d9a2493c70779136126d1dfe5b89 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 22:37:00 +0900 Subject: [PATCH 11/19] update biome to v2 --- biome.json | 4 +-- package-lock.json | 73 +++++++++++++++++++++++------------------------ package.json | 2 +- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/biome.json b/biome.json index d26abb7..a0b604d 100644 --- a/biome.json +++ b/biome.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 73b9290..ad16449 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,15 +5,14 @@ "packages": { "": { "devDependencies": { - "@biomejs/biome": "^1" + "@biomejs/biome": "^2" } }, "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz", + "integrity": "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==", "dev": true, - "hasInstallScript": true, "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -26,20 +25,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" + "@biomejs/cli-darwin-arm64": "2.4.2", + "@biomejs/cli-darwin-x64": "2.4.2", + "@biomejs/cli-linux-arm64": "2.4.2", + "@biomejs/cli-linux-arm64-musl": "2.4.2", + "@biomejs/cli-linux-x64": "2.4.2", + "@biomejs/cli-linux-x64-musl": "2.4.2", + "@biomejs/cli-win32-arm64": "2.4.2", + "@biomejs/cli-win32-x64": "2.4.2" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.2.tgz", + "integrity": "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==", "cpu": [ "arm64" ], @@ -54,9 +53,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.2.tgz", + "integrity": "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==", "cpu": [ "x64" ], @@ -71,9 +70,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.2.tgz", + "integrity": "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==", "cpu": [ "arm64" ], @@ -88,9 +87,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.2.tgz", + "integrity": "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==", "cpu": [ "arm64" ], @@ -105,9 +104,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.2.tgz", + "integrity": "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==", "cpu": [ "x64" ], @@ -122,9 +121,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.2.tgz", + "integrity": "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==", "cpu": [ "x64" ], @@ -139,9 +138,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.2.tgz", + "integrity": "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==", "cpu": [ "arm64" ], @@ -156,9 +155,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.2.tgz", + "integrity": "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index e8d9f3d..57a27e6 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "fix": "biome check --write ." }, "devDependencies": { - "@biomejs/biome": "^1" + "@biomejs/biome": "^2" } } From 005cc8faf1465264d7ba1c3ea005f52f0e3a59c9 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 22:37:55 +0900 Subject: [PATCH 12/19] fix lint error --- admin/src/auth/AuthProvider.tsx | 4 ++-- viewer/src/components/ReportTable.tsx | 2 +- viewer/src/components/ReporterSummary.tsx | 12 ++++++++---- viewer/src/main.tsx | 2 +- viewer/src/reportTableUtils.test.ts | 2 +- viewer/src/reporterSummaryUtils.test.ts | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/admin/src/auth/AuthProvider.tsx b/admin/src/auth/AuthProvider.tsx index cdfacc9..97e32ef 100644 --- a/admin/src/auth/AuthProvider.tsx +++ b/admin/src/auth/AuthProvider.tsx @@ -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; diff --git a/viewer/src/components/ReportTable.tsx b/viewer/src/components/ReportTable.tsx index 20aba73..2f2e559 100644 --- a/viewer/src/components/ReportTable.tsx +++ b/viewer/src/components/ReportTable.tsx @@ -2,8 +2,8 @@ import { useMemo } from "react"; import { createExcludedIdSet, isOutlier } from "../aggregate"; import { formatItemHeader, formatNote, formatTimestamp } from "../formatters"; import { useSortState } from "../hooks/useSortState"; -import { sortReports } from "../reportTableUtils"; import type { SortKey } from "../reportTableUtils"; +import { sortReports } from "../reportTableUtils"; import type { Exclusion, ItemOutlierStats, ItemStats, Report } from "../types"; import { sortIndicator, diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index 7d169c7..483cab9 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -3,8 +3,8 @@ import { fetchQuestData } from "../api"; import { formatTimestamp } from "../formatters"; import { useFixedSortState } from "../hooks/useSortState"; import { useToggleSet } from "../hooks/useToggleSet"; -import { DEFAULT_SORT, aggregateReporters, sortRows } from "../reporterSummaryUtils"; import type { ReportDetail, SortKey } from "../reporterSummaryUtils"; +import { aggregateReporters, DEFAULT_SORT, sortRows } from "../reporterSummaryUtils"; import type { ExclusionsMap, Quest, QuestData } from "../types"; import { LoadingError } from "./LoadingError"; import { StatsBar } from "./StatsBar"; @@ -28,7 +28,11 @@ function XIdLink({ xId, href, children, -}: { xId: string; href: string; children: React.ReactNode }) { +}: { + xId: string; + href: string; + children: React.ReactNode; +}) { if (!xId || xId === "anonymous") return <>{children}; return ( @@ -107,10 +111,10 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { .finally(() => setLoading(false)); }, [eventId, quests]); - if (loading || error) return ; - const rawRows = useMemo(() => aggregateReporters(questData, exclusions), [questData, exclusions]); const rows = useMemo(() => sortRows(rawRows, sort), [rawRows, sort]); + + if (loading || error) return ; const totalReporters = rows.length; const totalReports = rows.reduce((s, r) => s + r.reportCount, 0); const totalRuns = rows.reduce((s, r) => s + r.totalRuns, 0); diff --git a/viewer/src/main.tsx b/viewer/src/main.tsx index 99f20a3..b0bd28c 100644 --- a/viewer/src/main.tsx +++ b/viewer/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"; +import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; import { AppLayout } from "./AppLayout"; import { EventItemSummaryPage } from "./pages/EventItemSummaryPage"; import { EventRedirect } from "./pages/EventRedirect"; diff --git a/viewer/src/reportTableUtils.test.ts b/viewer/src/reportTableUtils.test.ts index b6b9198..068fab3 100644 --- a/viewer/src/reportTableUtils.test.ts +++ b/viewer/src/reportTableUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getReporterName, sortReports } from "./reportTableUtils"; import type { SortState } from "./reportTableUtils"; +import { getReporterName, sortReports } from "./reportTableUtils"; import type { Report } from "./types"; function makeReport(overrides: Partial = {}): Report { diff --git a/viewer/src/reporterSummaryUtils.test.ts b/viewer/src/reporterSummaryUtils.test.ts index 0b64243..54e2121 100644 --- a/viewer/src/reporterSummaryUtils.test.ts +++ b/viewer/src/reporterSummaryUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { aggregateReporters, sortRows } from "./reporterSummaryUtils"; import type { ReporterRow, SortState } from "./reporterSummaryUtils"; +import { aggregateReporters, sortRows } from "./reporterSummaryUtils"; import type { ExclusionsMap, QuestData } from "./types"; function makeQuestData( From fa12547d233c170dfec5df8bb66a10282d83ee9b Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 22:50:13 +0900 Subject: [PATCH 13/19] =?UTF-8?q?DetailTable=20=E3=81=AE=20key=20=E3=82=92?= =?UTF-8?q?=E9=85=8D=E5=88=97=20index=20=E3=81=8B=E3=82=89=20reportId=20?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit リスト順変更時の React の差分検出を正確にするため、 `key={i}` を `key={d.reportId}` に修正する。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/ReporterSummary.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index 483cab9..7948ff3 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -67,8 +67,8 @@ function DetailTable({ details }: { details: ReportDetail[] }) { - {sorted.map((d, i) => ( - + {sorted.map((d) => ( +
{i + 1} - {r.xId && r.xId !== "anonymous" ? ( - - {r.reporter} - - ) : ( - r.reporter - )} + + {r.reporter} + - {r.xId && r.xId !== "anonymous" ? ( - - {r.xId} - - ) : ( - r.xId - )} + + {r.xId} + {r.reportCount.toLocaleString()} {r.totalRuns.toLocaleString()}
Date: Thu, 19 Feb 2026 22:51:09 +0900 Subject: [PATCH 14/19] =?UTF-8?q?getHighestQuest:=20"90+"=20=E3=83=AC?= =?UTF-8?q?=E3=83=99=E3=83=AB=E6=96=87=E5=AD=97=E5=88=97=E3=81=AE=20NaN=20?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number("90+") が NaN になりソート順が不定になる問題を修正。 parseLevel() で parseInt + "+" サフィックスに 0.5 を加算し、 90+ > 90 の順序を保証する。テストも意図を明確化した。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/routeUtils.test.ts | 5 ++--- viewer/src/routeUtils.ts | 7 ++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/viewer/src/routeUtils.test.ts b/viewer/src/routeUtils.test.ts index 1e0d3fe..7c68c1b 100644 --- a/viewer/src/routeUtils.test.ts +++ b/viewer/src/routeUtils.test.ts @@ -60,11 +60,10 @@ describe("getHighestQuest", () => { expect(getHighestQuest([mid, low, high])).toBe(high); }); - it("90+ のような文字列レベルを正しく扱う", () => { + it("90+ は 90 より高いレベルとして扱う", () => { const q90 = makeQuest("q90", "90"); const q90plus = makeQuest("q90+", "90+"); - // Number("90+") は NaN なので、ソート結果は実装依存だが壊れないことを確認 - expect(getHighestQuest([q90, q90plus])).toBeDefined(); + expect(getHighestQuest([q90, q90plus])).toBe(q90plus); }); it("元の配列を変更しない", () => { diff --git a/viewer/src/routeUtils.ts b/viewer/src/routeUtils.ts index b6d269a..7538a16 100644 --- a/viewer/src/routeUtils.ts +++ b/viewer/src/routeUtils.ts @@ -6,8 +6,13 @@ export function getLatestEvent(events: EventData[]): EventData | undefined { )[0]; } +function parseLevel(level: string): number { + const base = Number.parseInt(level, 10); + return level.endsWith("+") ? base + 0.5 : base; +} + export function getHighestQuest(quests: Quest[]): Quest | undefined { if (quests.length === 0) return undefined; - const sorted = [...quests].sort((a, b) => Number(a.level) - Number(b.level)); + const sorted = [...quests].sort((a, b) => parseLevel(a.level) - parseLevel(b.level)); return sorted[sorted.length - 1]; } From a3cab66e5fa6a0724e80df815a96217b27435af8 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 22:52:35 +0900 Subject: [PATCH 15/19] =?UTF-8?q?QuestView:=20=E9=9B=86=E8=A8=88=E8=A8=88?= =?UTF-8?q?=E7=AE=97=E3=82=92=20useMemo=20=E3=81=A7=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=81=97=E3=81=A6=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E3=81=AA=E5=86=8D=E8=A8=88=E7=AE=97=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data・exclusions が変わらない限り aggregate(), calcOutlierStats(), excludedIds, totalRuns, validCount, sortedItemNames を再計算しないように useMemo でキャッシュする。hooks を早期 return より前に移動した。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/components/QuestView.tsx | 49 +++++++++++++++++++---------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/viewer/src/components/QuestView.tsx b/viewer/src/components/QuestView.tsx index 49534ee..89c6895 100644 --- a/viewer/src/components/QuestView.tsx +++ b/viewer/src/components/QuestView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { aggregate, calcOutlierStats, createExcludedIdSet } from "../aggregate"; import { fetchQuestData } from "../api"; import { formatTimestamp } from "../formatters"; @@ -28,25 +28,40 @@ export function QuestView({ eventId, questId, exclusions }: Props) { .finally(() => setLoading(false)); }, [eventId, questId]); + 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(); + for (const report of data.reports) { + for (const key of Object.keys(report.items)) { + itemNames.add(key); + } + } + return [...itemNames]; + }, [data]); + if (loading || error) return ; if (!data) return

このクエストのデータはまだ登録されていません。

; - const stats = aggregate(data.reports, exclusions); - const outlierStats = calcOutlierStats(data.reports, exclusions); - const excludedIds = createExcludedIdSet(exclusions); - 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(); - for (const report of data.reports) { - for (const key of Object.keys(report.items)) { - itemNames.add(key); - } - } - const sortedItemNames = [...itemNames]; - return (

From b8e528702c9a65babe75028b4009e9c4a9d53874 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 22:54:35 +0900 Subject: [PATCH 16/19] =?UTF-8?q?useEffect=20=E3=81=AE=20fetch=20=E3=81=AB?= =?UTF-8?q?=20AbortController=20=E3=82=92=E5=B0=8E=E5=85=A5=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=83=A1=E3=83=A2=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コンポーネント unmount 時に進行中の fetch をキャンセルできるよう、 QuestView / ReporterSummary / EventItemSummaryPage / AppLayout の useEffect に AbortController を追加する。 api.ts の各関数も signal?: AbortSignal を受け取れるよう拡張した。 AbortError はエラーとして扱わずスキップする。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/AppLayout.tsx | 9 +++++++-- viewer/src/api.ts | 16 ++++++++++------ viewer/src/components/QuestView.tsx | 9 +++++++-- viewer/src/components/ReporterSummary.tsx | 9 +++++++-- viewer/src/pages/EventItemSummaryPage.tsx | 11 +++++++++-- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/viewer/src/AppLayout.tsx b/viewer/src/AppLayout.tsx index 3d62b3f..5aa907c 100644 --- a/viewer/src/AppLayout.tsx +++ b/viewer/src/AppLayout.tsx @@ -32,13 +32,18 @@ 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))) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }) .finally(() => setLoading(false)); + return () => controller.abort(); }, []); if (loading) return

読み込み中...

; diff --git a/viewer/src/api.ts b/viewer/src/api.ts index 5178d08..72dc618 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -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 { - const res = await fetch(`${DATA_URL}/events.json`); +export async function fetchEvents(signal?: AbortSignal): Promise { + 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 { - const res = await fetch(`${DATA_URL}/exclusions.json`); +export async function fetchExclusions(signal?: AbortSignal): Promise { + 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 { - const res = await fetch(`${DATA_URL}/${eventId}/${questId}.json`); +export async function fetchQuestData( + eventId: string, + questId: string, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${DATA_URL}/${eventId}/${questId}.json`, { signal }); // S3 + CloudFront では未作成のオブジェクトに対して 403 が返るため、 // 404 と同様に「データ未登録」として扱い、エラーではなく null を返す if (res.status === 403 || res.status === 404) return null; diff --git a/viewer/src/components/QuestView.tsx b/viewer/src/components/QuestView.tsx index 89c6895..2dc080a 100644 --- a/viewer/src/components/QuestView.tsx +++ b/viewer/src/components/QuestView.tsx @@ -20,12 +20,17 @@ 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))) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }) .finally(() => setLoading(false)); + return () => controller.abort(); }, [eventId, questId]); const stats = useMemo( diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index 7948ff3..658e62d 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -103,12 +103,17 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { const { set: expanded, toggle: toggleExpanded } = useToggleSet(); useEffect(() => { + const controller = new AbortController(); setLoading(true); setError(null); - Promise.all(quests.map((q) => fetchQuestData(eventId, q.questId))) + 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) => setError(e instanceof Error ? e.message : String(e))) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }) .finally(() => setLoading(false)); + return () => controller.abort(); }, [eventId, quests]); const rawRows = useMemo(() => aggregateReporters(questData, exclusions), [questData, exclusions]); diff --git a/viewer/src/pages/EventItemSummaryPage.tsx b/viewer/src/pages/EventItemSummaryPage.tsx index 2975eea..492047a 100644 --- a/viewer/src/pages/EventItemSummaryPage.tsx +++ b/viewer/src/pages/EventItemSummaryPage.tsx @@ -20,12 +20,15 @@ export function EventItemSummaryPage() { useEffect(() => { if (!event) return; + const controller = new AbortController(); setLoading(true); setError(null); const sortedQuests = [...event.quests].sort((a, b) => Number(a.level) - Number(b.level)); - Promise.all(sortedQuests.map((q) => fetchQuestData(event.eventId, q.questId))) + 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++) { @@ -43,8 +46,12 @@ export function EventItemSummaryPage() { } setQuestExpected(qe); }) - .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }) .finally(() => setLoading(false)); + return () => controller.abort(); }, [event, exclusions]); if (!eventId) return null; From db5342e29f9c83337ee4b314584f9d05f78e1634 Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 23:26:38 +0900 Subject: [PATCH 17/19] =?UTF-8?q?finally:=20abort=20=E6=B8=88=E3=81=BF?= =?UTF-8?q?=E3=81=AE=E5=A0=B4=E5=90=88=E3=81=AF=20setLoading(false)=20?= =?UTF-8?q?=E3=82=92=E3=82=B9=E3=82=AD=E3=83=83=E3=83=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依存変数変更で前回 fetch を abort しつつ新リクエストを開始した際に、 前回の finally が後から実行されて loading: true を上書きする競合を防ぐ。 controller.signal.aborted を確認して abort 済みなら state 更新をスキップする。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/AppLayout.tsx | 4 +++- viewer/src/components/QuestView.tsx | 4 +++- viewer/src/components/ReporterSummary.tsx | 4 +++- viewer/src/pages/EventItemSummaryPage.tsx | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/viewer/src/AppLayout.tsx b/viewer/src/AppLayout.tsx index 5aa907c..949a640 100644 --- a/viewer/src/AppLayout.tsx +++ b/viewer/src/AppLayout.tsx @@ -42,7 +42,9 @@ export function AppLayout() { if (e instanceof DOMException && e.name === "AbortError") return; setError(e instanceof Error ? e.message : String(e)); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, []); diff --git a/viewer/src/components/QuestView.tsx b/viewer/src/components/QuestView.tsx index 2dc080a..51e573d 100644 --- a/viewer/src/components/QuestView.tsx +++ b/viewer/src/components/QuestView.tsx @@ -29,7 +29,9 @@ export function QuestView({ eventId, questId, exclusions }: Props) { if (e instanceof DOMException && e.name === "AbortError") return; setError(e instanceof Error ? e.message : String(e)); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, [eventId, questId]); diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index 658e62d..0c33c8d 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -112,7 +112,9 @@ export function ReporterSummary({ eventId, quests, exclusions }: Props) { if (e instanceof DOMException && e.name === "AbortError") return; setError(e instanceof Error ? e.message : String(e)); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, [eventId, quests]); diff --git a/viewer/src/pages/EventItemSummaryPage.tsx b/viewer/src/pages/EventItemSummaryPage.tsx index 492047a..a520e95 100644 --- a/viewer/src/pages/EventItemSummaryPage.tsx +++ b/viewer/src/pages/EventItemSummaryPage.tsx @@ -50,7 +50,9 @@ export function EventItemSummaryPage() { if (e instanceof DOMException && e.name === "AbortError") return; setError(e instanceof Error ? e.message : String(e)); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, [event, exclusions]); From bcb348da27dddbe106f854bcb4ceedfd56799f6a Mon Sep 17 00:00:00 2001 From: max747 Date: Thu, 19 Feb 2026 23:27:53 +0900 Subject: [PATCH 18/19] =?UTF-8?q?parseLevel=20=E3=82=92=20export=20?= =?UTF-8?q?=E3=81=97=E3=80=81=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88=E3=82=BD?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=AE=20NaN=20=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventItemSummaryPage と AppLayout のクエストソートで Number(a.level) を 使っていたため "90+" が NaN になりソート順が不定だった。 parseLevel を routeUtils からエクスポートして共用し、両箇所を置き換えた。 Co-Authored-By: Claude Sonnet 4.6 --- viewer/src/AppLayout.tsx | 4 ++-- viewer/src/pages/EventItemSummaryPage.tsx | 5 ++++- viewer/src/routeUtils.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/viewer/src/AppLayout.tsx b/viewer/src/AppLayout.tsx index 949a640..6a224b3 100644 --- a/viewer/src/AppLayout.tsx +++ b/viewer/src/AppLayout.tsx @@ -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 = { @@ -99,7 +99,7 @@ export function AppLayout() { {selectedEvent && selectedEvent.quests.length > 0 && (
{[...selectedEvent.quests] - .sort((a, b) => Number(a.level) - Number(b.level)) + .sort((a, b) => parseLevel(a.level) - parseLevel(b.level)) .map((q) => (