diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8558bd798..e40fcbcf8 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 MatchProfile from "views/MatchProfile";
+import { matchProfileLoader } from "api/loaders/matchProfileLoader";
const queryClient = new QueryClient({
queryCache: new QueryCache({
@@ -178,6 +180,11 @@ const router = createBrowserRouter([
path: "client",
element: ,
},
+ {
+ path: "match/:matchId",
+ element: ,
+ loader: matchProfileLoader(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..398025976 100644
--- a/frontend/src/api/compete/competeApi.ts
+++ b/frontend/src/api/compete/competeApi.ts
@@ -1,29 +1,32 @@
import {
- CompeteApi,
- type TournamentSubmission,
- type PaginatedSubmissionList,
- type PaginatedScrimmageRequestList,
- type PaginatedMatchList,
- type CompeteSubmissionCreateRequest,
- type CompeteSubmissionDownloadRetrieveRequest,
- type CompeteSubmissionListRequest,
- type Submission,
- type CompeteRequestAcceptCreateRequest,
- type CompeteRequestRejectCreateRequest,
- type CompeteRequestInboxListRequest,
- type CompeteRequestOutboxListRequest,
- type CompeteRequestCreateRequest,
- type ScrimmageRequest,
- type CompeteMatchScrimmageListRequest,
- type CompeteMatchTournamentListRequest,
- type CompeteMatchListRequest,
- type CompeteSubmissionTournamentListRequest,
- type CompeteRequestDestroyRequest,
- type CompeteMatchHistoricalRatingTopNListRequest,
- type HistoricalRating,
- type CompeteMatchScrimmagingRecordRetrieveRequest,
- type ScrimmageRecord,
- type CompeteMatchHistoricalRatingRetrieveRequest,
+ CompeteApi,
+ type TournamentSubmission,
+ type PaginatedSubmissionList,
+ type PaginatedScrimmageRequestList,
+ type PaginatedMatchList,
+ type CompeteSubmissionRetrieveRequest,
+ type CompeteSubmissionCreateRequest,
+ type CompeteSubmissionDownloadRetrieveRequest,
+ type CompeteSubmissionListRequest,
+ type Submission,
+ type CompeteRequestAcceptCreateRequest,
+ type CompeteRequestRejectCreateRequest,
+ type CompeteRequestInboxListRequest,
+ type CompeteRequestOutboxListRequest,
+ type CompeteRequestCreateRequest,
+ type ScrimmageRequest,
+ type Match,
+ type CompeteMatchRetrieveRequest,
+ type CompeteMatchScrimmageListRequest,
+ type CompeteMatchTournamentListRequest,
+ type CompeteMatchListRequest,
+ type CompeteSubmissionTournamentListRequest,
+ type CompeteRequestDestroyRequest,
+ type CompeteMatchHistoricalRatingTopNListRequest,
+ type HistoricalRating,
+ type CompeteMatchScrimmagingRecordRetrieveRequest,
+ type ScrimmageRecord,
+ type CompeteMatchHistoricalRatingRetrieveRequest,
} from "../_autogen";
import { DEFAULT_API_CONFIGURATION, downloadFile } from "../helpers";
@@ -73,6 +76,11 @@ 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.
@@ -93,6 +101,7 @@ export const getAllUserTournamentSubmissions = async ({
}: CompeteSubmissionTournamentListRequest): Promise =>
await API.competeSubmissionTournamentList({ episodeId });
+
/**
* Accept a scrimmage invitation.
* @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..a29da67b3 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,18 @@ import type {
CompeteSubmissionListRequest,
CompeteSubmissionTournamentListRequest,
HistoricalRating,
+ Match,
PaginatedMatchList,
PaginatedScrimmageRequestList,
PaginatedSubmissionList,
TournamentSubmission,
- ScrimmageRecord,
+ ScrimmageRecord, CompeteSubmissionRetrieveRequest, Submission
} from "../_autogen";
import type { PaginatedQueryFactory, QueryFactory } from "../apiTypes";
import { competeQueryKeys } from "./competeKeys";
import {
getAllUserTournamentSubmissions,
+ getMatchInfo,
getMatchesList,
getRatingTopNList,
getRatingHistory,
@@ -28,10 +31,18 @@ import {
getSubmissionsList,
getTournamentMatchesList,
getUserScrimmagesInboxList,
- getUserScrimmagesOutboxList,
+ 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 +172,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..56ca292da 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,
@@ -8,13 +9,14 @@ import type {
CompeteRequestInboxListRequest,
CompeteRequestOutboxListRequest,
CompeteSubmissionListRequest,
- CompeteSubmissionTournamentListRequest,
+ 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 +27,7 @@ interface CompeteKeys {
scrimsOtherList: QueryKeyBuilder;
// --- MATCHES --- //
matchBase: QueryKeyBuilder<{ episodeId: string }>;
+ matchInfo: QueryKeyBuilder;
matchList: QueryKeyBuilder;
tourneyMatchList: QueryKeyBuilder;
// --- PERFORMANCE --- //
@@ -43,6 +46,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 +111,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..7d397d6c5 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,
@@ -55,13 +59,22 @@ import {
tournamentMatchListFactory,
tournamentSubsListFactory,
userScrimmageListFactory,
- ratingHistoryFactory,
+ 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 +176,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/matchProfileLoader.ts b/frontend/src/api/loaders/matchProfileLoader.ts
new file mode 100644
index 000000000..e83ef4722
--- /dev/null
+++ b/frontend/src/api/loaders/matchProfileLoader.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 matchProfileLoader =
+ (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/components/tables/scrimmaging/ScrimHistoryTable.tsx b/frontend/src/components/tables/scrimmaging/ScrimHistoryTable.tsx
index a22d181a2..8d5fa59d8 100644
--- a/frontend/src/components/tables/scrimmaging/ScrimHistoryTable.tsx
+++ b/frontend/src/components/tables/scrimmaging/ScrimHistoryTable.tsx
@@ -11,6 +11,7 @@ import { useEpisodeId } from "contexts/EpisodeContext";
import { useUserTeam } from "api/team/useTeam";
import { useUserScrimmageList } from "api/compete/useCompete";
import { useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
import MatchReplayButton from "components/MatchReplayButton";
interface ScrimHistoryTableProps {
@@ -24,6 +25,7 @@ const ScrimHistoryTable: React.FC = ({
}) => {
const { episodeId } = useEpisodeId();
const queryClient = useQueryClient();
+ const navigate = useNavigate();
const episodeData = useEpisodeInfo({ id: episodeId });
const userTeamData = useUserTeam({ episodeId });
const scrimsData = useUserScrimmageList(
@@ -113,6 +115,9 @@ const ScrimHistoryTable: React.FC = ({
value: (match) => dateTime(match.created).localFullString,
},
]}
+ onRowClick={(match) => {
+ navigate(`/${episodeId}/match/${match.id.toString()}`);
+ }}
/>
);
diff --git a/frontend/src/views/MatchProfile.tsx b/frontend/src/views/MatchProfile.tsx
new file mode 100644
index 000000000..5e6a4f1b9
--- /dev/null
+++ b/frontend/src/views/MatchProfile.tsx
@@ -0,0 +1,83 @@
+import type React from "react";
+
+import { NavLink, useParams } from "react-router-dom";
+
+import { useMatchInfo, useSubmissionInfo } from "api/compete/useCompete";
+import { useEpisodeId } from "contexts/EpisodeContext";
+
+import { PageTitle } from "components/elements/BattlecodeStyle";
+import SectionCard from "components/SectionCard";
+
+import type { Submission } from "api/_autogen";
+import { useUserTeam } from "api/team/useTeam";
+import { isPresent } from "utils/utilTypes";
+import PageNotFound from "./PageNotFound";
+import type { UseQueryResult } from "@tanstack/react-query";
+import { dateTime } from "utils/dateTime";
+
+const MatchProfile: React.FC = () => {
+ const { episodeId } = useEpisodeId();
+ const { matchId } = useParams();
+ const teamData = useUserTeam({ episodeId });
+ const match = useMatchInfo({ episodeId, id: matchId ?? "" });
+
+ const getUserSubmission = (): UseQueryResult | undefined => {
+ if (!isPresent(match.data?.participants) || !isPresent(teamData.data))
+ return undefined;
+
+ const id = match.data?.participants
+ .find((participant) => participant.team === teamData.data?.id)
+ ?.submission.toString();
+
+ if (!isPresent(id)) return undefined;
+
+ return useSubmissionInfo({ episodeId, id });
+ };
+
+ const submission = getUserSubmission();
+
+ if (match.isError) {
+ return ;
+ }
+
+ return (
+
+
Match Profile
+
+
+ {isPresent(submission) && submission.isSuccess && (
+ <>
+
+ -
+ Submitted At:{" "}
+ {dateTime(submission.data.created).localFullString}
+
+ -
+ Description:{" "}
+ {
+ submission.data.description ??
+ "None provided" /* shouldn't happen */
+ }
+
+ - Package Name: {submission.data._package ?? "None"}
+ -
+ Submitter:{" "}
+ {
+
+ {submission.data.username}
+
+ }
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default MatchProfile;