Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/backend/src/network/frontendSsr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/network/rest/apiQueryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class ApiRequestError extends Error {

type FinishedGamesQueryOptions = {
view: FinishedGamesArchiveView;
ratedFilter: `all` | `rated` | `unrated`;
page: number;
pageSize: number;
baseTimestamp: number;
Expand Down Expand Up @@ -143,6 +144,7 @@ export class ApiQueryService {
pageSize: options.pageSize,
baseTimestamp: options.baseTimestamp,
playerProfileId: options.view === `mine` ? currentUser?.id : undefined,
ratedFilter: options.ratedFilter,
});
}

Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/network/rest/createApiRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
Expand Down
18 changes: 16 additions & 2 deletions packages/backend/src/persistence/gameHistoryRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type ListFinishedGamesOptions = {
pageSize?: number;
baseTimestamp?: number;
playerProfileId?: string;
ratedFilter?: `all` | `rated` | `unrated`;
};

export type GameHistoryAdminWindowStats = {
Expand Down Expand Up @@ -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 }[];
Expand Down Expand Up @@ -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,
};
}

Expand Down
14 changes: 13 additions & 1 deletion packages/frontend/src/components/FinishedGamesScreen.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -23,6 +24,8 @@ type FinishedGamesScreenProps = {
onOpenGame: (gameId: string) => void
onChangePage: (page: number) => void
onRefresh: () => void
ratedFilter: FinishedGamesRatedFilter
onChangeRatedFilter: (ratedFilter: FinishedGamesRatedFilter) => void
};

function getResultPresentation(
Expand Down Expand Up @@ -75,6 +78,8 @@ function FinishedGamesScreen({
errorMessage,
onOpenGame,
onChangePage,
ratedFilter,
onChangeRatedFilter,
}: Readonly<FinishedGamesScreenProps>) {
const intlFormatProvider = useIntlFormatProvider();
const isOwnArchive = archiveView === `mine`;
Expand Down Expand Up @@ -117,6 +122,13 @@ function FinishedGamesScreen({
</span>
</div>

<div className="col-span-2 lg:col-span-1 lg:ml-auto">
<RatedFilterTabs
value={ratedFilter}
onChange={onChangeRatedFilter}
/>
</div>

{showSignInHint && (
<div className="mt-2 col-span-2 lg:col-span-1 ml-auto lg:mt-[-2em] w-full lg:text-right lg:max-w-md rounded-[1.35rem] border border-amber-300/20 bg-amber-300/10 px-4 py-3 text-sm text-amber-50 sm:px-5">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-amber-100/90">
Expand Down
45 changes: 11 additions & 34 deletions packages/frontend/src/components/PublicMatchesList.tsx
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -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 (
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-4 w-4 fill-none stroke-current">
Expand Down Expand Up @@ -161,7 +155,7 @@ export default function PublicMatchesList({
return () => window.clearInterval(interval);
}, []);

const [activeFilter, setActiveFilter] = useState<LobbyFilter>(`all`);
const [activeFilter, setActiveFilter] = useState<RatedFilter>(`all`);
const filteredSessions = liveSessions.filter((session) => {
if (activeFilter === `rated`) {
return session.rated;
Expand Down Expand Up @@ -224,27 +218,10 @@ export default function PublicMatchesList({
</div>

<div className="relative mt-5 flex flex-col min-h-0 gap-4 sm:mt-6">
<div className="inline-flex w-full max-w-max rounded-full border border-white/10 bg-slate-900 p-1">
{lobbyFilterOptions.map((filterOption) => {
const isActive = activeFilter === filterOption.value;
return (
<button
key={filterOption.value}
type="button"
aria-pressed={isActive}
onClick={() => setActiveFilter(filterOption.value)}
className={cn(
`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] transition sm:px-5`,
isActive
? `bg-sky-300 text-slate-950`
: `cursor-pointer text-slate-300 hover:bg-slate-800 hover:text-white`,
)}
>
{filterOption.label}
</button>
);
})}
</div>
<RatedFilterTabs
value={activeFilter}
onChange={setActiveFilter}
/>

<div className="min-h-0 sm:flex-1 flex-col sm:overflow-y-auto sm:overscroll-contain sm:pr-1 lg:flex-1 lg:overflow-y-auto lg:overscroll-contain lg:pr-1">
{filteredSessions.length === 0 ? (
Expand Down Expand Up @@ -275,7 +252,7 @@ export default function PublicMatchesList({
<span className={`rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${canJoin
? `bg-emerald-400/15 text-emerald-200`
: `bg-sky-400/15 text-sky-200`
}`}
}`}
>
{canJoin ? `Lobby` : `Game`}
{` `}
Expand All @@ -285,7 +262,7 @@ export default function PublicMatchesList({
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${session.rated
? `bg-amber-300/15 text-amber-100`
: `bg-white/8 text-slate-200`
}`}
}`}
>
<ModeBadgeIcon rated={session.rated} />
{session.rated ? `Rated` : `Unrated`}
Expand Down Expand Up @@ -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}
</button>
Expand Down
36 changes: 36 additions & 0 deletions packages/frontend/src/components/RatedFilterTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<RatedFilterTabsProps>) {
return (
<div className="inline-flex w-full max-w-max rounded-full border border-white/10 bg-slate-900 p-1">
{ratedFilterOptions.map((filterOption) => {
const isActive = value === filterOption.value;
return (
<button
key={filterOption.value}
type="button"
aria-pressed={isActive}
onClick={() => onChange(filterOption.value)}
className={
`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] transition sm:px-5 ${
isActive
? `bg-sky-300 text-slate-950`
: `cursor-pointer text-slate-300 hover:bg-slate-800 hover:text-white`
}`
}
>
{filterOption.label}
</button>
);
})}
</div>
);
}
10 changes: 8 additions & 2 deletions packages/frontend/src/query/finishedGamesClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { queryClient } from './queryClient';
import {
FINISHED_GAMES_PAGE_SIZE,
type FinishedGamesArchiveView,
type FinishedGamesRatedFilter,
queryKeys,
} from './queryDefinitions';

Expand All @@ -15,6 +16,7 @@ async function fetchFinishedGames(
pageSize: number,
baseTimestamp: number,
view: FinishedGamesArchiveView,
ratedFilter: FinishedGamesRatedFilter,
) {
const params = new URLSearchParams({
page: String(page),
Expand All @@ -24,6 +26,9 @@ async function fetchFinishedGames(
if (view === `mine`) {
params.set(`view`, view);
}
if (ratedFilter !== `all`) {
params.set(`rated`, ratedFilter);
}

return await fetchJson<FinishedGamesPage>(`/api/finished-games?${params.toString()}`);
}
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/frontend/src/query/queryDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
16 changes: 14 additions & 2 deletions packages/frontend/src/routes/FinishedGamesRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) },
);

Expand All @@ -30,6 +31,7 @@ function FinishedGamesRoute() {
finishedGamesQuery.data.pagination.totalPages,
archiveRouteState.archiveBaseTimestamp,
archiveRouteState.archiveView,
archiveRouteState.ratedFilter,
),
{ replace: true },
);
Expand Down Expand Up @@ -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 },
)}
/>
Expand Down
Loading
Loading