From 3d8fa042c42797795066294ef50b47cbf5c42484 Mon Sep 17 00:00:00 2001 From: MineKing Date: Fri, 15 May 2026 21:43:11 +0200 Subject: [PATCH] feat: allow filtering finished games by rating status --- packages/backend/src/network/frontendSsr.ts | 7 ++- .../src/network/rest/apiQueryService.ts | 2 + .../src/network/rest/createApiRouter.ts | 3 ++ .../src/persistence/gameHistoryRepository.ts | 18 +++++++- .../src/components/FinishedGamesScreen.tsx | 14 +++++- .../src/components/PublicMatchesList.tsx | 45 +++++-------------- .../src/components/RatedFilterTabs.tsx | 36 +++++++++++++++ .../frontend/src/query/finishedGamesClient.ts | 10 ++++- .../frontend/src/query/queryDefinitions.ts | 3 ++ .../src/routes/FinishedGamesRoute.tsx | 16 ++++++- .../frontend/src/routes/archiveRouteState.ts | 20 ++++++++- packages/frontend/src/utils/ratedFilter.ts | 7 +++ packages/shared/src/queryKeys.ts | 11 ++++- 13 files changed, 146 insertions(+), 46 deletions(-) create mode 100644 packages/frontend/src/components/RatedFilterTabs.tsx create mode 100644 packages/frontend/src/utils/ratedFilter.ts diff --git a/packages/backend/src/network/frontendSsr.ts b/packages/backend/src/network/frontendSsr.ts index 8479d64..44a5810 100644 --- a/packages/backend/src/network/frontendSsr.ts +++ b/packages/backend/src/network/frontendSsr.ts @@ -198,15 +198,20 @@ export class FrontendSsrRenderer { if (path === `/games` || path === `/account/games`) { const archiveView: FinishedGamesArchiveView = path.startsWith(`/account/games`) ? `mine` : `all`; + const ratedFilterParam = requestUrl.searchParams.get(`rated`); + const ratedFilter = ratedFilterParam === `rated` || ratedFilterParam === `unrated` + ? ratedFilterParam + : `all`; const page = parsePositiveInteger(requestUrl.searchParams.get(`page`)) ?? 1; const baseTimestamp = parsePositiveInteger(requestUrl.searchParams.get(`at`)); if (baseTimestamp !== null) { try { queryClient.setQueryData( - queryKeys.finishedGamesPage(archiveView, page, FINISHED_GAMES_PAGE_SIZE, baseTimestamp), + queryKeys.finishedGamesPage(archiveView, ratedFilter, page, FINISHED_GAMES_PAGE_SIZE, baseTimestamp), await this.dependencies.apiQueryService.getFinishedGames(req, { view: archiveView, + ratedFilter, page, pageSize: FINISHED_GAMES_PAGE_SIZE, baseTimestamp, diff --git a/packages/backend/src/network/rest/apiQueryService.ts b/packages/backend/src/network/rest/apiQueryService.ts index 4c8a68f..caabc90 100644 --- a/packages/backend/src/network/rest/apiQueryService.ts +++ b/packages/backend/src/network/rest/apiQueryService.ts @@ -39,6 +39,7 @@ export class ApiRequestError extends Error { type FinishedGamesQueryOptions = { view: FinishedGamesArchiveView; + ratedFilter: `all` | `rated` | `unrated`; page: number; pageSize: number; baseTimestamp: number; @@ -143,6 +144,7 @@ export class ApiQueryService { pageSize: options.pageSize, baseTimestamp: options.baseTimestamp, playerProfileId: options.view === `mine` ? currentUser?.id : undefined, + ratedFilter: options.ratedFilter, }); } diff --git a/packages/backend/src/network/rest/createApiRouter.ts b/packages/backend/src/network/rest/createApiRouter.ts index 69722a0..6ebe76f 100644 --- a/packages/backend/src/network/rest/createApiRouter.ts +++ b/packages/backend/src/network/rest/createApiRouter.ts @@ -55,11 +55,13 @@ const zPositiveInteger = z.coerce.number().int() .positive(); const zPositiveIntegerQueryValue = z.preprocess((value): unknown => Array.isArray(value) ? value[0] : value, zPositiveInteger); const zFinishedGamesView = z.enum([`all`, `mine`]); +const zFinishedGamesRatedFilter = z.enum([`all`, `rated`, `unrated`]); const zFinishedGamesQuery = z.object({ page: zPositiveIntegerQueryValue.optional(), pageSize: zPositiveIntegerQueryValue.optional(), baseTimestamp: zPositiveIntegerQueryValue.optional(), view: z.preprocess((value): unknown => Array.isArray(value) ? value[0] : value, zFinishedGamesView).optional(), + rated: z.preprocess((value): unknown => Array.isArray(value) ? value[0] : value, zFinishedGamesRatedFilter).optional(), }); const zAdminStatsQuery = z.object({ tzOffsetMinutes: z.preprocess( @@ -251,6 +253,7 @@ export class ApiRouter { try { res.json(await this.apiQueryService.getFinishedGames(req, { view: query.view ?? `all`, + ratedFilter: query.rated ?? `all`, page: query.page ?? 1, pageSize: query.pageSize ?? 20, baseTimestamp: query.baseTimestamp ?? Date.now(), diff --git a/packages/backend/src/persistence/gameHistoryRepository.ts b/packages/backend/src/persistence/gameHistoryRepository.ts index 7bcdc1a..e1fceee 100644 --- a/packages/backend/src/persistence/gameHistoryRepository.ts +++ b/packages/backend/src/persistence/gameHistoryRepository.ts @@ -36,6 +36,7 @@ type ListFinishedGamesOptions = { pageSize?: number; baseTimestamp?: number; playerProfileId?: string; + ratedFilter?: `all` | `rated` | `unrated`; }; export type GameHistoryAdminWindowStats = { @@ -239,7 +240,11 @@ export class GameHistoryRepository { const pageSize = this.normalizePageSize(options.pageSize); const baseTimestamp = this.normalizeBaseTimestamp(options.baseTimestamp); const requestedPage = this.normalizePage(options.page); - const matchStage = this.buildFinishedGamesMatch(baseTimestamp, options.playerProfileId); + const matchStage = this.buildFinishedGamesMatch( + baseTimestamp, + options.playerProfileId, + options.ratedFilter ?? `all`, + ); const aggregationResult = await collection.aggregate<{ games: GameHistoryDocument[]; totals: { totalGames: number; totalMoves: number }[]; @@ -917,13 +922,22 @@ export class GameHistoryRepository { return Object.fromEntries(Object.entries(playerTiles).map(([playerId, playerTileConfig]) => [playerId, { ...playerTileConfig }])); } - private buildFinishedGamesMatch(baseTimestamp: number, playerProfileId?: string) { + private buildFinishedGamesMatch( + baseTimestamp: number, + playerProfileId?: string, + ratedFilter: 'all' | 'rated' | 'unrated' = `all`, + ) { + const ratedMatch = ratedFilter === `all` + ? {} + : { 'gameOptions.rated': ratedFilter === `rated` }; + return { finishedAt: { $ne: null, $lte: baseTimestamp, }, ...(playerProfileId ? { 'players.profileId': playerProfileId } : {}), + ...ratedMatch, }; } diff --git a/packages/frontend/src/components/FinishedGamesScreen.tsx b/packages/frontend/src/components/FinishedGamesScreen.tsx index 20a4d93..ecbdc76 100644 --- a/packages/frontend/src/components/FinishedGamesScreen.tsx +++ b/packages/frontend/src/components/FinishedGamesScreen.tsx @@ -1,6 +1,6 @@ import type { FinishedGamesPage, FinishedGameSummary } from '@ih3t/shared'; -import type { FinishedGamesArchiveView } from '../query/queryDefinitions'; +import type { FinishedGamesArchiveView, FinishedGamesRatedFilter } from '../query/queryDefinitions'; import { formatDateTime, useIntlFormatProvider } from '../utils/dateTime'; import { formatCompactDuration } from '../utils/duration'; import { @@ -11,6 +11,7 @@ import { import { getPlayerLabel, getPlayerTileColor } from '../utils/gameBoard'; import { getVisiblePageNumbers } from '../utils/pagination'; import PageCorpus from './PageCorpus'; +import RatedFilterTabs from './RatedFilterTabs'; type FinishedGamesScreenProps = { archive: FinishedGamesPage | null @@ -23,6 +24,8 @@ type FinishedGamesScreenProps = { onOpenGame: (gameId: string) => void onChangePage: (page: number) => void onRefresh: () => void + ratedFilter: FinishedGamesRatedFilter + onChangeRatedFilter: (ratedFilter: FinishedGamesRatedFilter) => void }; function getResultPresentation( @@ -75,6 +78,8 @@ function FinishedGamesScreen({ errorMessage, onOpenGame, onChangePage, + ratedFilter, + onChangeRatedFilter, }: Readonly) { const intlFormatProvider = useIntlFormatProvider(); const isOwnArchive = archiveView === `mine`; @@ -117,6 +122,13 @@ function FinishedGamesScreen({ +
+ +
+ {showSignInHint && (
diff --git a/packages/frontend/src/components/PublicMatchesList.tsx b/packages/frontend/src/components/PublicMatchesList.tsx index c525dee..d285044 100644 --- a/packages/frontend/src/components/PublicMatchesList.tsx +++ b/packages/frontend/src/components/PublicMatchesList.tsx @@ -1,10 +1,12 @@ import type { AccountProfile, LobbyInfo } from '@ih3t/shared'; import { useEffect, useState } from 'react'; +import { useSsrCompatibleNow } from '../ssrState'; import { cn } from '../utils/cn'; import { formatTimeControl } from '../utils/gameTimeControl'; import { formatLobbyLiveDuration } from '../utils/lobby'; -import { useSsrCompatibleNow } from '../ssrState'; +import type { RatedFilter } from '../utils/ratedFilter'; +import RatedFilterTabs from './RatedFilterTabs'; type PublicMatchesListProps = { liveSessions: LobbyInfo[] @@ -15,14 +17,6 @@ type PublicMatchesListProps = { className?: string }; -type LobbyFilter = `all` | `rated` | `unrated`; - -const lobbyFilterOptions: { value: LobbyFilter, label: string }[] = [ - { value: `all`, label: `All` }, - { value: `rated`, label: `Rated` }, - { value: `unrated`, label: `Unrated` }, -]; - function ClockBadgeIcon() { return (
-
- {lobbyFilterOptions.map((filterOption) => { - const isActive = activeFilter === filterOption.value; - return ( - - ); - })} -
+
{filteredSessions.length === 0 ? ( @@ -275,7 +252,7 @@ export default function PublicMatchesList({ {canJoin ? `Lobby` : `Game`} {` `} @@ -285,7 +262,7 @@ export default function PublicMatchesList({ {session.rated ? `Rated` : `Unrated`} @@ -315,7 +292,7 @@ export default function PublicMatchesList({ : canJoin ? `cursor-pointer bg-sky-400 text-slate-950 shadow-[0_10px_30px_rgba(56,189,248,0.28)] hover:-translate-y-0.5 hover:bg-sky-300` : `cursor-pointer border border-white/15 bg-white/8 text-white hover:-translate-y-0.5 hover:bg-white/14` - }`} + }`} > {joinButtonLabel} diff --git a/packages/frontend/src/components/RatedFilterTabs.tsx b/packages/frontend/src/components/RatedFilterTabs.tsx new file mode 100644 index 0000000..d9af2d5 --- /dev/null +++ b/packages/frontend/src/components/RatedFilterTabs.tsx @@ -0,0 +1,36 @@ +import { type RatedFilter, ratedFilterOptions } from '../utils/ratedFilter'; + +type RatedFilterTabsProps = { + value: RatedFilter + onChange: (value: RatedFilter) => void +}; + +export default function RatedFilterTabs({ + value, + onChange, +}: Readonly) { + return ( +
+ {ratedFilterOptions.map((filterOption) => { + const isActive = value === filterOption.value; + return ( + + ); + })} +
+ ); +} diff --git a/packages/frontend/src/query/finishedGamesClient.ts b/packages/frontend/src/query/finishedGamesClient.ts index c0468cb..4fa625a 100644 --- a/packages/frontend/src/query/finishedGamesClient.ts +++ b/packages/frontend/src/query/finishedGamesClient.ts @@ -6,6 +6,7 @@ import { queryClient } from './queryClient'; import { FINISHED_GAMES_PAGE_SIZE, type FinishedGamesArchiveView, + type FinishedGamesRatedFilter, queryKeys, } from './queryDefinitions'; @@ -15,6 +16,7 @@ async function fetchFinishedGames( pageSize: number, baseTimestamp: number, view: FinishedGamesArchiveView, + ratedFilter: FinishedGamesRatedFilter, ) { const params = new URLSearchParams({ page: String(page), @@ -24,6 +26,9 @@ async function fetchFinishedGames( if (view === `mine`) { params.set(`view`, view); } + if (ratedFilter !== `all`) { + params.set(`rated`, ratedFilter); + } return await fetchJson(`/api/finished-games?${params.toString()}`); } @@ -44,11 +49,12 @@ export function useQueryFinishedGames( page: number, baseTimestamp: number, view: FinishedGamesArchiveView, + ratedFilter: FinishedGamesRatedFilter, options?: { enabled?: boolean }, ) { return useQuery({ - queryKey: queryKeys.finishedGamesPage(view, page, FINISHED_GAMES_PAGE_SIZE, baseTimestamp), - queryFn: () => fetchFinishedGames(page, FINISHED_GAMES_PAGE_SIZE, baseTimestamp, view), + queryKey: queryKeys.finishedGamesPage(view, ratedFilter, page, FINISHED_GAMES_PAGE_SIZE, baseTimestamp), + queryFn: () => fetchFinishedGames(page, FINISHED_GAMES_PAGE_SIZE, baseTimestamp, view, ratedFilter), placeholderData: keepPreviousData, enabled: options?.enabled, staleTime: 60 * 60 * 1000, diff --git a/packages/frontend/src/query/queryDefinitions.ts b/packages/frontend/src/query/queryDefinitions.ts index 0199345..707f88d 100644 --- a/packages/frontend/src/query/queryDefinitions.ts +++ b/packages/frontend/src/query/queryDefinitions.ts @@ -4,9 +4,12 @@ import { queryKeys, } from '@ih3t/shared'; +import type { RatedFilter } from '../utils/ratedFilter'; + export { FINISHED_GAMES_PAGE_SIZE, queryKeys, }; +export type FinishedGamesRatedFilter = RatedFilter; export type { FinishedGamesArchiveView }; diff --git a/packages/frontend/src/routes/FinishedGamesRoute.tsx b/packages/frontend/src/routes/FinishedGamesRoute.tsx index bf62c4c..a3b29d5 100644 --- a/packages/frontend/src/routes/FinishedGamesRoute.tsx +++ b/packages/frontend/src/routes/FinishedGamesRoute.tsx @@ -16,6 +16,7 @@ function FinishedGamesRoute() { archiveRouteState?.archivePage ?? 1, archiveRouteState?.archiveBaseTimestamp ?? Date.now(), archiveRouteState?.archiveView ?? `all`, + archiveRouteState?.ratedFilter ?? `all`, { enabled: Boolean(archiveRouteState) && (!isOwnArchive || Boolean(accountQuery.data?.user)) }, ); @@ -30,6 +31,7 @@ function FinishedGamesRoute() { finishedGamesQuery.data.pagination.totalPages, archiveRouteState.archiveBaseTimestamp, archiveRouteState.archiveView, + archiveRouteState.ratedFilter, ), { replace: true }, ); @@ -72,11 +74,21 @@ function FinishedGamesRoute() { ), )} onChangePage={(nextArchivePage) => void navigate( - buildFinishedGamesPath(nextArchivePage, archiveRouteState.archiveBaseTimestamp, archiveRouteState.archiveView), + buildFinishedGamesPath( + nextArchivePage, + archiveRouteState.archiveBaseTimestamp, + archiveRouteState.archiveView, + archiveRouteState.ratedFilter, + ), { replace: true }, )} onRefresh={() => void navigate( - buildFinishedGamesPath(1, Date.now(), archiveRouteState.archiveView), + buildFinishedGamesPath(1, Date.now(), archiveRouteState.archiveView, archiveRouteState.ratedFilter), + { replace: true }, + )} + ratedFilter={archiveRouteState.ratedFilter} + onChangeRatedFilter={(ratedFilter) => void navigate( + buildFinishedGamesPath(1, Date.now(), archiveRouteState.archiveView, ratedFilter), { replace: true }, )} /> diff --git a/packages/frontend/src/routes/archiveRouteState.ts b/packages/frontend/src/routes/archiveRouteState.ts index 1ffb3ba..69f82ec 100644 --- a/packages/frontend/src/routes/archiveRouteState.ts +++ b/packages/frontend/src/routes/archiveRouteState.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router'; -import type { FinishedGamesArchiveView } from '../query/queryDefinitions'; +import type { FinishedGamesArchiveView, FinishedGamesRatedFilter } from '../query/queryDefinitions'; function parseArchivePage(searchParams: URLSearchParams) { const pageValue = searchParams.get(`page`); @@ -19,6 +19,15 @@ function parseArchiveBaseTimestamp(searchParams: URLSearchParams) { return Number.isFinite(value) && value > 0 ? value : null; } +function parseRatedFilter(searchParams: URLSearchParams): FinishedGamesRatedFilter { + const ratedValue = searchParams.get(`rated`); + if (ratedValue === `rated` || ratedValue === `unrated`) { + return ratedValue; + } + + return `all`; +} + export function getArchiveViewFromPath(pathname: string): FinishedGamesArchiveView { return pathname.startsWith(`/account/games`) ? `mine` : `all`; } @@ -27,9 +36,13 @@ export function buildFinishedGamesPath( archivePage: number, archiveBaseTimestamp: number, archiveView: FinishedGamesArchiveView = `all`, + ratedFilter: FinishedGamesRatedFilter = `all`, ) { const searchParams = new URLSearchParams(); searchParams.set(`at`, String(archiveBaseTimestamp)); + if (ratedFilter !== `all`) { + searchParams.set(`rated`, ratedFilter); + } if (archivePage > 1) { searchParams.set(`page`, String(archivePage)); @@ -58,6 +71,7 @@ export function useArchiveRouteState() { const archivePage = parseArchivePage(searchParams); const archiveBaseTimestamp = parseArchiveBaseTimestamp(searchParams); const archiveView = getArchiveViewFromPath(location.pathname); + const ratedFilter = parseRatedFilter(searchParams); useEffect(() => { if (archiveBaseTimestamp) { @@ -68,11 +82,12 @@ export function useArchiveRouteState() { pathname: location.pathname, search: `?${new URLSearchParams({ at: String(Date.now()), + ...(ratedFilter !== `all` ? { rated: ratedFilter } : {}), ...(archivePage > 1 ? { page: String(archivePage) } : {}), }).toString()}`, }, { replace: true }); }, [ - archiveBaseTimestamp, archivePage, archiveView, location.pathname, navigate, + archiveBaseTimestamp, archivePage, archiveView, ratedFilter, location.pathname, navigate, ]); if (!archiveBaseTimestamp) { @@ -83,5 +98,6 @@ export function useArchiveRouteState() { archivePage, archiveBaseTimestamp, archiveView, + ratedFilter, }; } diff --git a/packages/frontend/src/utils/ratedFilter.ts b/packages/frontend/src/utils/ratedFilter.ts new file mode 100644 index 0000000..c2d7126 --- /dev/null +++ b/packages/frontend/src/utils/ratedFilter.ts @@ -0,0 +1,7 @@ +export type RatedFilter = `all` | `rated` | `unrated`; + +export const ratedFilterOptions: readonly { value: RatedFilter; label: string }[] = [ + { value: `all`, label: `All` }, + { value: `rated`, label: `Rated` }, + { value: `unrated`, label: `Unrated` }, +]; diff --git a/packages/shared/src/queryKeys.ts b/packages/shared/src/queryKeys.ts index e61898d..8df9f31 100644 --- a/packages/shared/src/queryKeys.ts +++ b/packages/shared/src/queryKeys.ts @@ -1,5 +1,6 @@ export const FINISHED_GAMES_PAGE_SIZE = 20; export type FinishedGamesArchiveView = `all` | `mine`; +export type FinishedGamesRatedFilter = `all` | `rated` | `unrated`; export const queryKeys = { account: [`account`] as const, @@ -26,9 +27,15 @@ export const queryKeys = { sandboxPosition: (positionId: string | null) => [`sandbox-position`, positionId ?? `none`] as const, finishedGames: [`finished-games`] as const, - finishedGamesPage: (view: FinishedGamesArchiveView, page: number, pageSize: number, baseTimestamp: number) => + finishedGamesPage: ( + view: FinishedGamesArchiveView, + ratedFilter: FinishedGamesRatedFilter, + page: number, + pageSize: number, + baseTimestamp: number, + ) => [ - `finished-games`, view, page, pageSize, baseTimestamp, + `finished-games`, view, ratedFilter, page, pageSize, baseTimestamp, ] as const, finishedGame: (gameId: string | null) => [`finished-games`, gameId ?? `empty`] as const,