diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8558bd798..4e2b56bcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -60,6 +60,8 @@ import CodeOfConduct from "views/CodeOfConduct"; import Client from "views/Client"; import AdminTournament from "views/AdminTournament"; import { adminTournamentLoader } from "api/loaders/adminTournamentLoader"; +import { matchDetailsLoader } from "api/loaders/matchDetailsLoader"; +import MatchDetails from "views/MatchDetails"; const queryClient = new QueryClient({ queryCache: new QueryCache({ @@ -178,6 +180,11 @@ const router = createBrowserRouter([ path: "client", element: , }, + { + path: "match/:matchId", + element: , + loader: matchDetailsLoader(queryClient), + }, ], }, // Pages that should always be visible diff --git a/frontend/src/api/compete/competeApi.ts b/frontend/src/api/compete/competeApi.ts index 12e116f7c..55b2ba468 100644 --- a/frontend/src/api/compete/competeApi.ts +++ b/frontend/src/api/compete/competeApi.ts @@ -4,6 +4,7 @@ import { type PaginatedSubmissionList, type PaginatedScrimmageRequestList, type PaginatedMatchList, + type CompeteSubmissionRetrieveRequest, type CompeteSubmissionCreateRequest, type CompeteSubmissionDownloadRetrieveRequest, type CompeteSubmissionListRequest, @@ -14,6 +15,8 @@ import { type CompeteRequestOutboxListRequest, type CompeteRequestCreateRequest, type ScrimmageRequest, + type Match, + type CompeteMatchRetrieveRequest, type CompeteMatchScrimmageListRequest, type CompeteMatchTournamentListRequest, type CompeteMatchListRequest, @@ -73,6 +76,12 @@ export const downloadSubmission = async ({ await downloadFile(url, `battlecode_source_${id}.zip`); }; +export const getSubmissionInfo = async ({ + episodeId, + id, +}: CompeteSubmissionRetrieveRequest): Promise => + await API.competeSubmissionRetrieve({ episodeId, id }); + /** * Get a paginated list of all of the current user's team's submissions. * @param episodeId The current episode's ID. @@ -203,6 +212,15 @@ export const getTournamentMatchesList = async ({ tournamentId, }); +export const getMatchInfo = async ({ + episodeId, + id, +}: CompeteMatchRetrieveRequest): Promise => + await API.competeMatchRetrieve({ + episodeId, + id, + }); + /** * Get all of the matches played in the given episode. Includes both tournament * matches and scrimmages. diff --git a/frontend/src/api/compete/competeFactories.ts b/frontend/src/api/compete/competeFactories.ts index d37a3420e..f4d7245b0 100644 --- a/frontend/src/api/compete/competeFactories.ts +++ b/frontend/src/api/compete/competeFactories.ts @@ -1,6 +1,7 @@ import type { CompeteMatchHistoricalRatingTopNListRequest, CompeteMatchHistoricalRatingRetrieveRequest, + CompeteMatchRetrieveRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, CompeteMatchScrimmagingRecordRetrieveRequest, @@ -10,16 +11,20 @@ import type { CompeteSubmissionListRequest, CompeteSubmissionTournamentListRequest, HistoricalRating, + Match, PaginatedMatchList, PaginatedScrimmageRequestList, PaginatedSubmissionList, TournamentSubmission, ScrimmageRecord, + CompeteSubmissionRetrieveRequest, + Submission, } from "../_autogen"; import type { PaginatedQueryFactory, QueryFactory } from "../apiTypes"; import { competeQueryKeys } from "./competeKeys"; import { getAllUserTournamentSubmissions, + getMatchInfo, getMatchesList, getRatingTopNList, getRatingHistory, @@ -29,9 +34,19 @@ import { getTournamentMatchesList, getUserScrimmagesInboxList, getUserScrimmagesOutboxList, + getSubmissionInfo, } from "./competeApi"; import { prefetchNextPage } from "../helpers"; +export const submissionInfoFactory: QueryFactory< + CompeteSubmissionRetrieveRequest, + Submission +> = { + queryKey: competeQueryKeys.subInfo, + queryFn: async ({ episodeId, id }) => + await getSubmissionInfo({ episodeId, id }), +} as const; + export const subsListFactory: PaginatedQueryFactory< CompeteSubmissionListRequest, PaginatedSubmissionList @@ -161,6 +176,14 @@ export const teamScrimmageListFactory: PaginatedQueryFactory< }, } as const; +export const matchInfoFactory: QueryFactory< + CompeteMatchRetrieveRequest, + Match +> = { + queryKey: competeQueryKeys.matchInfo, + queryFn: async ({ episodeId, id }) => await getMatchInfo({ episodeId, id }), +} as const; + export const matchListFactory: PaginatedQueryFactory< CompeteMatchListRequest, PaginatedMatchList diff --git a/frontend/src/api/compete/competeKeys.ts b/frontend/src/api/compete/competeKeys.ts index 85c8c80df..57fe80490 100644 --- a/frontend/src/api/compete/competeKeys.ts +++ b/frontend/src/api/compete/competeKeys.ts @@ -1,6 +1,7 @@ import type { CompeteMatchHistoricalRatingTopNListRequest, CompeteMatchHistoricalRatingRetrieveRequest, + CompeteMatchRetrieveRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, CompeteMatchScrimmagingRecordRetrieveRequest, @@ -9,12 +10,14 @@ import type { CompeteRequestOutboxListRequest, CompeteSubmissionListRequest, CompeteSubmissionTournamentListRequest, + CompeteSubmissionRetrieveRequest, } from "../_autogen"; import type { QueryKeyBuilder } from "../apiTypes"; interface CompeteKeys { // --- SUBMISSIONS --- // subBase: QueryKeyBuilder<{ episodeId: string }>; + subInfo: QueryKeyBuilder; subList: QueryKeyBuilder; tourneySubs: QueryKeyBuilder; // --- SCRIMMAGES --- // @@ -25,6 +28,7 @@ interface CompeteKeys { scrimsOtherList: QueryKeyBuilder; // --- MATCHES --- // matchBase: QueryKeyBuilder<{ episodeId: string }>; + matchInfo: QueryKeyBuilder; matchList: QueryKeyBuilder; tourneyMatchList: QueryKeyBuilder; // --- PERFORMANCE --- // @@ -43,6 +47,11 @@ export const competeQueryKeys: CompeteKeys = { ["compete", episodeId, "submissions"] as const, }, + subInfo: { + key: ({ episodeId, id }: CompeteSubmissionRetrieveRequest) => + [...competeQueryKeys.subBase.key({ episodeId }), "info", id] as const, + }, + subList: { key: ({ episodeId, page = 1 }: CompeteSubmissionListRequest) => [...competeQueryKeys.subBase.key({ episodeId }), "list", page] as const, @@ -103,6 +112,11 @@ export const competeQueryKeys: CompeteKeys = { ["compete", episodeId, "matches"] as const, }, + matchInfo: { + key: ({ episodeId, id }: CompeteMatchRetrieveRequest) => + [...competeQueryKeys.matchBase.key({ episodeId }), "info", id] as const, + }, + matchList: { key: ({ episodeId, page = 1 }: CompeteMatchListRequest) => [...competeQueryKeys.matchBase.key({ episodeId }), "list", page] as const, diff --git a/frontend/src/api/compete/useCompete.ts b/frontend/src/api/compete/useCompete.ts index d9993ed4a..a3f6f3f87 100644 --- a/frontend/src/api/compete/useCompete.ts +++ b/frontend/src/api/compete/useCompete.ts @@ -9,6 +9,7 @@ import { competeMutationKeys, competeQueryKeys } from "./competeKeys"; import type { CompeteMatchHistoricalRatingTopNListRequest, CompeteMatchHistoricalRatingRetrieveRequest, + CompeteMatchRetrieveRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, CompeteMatchScrimmagingRecordRetrieveRequest, @@ -20,10 +21,12 @@ import type { CompeteRequestOutboxListRequest, CompeteRequestRejectCreateRequest, CompeteSubmissionCreateRequest, + CompeteSubmissionRetrieveRequest, CompeteSubmissionListRequest, CompeteSubmissionTournamentListRequest, CompeteSubmissionDownloadRetrieveRequest, HistoricalRating, + Match, PaginatedMatchList, PaginatedScrimmageRequestList, PaginatedSubmissionList, @@ -44,6 +47,7 @@ import { import toast from "react-hot-toast"; import { buildKey } from "../helpers"; import { + matchInfoFactory, matchListFactory, ratingHistoryTopNFactory, userRatingHistoryFactory, @@ -56,12 +60,22 @@ import { tournamentSubsListFactory, userScrimmageListFactory, ratingHistoryFactory, + submissionInfoFactory, } from "./competeFactories"; import { MILLIS_SECOND, SECONDS_MINUTE } from "utils/utilTypes"; // ---------- QUERY HOOKS ---------- // const STATISTICS_WAIT_MINUTES = 5; +export const useSubmissionInfo = ({ + episodeId, + id, +}: CompeteSubmissionRetrieveRequest): UseQueryResult => + useQuery({ + queryKey: buildKey(submissionInfoFactory.queryKey, { episodeId, id }), + queryFn: async () => await submissionInfoFactory.queryFn({ episodeId, id }), + }); + /** * For retrieving a list of the currently logged in user's submissions. */ @@ -163,6 +177,15 @@ export const useTeamScrimmageList = ( ), }); +export const useMatchInfo = ({ + episodeId, + id, +}: CompeteMatchRetrieveRequest): UseQueryResult => + useQuery({ + queryKey: buildKey(matchInfoFactory.queryKey, { episodeId, id }), + queryFn: async () => await matchInfoFactory.queryFn({ episodeId, id }), + }); + /** * For retrieving a paginated list of the matches in a given episode. */ diff --git a/frontend/src/api/loaders/matchDetailsLoader.ts b/frontend/src/api/loaders/matchDetailsLoader.ts new file mode 100644 index 000000000..314d83415 --- /dev/null +++ b/frontend/src/api/loaders/matchDetailsLoader.ts @@ -0,0 +1,25 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { matchInfoFactory } from "api/compete/competeFactories"; +import { safeEnsureQueryData } from "api/helpers"; +import type { LoaderFunction } from "react-router-dom"; +import { isPresent } from "utils/utilTypes"; + +export const matchDetailsLoader = + (queryClient: QueryClient): LoaderFunction => + ({ params }) => { + const { episodeId, id } = params; + + if (!isPresent(id) || !isPresent(episodeId)) return null; + + // Load match info + safeEnsureQueryData( + { + episodeId, + id, + }, + matchInfoFactory, + queryClient, + ); + + return null; + }; diff --git a/frontend/src/api/team/useTeam.ts b/frontend/src/api/team/useTeam.ts index 20b8af324..578418356 100644 --- a/frontend/src/api/team/useTeam.ts +++ b/frontend/src/api/team/useTeam.ts @@ -35,7 +35,7 @@ import { import { buildKey } from "../helpers"; import { userRatingHistoryFactory } from "api/compete/competeFactories"; import { competeQueryKeys } from "api/compete/competeKeys"; -import { MILLIS_SECOND } from "utils/utilTypes"; +import { type Maybe, MILLIS_SECOND } from "utils/utilTypes"; // ---------- QUERY HOOKS ---------- // const SEARCH_WAIT_SECONDS = 30; @@ -129,14 +129,17 @@ export const useCreateTeam = ( export const useJoinTeam = ( { episodeId }: { episodeId: string }, queryClient: QueryClient, -): UseMutationResult => - useMutation({ +): UseMutationResult => { + let err: Maybe = undefined; + const getErr = (): string => err ?? "Error joining team."; + + return useMutation({ mutationKey: teamMutationKeys.join({ episodeId }), mutationFn: async (teamJoinRequest: TeamJoinRequest) => { await toast.promise(joinTeam({ episodeId, teamJoinRequest }), { loading: "Joining team...", success: "Joined team!", - error: "Error joining team.", + error: getErr, }); }, onSuccess: async () => { @@ -153,7 +156,12 @@ export const useJoinTeam = ( queryKey: competeQueryKeys.scrimBase.key({ episodeId }), }); }, + onError: (error) => { + err = `${error.name}: ${error.message}`; + console.log(err); + }, }); +}; /** * Leave the user's current team in a given episode. diff --git a/frontend/src/components/MatchReplayButton.tsx b/frontend/src/components/MatchReplayButton.tsx index 56390b2d4..59757952a 100644 --- a/frontend/src/components/MatchReplayButton.tsx +++ b/frontend/src/components/MatchReplayButton.tsx @@ -32,10 +32,11 @@ const MatchReplayButton: React.FC = ({