diff --git a/app/features/top-search/routes/xsearch.tsx b/app/features/top-search/routes/xsearch.tsx index 9b42d59bd..3b4baefb5 100644 --- a/app/features/top-search/routes/xsearch.tsx +++ b/app/features/top-search/routes/xsearch.tsx @@ -1,24 +1,35 @@ -import { nanoid } from "nanoid"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { + SendouSelect, + SendouSelectItem, + SendouSelectItemSection, +} from "~/components/elements/Select"; +import { Image, ModeImage } from "~/components/Image"; +import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { Main } from "~/components/Main"; -import type { Tables } from "~/db/tables"; -import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import type { RankedModeShort } from "~/modules/in-game-lists/types"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; -import invariant from "~/utils/invariant"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { navIconUrl, topSearchPage } from "~/utils/urls"; +import { brandImageUrl, navIconUrl, topSearchPage } from "~/utils/urls"; import { PlacementsTable } from "../components/Placements"; import { loader } from "../loaders/xsearch.server"; import { topSearchSearchParams } from "../top-search-search-params"; -import type { MonthYear } from "../top-search-utils"; +import { type MonthYear, monthYearToSpan } from "../top-search-utils"; export { loader }; +const DIVISIONS = [ + { region: "WEST", brandId: "B10" }, + { region: "JPN", brandId: "B11" }, +] as const; + export const handle: SendouRouteHandle = { breadcrumb: () => ({ imgPath: navIconUrl("xsearch"), @@ -38,119 +49,146 @@ export const meta: MetaFunction = (args) => { }; export default function XSearchPage() { - const [params, setParams] = useSearchParamsTyped(topSearchSearchParams); - const { t } = useTranslation(["common", "game-misc"]); - const { formatter: monthYearRangeFormatter } = useDateTimeFormat({ - month: "numeric", - year: "numeric", - }); const data = useLoaderData(); - const handleSelectChange = (event: React.ChangeEvent) => { - const [month, year, mode, region] = event.target.value.split("-"); - invariant(month, "month is missing"); - invariant(year, "year is missing"); - invariant(mode, "mode is missing"); - invariant(region, "region is missing"); - - setParams({ - month: Number(month), - year: Number(year), - mode: mode as RankedModeShort, - region: region as "WEST" | "JPN", - }); - }; - - const selectValue = `${ - params.month ?? data.availableMonthYears[0].month - }-${params.year ?? data.availableMonthYears[0].year}-${params.mode}-${params.region}`; - - const formatMonthYearRange = (from: MonthYear, to: MonthYear) => - monthYearRangeFormatter.formatRange( - new Date(from.year, from.month - 1), - new Date(to.year, to.month - 1), - ) ?? ""; - return (
- +
+ + + +
); } -interface SelectOption { - id: string; - region: Tables["XRankPlacement"]["region"]; - mode: RankedModeShort; - span: { - from: MonthYear; - to: MonthYear; - value: MonthYear; +function SeasonMonthsSelect() { + const { t } = useTranslation(["common"]); + const data = useLoaderData(); + const [params, setParams] = useSearchParamsTyped(topSearchSearchParams); + + const selected = { + month: params.month ?? data.availableMonthYears[0].month, + year: params.year ?? data.availableMonthYears[0].year, }; + + return ( + setParams(keyToMonthYear(String(key)))} + items={monthYearsGroupedByYear(data.availableMonthYears)} + data-testid="xsearch-select" + > + {({ year, monthYears }) => ( + + {monthYears.map((monthYear) => ( + + + + ))} + + )} + + ); } -function selectOptions(monthYears: MonthYear[]) { - const options: SelectOption[][] = []; - for (const monthYear of monthYears) { - for (const region of ["WEST", "JPN"] as const) { - const regionOptions: SelectOption[] = []; - for (const mode of rankedModesShort) { - regionOptions.push({ - id: nanoid(), - region, - mode, - span: monthYearToSpan(monthYear), - }); - } +/** + * Rendered as its own component (rather than formatted where the select's items are built) + * because React Aria snapshots item content when it builds the collection, which happens + * before hydration and thus before the locale aware formatting is available. + */ +function MonthYearSpan({ monthYear }: { monthYear: MonthYear }) { + const span = monthYearToSpan(monthYear); - options.push(regionOptions); - } - } - - return options; + return ( + + ); } -function monthYearToSpan(monthYear: MonthYear) { - const date = new Date(monthYear.year, monthYear.month - 1); - const lastMonth = new Date(date.getFullYear(), date.getMonth(), 0); - const threeMonthsAgo = new Date(date.getFullYear(), date.getMonth() - 3, 1); +function ModeFilter() { + const { t } = useTranslation(["game-misc"]); + const [params, setParams] = useSearchParamsTyped(topSearchSearchParams); - return { - from: { - month: threeMonthsAgo.getMonth() + 1, - year: threeMonthsAgo.getFullYear(), - }, - to: { - month: lastMonth.getMonth() + 1, - year: lastMonth.getFullYear(), - }, - value: { - month: date.getMonth() + 1, - year: date.getFullYear(), - }, - }; + return ( + + {rankedModesShort.map((mode) => ( + setParams({ mode })} + > + + + {t(`game-misc:MODE_LONG_${mode}`)} + + + ))} + + ); +} + +function DivisionFilter() { + const { t } = useTranslation(["common"]); + const [params, setParams] = useSearchParamsTyped(topSearchSearchParams); + + return ( + + {DIVISIONS.map(({ region, brandId }) => ( + setParams({ region })} + > + + + {t(`common:divisions.${region}`)} + + + ))} + + ); +} + +function monthYearToKey({ month, year }: MonthYear) { + return `${month}-${year}`; +} + +function keyToMonthYear(key: string): MonthYear { + const [month, year] = key.split("-").map(Number); + + return { month, year }; +} + +/** Locale independent fallback used for type to select, the visible text is localized separately. */ +function monthYearSpanTextValue(monthYear: MonthYear) { + const span = monthYearToSpan(monthYear); + + return `${span.from.month}/${span.from.year} - ${span.to.month}/${span.to.year}`; +} + +function monthYearsGroupedByYear(monthYears: Array) { + const grouped = Object.groupBy(monthYears, (monthYear) => monthYear.year); + + return Object.entries(grouped) + .sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) + .map(([year, monthYearsOfYear]) => ({ + year, + monthYears: (monthYearsOfYear ?? []).sort((a, b) => b.month - a.month), + })); } diff --git a/e2e/pages/top-search/top-search-page.ts b/e2e/pages/top-search/top-search-page.ts index b2e0c936f..720832ab2 100644 --- a/e2e/pages/top-search/top-search-page.ts +++ b/e2e/pages/top-search/top-search-page.ts @@ -12,6 +12,18 @@ type Leaderboard = Pick< mode: RankedModeShort; }; +const MODE_NAMES: Record = { + SZ: "Splat Zones", + TC: "Tower Control", + RM: "Rainmaker", + CB: "Clam Blitz", +}; + +const DIVISION_NAMES: Record = { + WEST: "Tentatek", + JPN: "Takoroka", +}; + /** `/xsearch` */ export class TopSearchPage { private readonly page: Page; @@ -22,7 +34,7 @@ export class TopSearchPage { this.page = page; this.placements = new PlacementsTable(page); this.locators = { - leaderboardSelect: this.page.getByTestId("xsearch-select"), + seasonMonthsSelect: this.page.getByTestId("xsearch-select"), }; } @@ -31,8 +43,13 @@ export class TopSearchPage { } async selectLeaderboard({ month, year, mode, region }: Leaderboard) { - await this.locators.leaderboardSelect.selectOption( - `${month}-${year}-${mode}-${region}`, - ); + await this.locators.seasonMonthsSelect.click(); + // the trigger renders a copy of the selected option, so scope to the listbox + await this.page + .getByRole("listbox") + .getByTestId(`xsearch-select-option-${month}-${year}`) + .click(); + await this.page.getByText(MODE_NAMES[mode], { exact: true }).click(); + await this.page.getByText(DIVISION_NAMES[region], { exact: true }).click(); } }