diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 8214c67c3..c0a2ba1da 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -432,11 +432,9 @@ const withMaxEventStartTime = (eb: ExpressionBuilder) => { .whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id") .as("startTime"); }; -export function findResultsByUserId( - userId: number, - { showHighlightsOnly = false }: { showHighlightsOnly?: boolean } = {}, -) { - let calendarEventResultsQuery = db + +const baseCalendarEventResultsQuery = (userId: number) => + db .selectFrom("CalendarEventResultPlayer") .innerJoin( "CalendarEventResultTeam", @@ -453,7 +451,33 @@ export function findResultsByUserId( .onRef("UserResultHighlight.teamId", "=", "CalendarEventResultTeam.id") .on("UserResultHighlight.userId", "=", userId), ) - .select(({ eb, fn }) => [ + .where("CalendarEventResultPlayer.userId", "=", userId); + +const baseTournamentResultsQuery = (userId: number) => + db + .selectFrom("TournamentResult") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentResult.tournamentTeamId", + ) + .innerJoin( + "CalendarEvent", + "CalendarEvent.tournamentId", + "TournamentResult.tournamentId", + ) + .where("TournamentResult.userId", "=", userId); + +export function findResultsByUserId( + userId: number, + { + showHighlightsOnly = false, + limit, + offset, + }: { showHighlightsOnly?: boolean; limit?: number; offset?: number } = {}, +) { + let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select( + ({ eb, fn }) => [ "CalendarEvent.id as eventId", sql`null`.as("tournamentId"), "CalendarEventResultTeam.placement", @@ -486,22 +510,11 @@ export function findResultsByUserId( ]), ), ).as("mates"), - ]) - .where("CalendarEventResultPlayer.userId", "=", userId); + ], + ); - let tournamentResultsQuery = db - .selectFrom("TournamentResult") - .innerJoin( - "TournamentTeam", - "TournamentTeam.id", - "TournamentResult.tournamentTeamId", - ) - .innerJoin( - "CalendarEvent", - "CalendarEvent.tournamentId", - "TournamentResult.tournamentId", - ) - .select(({ eb }) => [ + let tournamentResultsQuery = baseTournamentResultsQuery(userId).select( + ({ eb }) => [ sql`null`.as("eventId"), "TournamentResult.tournamentId", "TournamentResult.placement", @@ -529,8 +542,8 @@ export function findResultsByUserId( ) .where("TournamentResult2.userId", "!=", userId), ).as("mates"), - ]) - .where("TournamentResult.userId", "=", userId); + ], + ); if (showHighlightsOnly) { calendarEventResultsQuery = calendarEventResultsQuery.where( @@ -545,11 +558,53 @@ export function findResultsByUserId( ); } - return calendarEventResultsQuery + let query = calendarEventResultsQuery .unionAll(tournamentResultsQuery) .orderBy("startTime", "desc") - .$narrowType<{ startTime: NotNull }>() - .execute(); + .$narrowType<{ startTime: NotNull }>(); + + if (limit !== undefined) { + query = query.limit(limit); + } + + if (offset !== undefined) { + query = query.offset(offset); + } + + return query.execute(); +} + +export async function countResultsByUserId( + userId: number, + { showHighlightsOnly = false }: { showHighlightsOnly?: boolean } = {}, +) { + let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select( + ({ fn }) => [fn.countAll().as("count")], + ); + + let tournamentResultsQuery = baseTournamentResultsQuery(userId).select( + ({ fn }) => [fn.countAll().as("count")], + ); + + if (showHighlightsOnly) { + calendarEventResultsQuery = calendarEventResultsQuery.where( + "UserResultHighlight.userId", + "is not", + null, + ); + tournamentResultsQuery = tournamentResultsQuery.where( + "TournamentResult.isHighlight", + "=", + 1, + ); + } + + const [calendarEventResults, tournamentResults] = await Promise.all([ + calendarEventResultsQuery.executeTakeFirst(), + tournamentResultsQuery.executeTakeFirst(), + ]); + + return (calendarEventResults?.count ?? 0) + (tournamentResults?.count ?? 0); } export async function hasHighlightedResultsByUserId(userId: number) { diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index a6c0877f8..38aa85c1e 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -20,7 +20,7 @@ import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.ser import { ParticipationPill } from "./ParticipationPill"; export type UserResultsTableProps = { - results: UserResultsLoaderData["results"]; + results: UserResultsLoaderData["results"]["value"]; id: string; hasHighlightCheckboxes?: boolean; }; diff --git a/app/features/user-page/loaders/u.$identifier.results.server.ts b/app/features/user-page/loaders/u.$identifier.results.server.ts index ae13d19ac..b8eb2b7ef 100644 --- a/app/features/user-page/loaders/u.$identifier.results.server.ts +++ b/app/features/user-page/loaders/u.$identifier.results.server.ts @@ -1,6 +1,8 @@ import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { notFoundIfFalsy, parseSafeSearchParams } from "~/utils/remix.server"; +import { RESULTS_PER_PAGE } from "../user-page-constants"; import { userResultsPageSearchParamsSchema } from "../user-page-schemas"; export type UserResultsLoaderData = SerializeFrom; @@ -30,10 +32,45 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { showHighlightsOnly = false; } - return { - results: await UserRepository.findResultsByUserId(userId, { + const page = parsedSearchParams.success ? parsedSearchParams.data.page : 1; + + const [results, totalCount] = await Promise.all([ + UserRepository.findResultsByUserId(userId, { showHighlightsOnly, + ...(isChoosingHighlights + ? {} + : { limit: RESULTS_PER_PAGE, offset: (page - 1) * RESULTS_PER_PAGE }), }), + UserRepository.countResultsByUserId(userId, { showHighlightsOnly }), + ]); + + const maxPage = Math.ceil(totalCount / RESULTS_PER_PAGE); + + redirectIfPageOutOfBounds({ request, page, maxPage }); + + return { + results: { + value: results, + currentPage: page, + pages: maxPage, + }, hasHighlightedResults, }; }; + +function redirectIfPageOutOfBounds({ + request, + page, + maxPage, +}: { + request: Request; + page: number; + maxPage: number; +}) { + if (page <= maxPage || page === 1) return; + + const url = new URL(request.url); + const searchParams = new URLSearchParams(url.searchParams); + searchParams.set("page", String(maxPage)); + throw redirect(`${url.pathname}?${searchParams.toString()}`); +} diff --git a/app/features/user-page/routes/u.$identifier.results.highlights.tsx b/app/features/user-page/routes/u.$identifier.results.highlights.tsx index db52bc4a1..8aef65523 100644 --- a/app/features/user-page/routes/u.$identifier.results.highlights.tsx +++ b/app/features/user-page/routes/u.$identifier.results.highlights.tsx @@ -21,7 +21,7 @@ export default function ResultHighlightsEditPage() { {t("user:results.highlights.explanation")} diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 27da0e34b..2c8521861 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -1,6 +1,7 @@ import { useLoaderData, useMatches, useSearchParams } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { LinkButton } from "~/components/elements/Button"; +import { Pagination } from "~/components/Pagination"; import { useUser } from "~/features/auth/core/user"; import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; import invariant from "~/utils/invariant"; @@ -22,6 +23,13 @@ export default function UserResultsPage() { const [searchParams, setSearchParams] = useSearchParams(); const showAll = searchParams.get("all") === "true"; + const setPage = (page: number) => { + setSearchParams((params) => { + params.set("page", String(page)); + return params; + }); + }; + return (
@@ -38,7 +46,16 @@ export default function UserResultsPage() { ) : null}
- + + {data.results.pages > 1 ? ( + setPage(data.results.currentPage + 1)} + previousPage={() => setPage(data.results.currentPage - 1)} + setPage={setPage} + /> + ) : null} {data.hasHighlightedResults ? ( setSearchParams((params) => { params.set("all", showAll ? "false" : "true"); + params.delete("page"); return params; }) diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index 1f891fc25..5673ab269 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -11,6 +11,7 @@ export const USER = { }; export const MATCHES_PER_SEASONS_PAGE = 8; +export const RESULTS_PER_PAGE = 25; export const DEFAULT_BUILD_SORT = ["WEAPON_POOL", "UPDATED_AT"] as const; export const CUSTOM_CSS_VAR_COLORS = [ diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 308a8665e..fe955192c 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -160,5 +160,6 @@ export const adminTabActionSchema = z.union([ ]); export const userResultsPageSearchParamsSchema = z.object({ - all: z.stringbool(), + all: z.stringbool().catch(false), + page: z.coerce.number().min(1).max(1_000).catch(1), }); diff --git a/migrations/099-missing-user-results-index.js b/migrations/099-missing-user-results-index.js new file mode 100644 index 000000000..004ffe41c --- /dev/null +++ b/migrations/099-missing-user-results-index.js @@ -0,0 +1,9 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create index calendar_event_date_event_id_start_time on "CalendarEventDate"("eventId", "startTime" desc) + `, + ).run(); + })(); +}