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 = ({