From c097bf60e5220d8b20b72559be925b97e8dbf0ea Mon Sep 17 00:00:00 2001 From: Vincent Zheng Date: Tue, 30 Dec 2025 00:08:15 -0500 Subject: [PATCH] add useMatchInfo, useSubmissionInfo hooks, add skeleton match details page MatchProfile (loader is broken) --- frontend/src/App.tsx | 7 ++ frontend/src/api/compete/competeApi.ts | 68 +++++++++------ frontend/src/api/compete/competeFactories.ts | 23 ++++- frontend/src/api/compete/competeKeys.ts | 15 +++- frontend/src/api/compete/useCompete.ts | 24 +++++- .../src/api/loaders/matchProfileLoader.ts | 25 ++++++ .../tables/scrimmaging/ScrimHistoryTable.tsx | 5 ++ frontend/src/views/MatchProfile.tsx | 83 +++++++++++++++++++ 8 files changed, 221 insertions(+), 29 deletions(-) create mode 100644 frontend/src/api/loaders/matchProfileLoader.ts create mode 100644 frontend/src/views/MatchProfile.tsx 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;