From 4a29016daf687baacd68e48b3494a72f0b365df3 Mon Sep 17 00:00:00 2001 From: Lowell Torola <44183219+lowtorola@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:00:10 -0400 Subject: [PATCH] Implement Win Loss (#808) Co-authored-by: Serena Li :shipit: --- backend/siarnaq/api/compete/serializers.py | 7 ++ backend/siarnaq/api/compete/views.py | 93 ++++++++++++++++ frontend2/.env.development | 2 +- frontend2/schema.yml | 55 ++++++++++ frontend2/src/App.tsx | 12 +- .../src/api/_autogen/.openapi-generator/FILES | 1 + frontend2/src/api/_autogen/apis/CompeteApi.ts | 65 +++++++++++ .../api/_autogen/models/ScrimmageRecord.ts | 93 ++++++++++++++++ frontend2/src/api/_autogen/models/index.ts | 1 + frontend2/src/api/compete/competeApi.ts | 20 ++++ frontend2/src/api/compete/competeFactories.ts | 13 +++ frontend2/src/api/compete/competeKeys.ts | 17 +++ frontend2/src/api/compete/useCompete.ts | 29 +++++ frontend2/src/api/loaders/homeLoader.ts | 26 +++++ frontend2/src/api/loaders/myTeamLoader.ts | 30 +++++ .../src/api/loaders/scrimmagingLoader.ts | 49 +++++++++ frontend2/src/api/team/useTeam.ts | 21 +++- .../components/compete/ScrimmagingRecord.tsx | 103 ++++++++++++++++++ .../src/components/compete/WinLossTie.tsx | 84 ++++++++++++++ .../src/components/tables/RankingsTable.tsx | 2 +- frontend2/src/views/Home.tsx | 77 +++++++++---- frontend2/src/views/MyTeam.tsx | 30 +++-- frontend2/src/views/Scrimmaging.tsx | 25 +++++ 23 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 frontend2/src/api/_autogen/models/ScrimmageRecord.ts create mode 100644 frontend2/src/components/compete/ScrimmagingRecord.tsx create mode 100644 frontend2/src/components/compete/WinLossTie.tsx diff --git a/backend/siarnaq/api/compete/serializers.py b/backend/siarnaq/api/compete/serializers.py index 231ecc680..355b4339f 100644 --- a/backend/siarnaq/api/compete/serializers.py +++ b/backend/siarnaq/api/compete/serializers.py @@ -481,5 +481,12 @@ class HistoricalRatingSerializer(serializers.Serializer): team_rating = TeamRatingSerializer(default=None) +class ScrimmageRecordSerializer(serializers.Serializer): + team_id = serializers.IntegerField() + wins = serializers.IntegerField() + losses = serializers.IntegerField() + ties = serializers.IntegerField() + + class EmptySerializer(serializers.Serializer): pass diff --git a/backend/siarnaq/api/compete/views.py b/backend/siarnaq/api/compete/views.py index ab949de87..782d7a56b 100644 --- a/backend/siarnaq/api/compete/views.py +++ b/backend/siarnaq/api/compete/views.py @@ -1,3 +1,4 @@ +from functools import reduce from typing import Optional import google.cloud.storage as storage @@ -28,6 +29,7 @@ HistoricalRatingSerializer, MatchReportSerializer, MatchSerializer, + ScrimmageRecordSerializer, ScrimmageRequestSerializer, SubmissionDownloadSerializer, SubmissionReportSerializer, @@ -417,6 +419,12 @@ def historical_rating(self, request, pk=None, *, episode_id): team_ids = {parse_int(team_id) for team_id in team_ids} queryset = queryset.filter(participants__team__in=team_ids) elif request.user.pk is not None: + team_ids = { + team.id + for team in Team.objects.filter(members__pk=request.user.pk).filter( + episode_id=episode_id + ) + } queryset = queryset.filter(participants__team__members=request.user.pk) else: return Response([]) @@ -450,6 +458,91 @@ def historical_rating(self, request, pk=None, *, episode_id): results = HistoricalRatingSerializer(grouped.values(), many=True).data return Response(results, status=status.HTTP_200_OK) + @extend_schema( + parameters=[ + OpenApiParameter( + name="team_id", + type=int, + description="A team to filter for. Defaults to your own team.", + ), + OpenApiParameter( + name="scrimmage_type", + enum=["ranked", "unranked", "all"], + default="all", + description="Which type of scrimmages to filter for. Defaults to all.", + ), + ], + responses={ + status.HTTP_200_OK: ScrimmageRecordSerializer(), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="No team found with the given ID." + ), + }, + ) + @action( + detail=False, + methods=["get"], + permission_classes=(IsEpisodeMutable,), + ) + def scrimmaging_record(self, request, pk=None, *, episode_id): + """List the scrimmaging win-loss-tie record of a team.""" + queryset = self.get_queryset().filter(tournament_round__isnull=True) + + scrimmage_type = self.request.query_params.get("scrimmage_type") + if scrimmage_type is not None: + if scrimmage_type == "ranked": + queryset = queryset.filter(is_ranked=True) + elif scrimmage_type == "unranked": + queryset = queryset.filter(is_ranked=False) + + team_id = parse_int(self.request.query_params.get("team_id")) + if team_id is None and request.user.pk is not None: + team_id = ( + Team.objects.filter(members__pk=request.user.pk) + .filter(episode_id=episode_id) + .first() + .id + ) + if team_id is None: + return Response(status=status.HTTP_400_BAD_REQUEST) + if team_id is not None: + queryset = queryset.filter(participants__team=team_id) + else: + return Response(status=status.HTTP_400_BAD_REQUEST) + has_invisible = self.get_queryset().filter( + participants__team__status=TeamStatus.INVISIBLE + ) + queryset = queryset.exclude(pk__in=Subquery(has_invisible.values("pk"))) + + def match_handler(record, match): + """Mutate the win-loss-tie record based on the match outcome.""" + this_team = match.participants.filter(team=team_id).first() + other_team = match.participants.exclude(team=team_id).first() + if this_team is None or other_team is None: + return record + if this_team.score is None or other_team.score is None: + return record + if this_team.score > other_team.score: + record["wins"] += 1 + elif this_team.score < other_team.score: + record["losses"] += 1 + else: + record["ties"] += 1 + return record + + win_loss_tie = reduce( + match_handler, + queryset.all(), + { + "team_id": team_id, + "wins": 0, + "losses": 0, + "ties": 0, + }, + ) + results = ScrimmageRecordSerializer(win_loss_tie).data + return Response(results, status=status.HTTP_200_OK) + @extend_schema( responses={ status.HTTP_204_NO_CONTENT: OpenApiResponse( diff --git a/frontend2/.env.development b/frontend2/.env.development index 4bfb5fa98..d1d6ea9d8 100644 --- a/frontend2/.env.development +++ b/frontend2/.env.development @@ -1,3 +1,3 @@ REACT_APP_THIS_URL=http://localhost:3000 -REACT_APP_BACKEND_URL=http://api.staging.battlecode.org/ +REACT_APP_BACKEND_URL=http://localhost:8000 REACT_APP_REPLAY_URL=https://play.battlecode.org diff --git a/frontend2/schema.yml b/frontend2/schema.yml index 50120da02..bd4e3a6f6 100644 --- a/frontend2/schema.yml +++ b/frontend2/schema.yml @@ -207,6 +207,45 @@ paths: schema: $ref: '#/components/schemas/PaginatedMatchList' description: '' + /api/compete/{episode_id}/match/scrimmaging_record/: + get: + operationId: compete_match_scrimmaging_record_retrieve + description: List the scrimmaging win-loss-tie record of a team. + parameters: + - in: path + name: episode_id + schema: + type: string + pattern: ^[^\/.]+$ + required: true + - in: query + name: scrimmage_type + schema: + type: string + enum: + - all + - ranked + - unranked + default: all + description: Which type of scrimmages to filter for. Defaults to all. + - in: query + name: team_id + schema: + type: integer + description: A team to filter for. Defaults to your own team. + tags: + - compete + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScrimmageRecord' + description: '' + '400': + description: No team found with the given ID. /api/compete/{episode_id}/match/tournament/: get: operationId: compete_match_tournament_list @@ -2560,6 +2599,22 @@ components: type: boolean required: - status + ScrimmageRecord: + type: object + properties: + team_id: + type: integer + wins: + type: integer + losses: + type: integer + ties: + type: integer + required: + - losses + - team_id + - ties + - wins ScrimmageRequest: type: object properties: diff --git a/frontend2/src/App.tsx b/frontend2/src/App.tsx index 403c43cd8..82be46d91 100644 --- a/frontend2/src/App.tsx +++ b/frontend2/src/App.tsx @@ -48,7 +48,7 @@ import { tournamentsLoader } from "./api/loaders/tournamentsLoader"; import { tournamentLoader } from "./api/loaders/tournamentLoader"; import { homeLoader } from "./api/loaders/homeLoader"; import ErrorBoundary from "./views/ErrorBoundary"; -import { searchTeamsFactory } from "api/team/teamFactories"; +import { myTeamFactory, searchTeamsFactory } from "api/team/teamFactories"; import PageNotFound from "views/PageNotFound"; import TeamProfile from "views/TeamProfile"; import { passwordForgotLoader } from "api/loaders/passwordForgotLoader"; @@ -73,7 +73,7 @@ queryClient.setQueryDefaults(["team"], { retry: false }); queryClient.setQueryDefaults(["user"], { retry: false }); // Run a check to see if the user has an invalid token -await loginCheck(queryClient); +const loggedIn = await loginCheck(queryClient); const App: React.FC = () => { return ( @@ -120,6 +120,14 @@ const episodeLoader: LoaderFunction = async ({ params }) => { ), }); + // Prefetch the user's team. + if (loggedIn) { + void queryClient.ensureQueryData({ + queryKey: buildKey(myTeamFactory.queryKey, { episodeId: id }), + queryFn: async () => await myTeamFactory.queryFn({ episodeId: id }), + }); + } + return episodeInfo; }; diff --git a/frontend2/src/api/_autogen/.openapi-generator/FILES b/frontend2/src/api/_autogen/.openapi-generator/FILES index 49e39f640..6d90c6e7f 100644 --- a/frontend2/src/api/_autogen/.openapi-generator/FILES +++ b/frontend2/src/api/_autogen/.openapi-generator/FILES @@ -39,6 +39,7 @@ models/ReleaseStatusEnum.ts models/ResetToken.ts models/ResetTokenRequest.ts models/SaturnInvocationRequest.ts +models/ScrimmageRecord.ts models/ScrimmageRequest.ts models/ScrimmageRequestRequest.ts models/ScrimmageStatusEnum.ts diff --git a/frontend2/src/api/_autogen/apis/CompeteApi.ts b/frontend2/src/api/_autogen/apis/CompeteApi.ts index 99febc04d..802dc0c50 100644 --- a/frontend2/src/api/_autogen/apis/CompeteApi.ts +++ b/frontend2/src/api/_autogen/apis/CompeteApi.ts @@ -21,6 +21,7 @@ import type { PaginatedMatchList, PaginatedScrimmageRequestList, PaginatedSubmissionList, + ScrimmageRecord, ScrimmageRequest, ScrimmageRequestRequest, Submission, @@ -41,6 +42,8 @@ import { PaginatedScrimmageRequestListToJSON, PaginatedSubmissionListFromJSON, PaginatedSubmissionListToJSON, + ScrimmageRecordFromJSON, + ScrimmageRecordToJSON, ScrimmageRequestFromJSON, ScrimmageRequestToJSON, ScrimmageRequestRequestFromJSON, @@ -92,6 +95,12 @@ export interface CompeteMatchScrimmageListRequest { teamId?: number; } +export interface CompeteMatchScrimmagingRecordRetrieveRequest { + episodeId: string; + scrimmageType?: CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum; + teamId?: number; +} + export interface CompeteMatchTournamentListRequest { episodeId: string; externalIdPrivate?: string; @@ -470,6 +479,52 @@ export class CompeteApi extends runtime.BaseAPI { return await response.value(); } + /** + * List the scrimmaging win-loss-tie record of a team. + */ + async competeMatchScrimmagingRecordRetrieveRaw(requestParameters: CompeteMatchScrimmagingRecordRetrieveRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters.episodeId === null || requestParameters.episodeId === undefined) { + throw new runtime.RequiredError('episodeId','Required parameter requestParameters.episodeId was null or undefined when calling competeMatchScrimmagingRecordRetrieve.'); + } + + const queryParameters: any = {}; + + if (requestParameters.scrimmageType !== undefined) { + queryParameters['scrimmage_type'] = requestParameters.scrimmageType; + } + + if (requestParameters.teamId !== undefined) { + queryParameters['team_id'] = requestParameters.teamId; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("jwtAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + const response = await this.request({ + path: `/api/compete/{episode_id}/match/scrimmaging_record/`.replace(`{${"episode_id"}}`, encodeURIComponent(String(requestParameters.episodeId))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ScrimmageRecordFromJSON(jsonValue)); + } + + /** + * List the scrimmaging win-loss-tie record of a team. + */ + async competeMatchScrimmagingRecordRetrieve(requestParameters: CompeteMatchScrimmagingRecordRetrieveRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.competeMatchScrimmagingRecordRetrieveRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * List matches played in a tournament, or in all tournaments if not specified. Passing the external_id_private of a tournament allows match lookup for the tournament, even if it\'s private. Client uses the external_id_private parameter */ @@ -1060,3 +1115,13 @@ export class CompeteApi extends runtime.BaseAPI { } } + +/** + * @export + * @enum {string} + */ +export enum CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum { + All = 'all', + Ranked = 'ranked', + Unranked = 'unranked' +} diff --git a/frontend2/src/api/_autogen/models/ScrimmageRecord.ts b/frontend2/src/api/_autogen/models/ScrimmageRecord.ts new file mode 100644 index 000000000..4f14a9526 --- /dev/null +++ b/frontend2/src/api/_autogen/models/ScrimmageRecord.ts @@ -0,0 +1,93 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 0.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface ScrimmageRecord + */ +export interface ScrimmageRecord { + /** + * + * @type {number} + * @memberof ScrimmageRecord + */ + team_id: number; + /** + * + * @type {number} + * @memberof ScrimmageRecord + */ + wins: number; + /** + * + * @type {number} + * @memberof ScrimmageRecord + */ + losses: number; + /** + * + * @type {number} + * @memberof ScrimmageRecord + */ + ties: number; +} + +/** + * Check if a given object implements the ScrimmageRecord interface. + */ +export function instanceOfScrimmageRecord(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "team_id" in value; + isInstance = isInstance && "wins" in value; + isInstance = isInstance && "losses" in value; + isInstance = isInstance && "ties" in value; + + return isInstance; +} + +export function ScrimmageRecordFromJSON(json: any): ScrimmageRecord { + return ScrimmageRecordFromJSONTyped(json, false); +} + +export function ScrimmageRecordFromJSONTyped(json: any, ignoreDiscriminator: boolean): ScrimmageRecord { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'team_id': json['team_id'], + 'wins': json['wins'], + 'losses': json['losses'], + 'ties': json['ties'], + }; +} + +export function ScrimmageRecordToJSON(value?: ScrimmageRecord | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'team_id': value.team_id, + 'wins': value.wins, + 'losses': value.losses, + 'ties': value.ties, + }; +} + diff --git a/frontend2/src/api/_autogen/models/index.ts b/frontend2/src/api/_autogen/models/index.ts index 7d5ab4f73..804ff76c7 100644 --- a/frontend2/src/api/_autogen/models/index.ts +++ b/frontend2/src/api/_autogen/models/index.ts @@ -32,6 +32,7 @@ export * from './ReleaseStatusEnum'; export * from './ResetToken'; export * from './ResetTokenRequest'; export * from './SaturnInvocationRequest'; +export * from './ScrimmageRecord'; export * from './ScrimmageRequest'; export * from './ScrimmageRequestRequest'; export * from './ScrimmageStatusEnum'; diff --git a/frontend2/src/api/compete/competeApi.ts b/frontend2/src/api/compete/competeApi.ts index 83016171a..69ab4fe69 100644 --- a/frontend2/src/api/compete/competeApi.ts +++ b/frontend2/src/api/compete/competeApi.ts @@ -21,6 +21,8 @@ import { type CompeteRequestDestroyRequest, type CompeteMatchHistoricalRatingListRequest, type HistoricalRating, + type CompeteMatchScrimmagingRecordRetrieveRequest, + type ScrimmageRecord, } from "../_autogen"; import { DEFAULT_API_CONFIGURATION, downloadFile } from "../helpers"; @@ -224,3 +226,21 @@ export const getRatingList = async ({ teamIds, }: CompeteMatchHistoricalRatingListRequest): Promise => await API.competeMatchHistoricalRatingList({ episodeId, teamIds }); + +/** + * Get a team's win-loss-tie record in scrimmages in a given episode. Defaults to the logged in user's team. + * Defaults to all scrimmage types. + * @param episodeId The episode ID to retrieve record data for. + * @param teamId The team ID to retrieve record data for. + * @param scrimmageType The type of scrimmage to retrieve record data for. + */ +export const getScrimmagingRecord = async ({ + episodeId, + teamId, + scrimmageType, +}: CompeteMatchScrimmagingRecordRetrieveRequest): Promise => + await API.competeMatchScrimmagingRecordRetrieve({ + episodeId, + teamId, + scrimmageType, + }); diff --git a/frontend2/src/api/compete/competeFactories.ts b/frontend2/src/api/compete/competeFactories.ts index 67c75deaf..c57a8df6f 100644 --- a/frontend2/src/api/compete/competeFactories.ts +++ b/frontend2/src/api/compete/competeFactories.ts @@ -2,6 +2,7 @@ import type { CompeteMatchHistoricalRatingListRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, + CompeteMatchScrimmagingRecordRetrieveRequest, CompeteMatchTournamentListRequest, CompeteRequestInboxListRequest, CompeteRequestOutboxListRequest, @@ -12,6 +13,7 @@ import type { PaginatedScrimmageRequestList, PaginatedSubmissionList, TournamentSubmission, + ScrimmageRecord, } from "../_autogen"; import type { PaginatedQueryFactory, QueryFactory } from "../apiTypes"; import { competeQueryKeys } from "./competeKeys"; @@ -20,6 +22,7 @@ import { getMatchesList, getRatingList, getScrimmagesListByTeam, + getScrimmagingRecord, getSubmissionsList, getTournamentMatchesList, getUserScrimmagesInboxList, @@ -208,3 +211,13 @@ export const ratingHistoryMeFactory: QueryFactory< queryFn: async ({ episodeId }) => await getRatingList({ episodeId, teamIds: undefined }), } as const; + +export const scrimmagingRecordFactory: QueryFactory< + CompeteMatchScrimmagingRecordRetrieveRequest, + ScrimmageRecord +> = { + queryKey: competeQueryKeys.scrimmagingRecord, + queryFn: async ({ episodeId, teamId, scrimmageType }) => { + return await getScrimmagingRecord({ episodeId, teamId, scrimmageType }); + }, +} as const; diff --git a/frontend2/src/api/compete/competeKeys.ts b/frontend2/src/api/compete/competeKeys.ts index 30e4f7557..b1ccd99d5 100644 --- a/frontend2/src/api/compete/competeKeys.ts +++ b/frontend2/src/api/compete/competeKeys.ts @@ -2,6 +2,7 @@ import type { CompeteMatchHistoricalRatingListRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, + CompeteMatchScrimmagingRecordRetrieveRequest, CompeteMatchTournamentListRequest, CompeteRequestInboxListRequest, CompeteRequestOutboxListRequest, @@ -29,6 +30,7 @@ interface CompeteKeys { ratingHistoryBase: QueryKeyBuilder<{ episodeId: string }>; ratingHistoryTopList: QueryKeyBuilder; ratingHistoryMeList: QueryKeyBuilder; + scrimmagingRecord: QueryKeyBuilder; } // ---------- KEY RECORDS ---------- // @@ -140,6 +142,21 @@ export const competeQueryKeys: CompeteKeys = { key: ({ episodeId }: CompeteMatchHistoricalRatingListRequest) => [...competeQueryKeys.ratingHistoryBase.key({ episodeId }), "me"] as const, }, + + scrimmagingRecord: { + key: ({ + episodeId, + teamId, + scrimmageType, + }: CompeteMatchScrimmagingRecordRetrieveRequest) => + [ + ...competeQueryKeys.scrimBase.key({ episodeId }), + "record", + teamId ?? "me", + "scrimmageType", + scrimmageType, + ] as const, + }, }; export const competeMutationKeys = { diff --git a/frontend2/src/api/compete/useCompete.ts b/frontend2/src/api/compete/useCompete.ts index 91edd78dd..525ee38f8 100644 --- a/frontend2/src/api/compete/useCompete.ts +++ b/frontend2/src/api/compete/useCompete.ts @@ -12,6 +12,7 @@ import type { CompeteMatchHistoricalRatingListRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, + CompeteMatchScrimmagingRecordRetrieveRequest, CompeteMatchTournamentListRequest, CompeteRequestAcceptCreateRequest, CompeteRequestCreateRequest, @@ -29,6 +30,7 @@ import type { PaginatedSubmissionList, PaginatedTeamPublicList, ResponseError, + ScrimmageRecord, ScrimmageRequest, Submission, TournamentSubmission, @@ -49,6 +51,7 @@ import { ratingHistoryTopFactory, scrimmageInboxListFactory, scrimmageOutboxListFactory, + scrimmagingRecordFactory, subsListFactory, teamScrimmageListFactory, tournamentMatchListFactory, @@ -253,6 +256,32 @@ export const useUserRatingHistoryList = ({ staleTime: 1000 * 60 * 5, // 5 minutes }); +/** + * For retrieving the given team's scrimmaging record in a given episode. Defaults to the currently logged in user's team. + */ +export const useScrimmagingRecord = ({ + episodeId, + teamId, + scrimmageType, +}: CompeteMatchScrimmagingRecordRetrieveRequest): UseQueryResult< + ScrimmageRecord, + Error +> => + useQuery({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + teamId, + scrimmageType, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + teamId, + scrimmageType, + }), + staleTime: 1000 * 30, // 30 seconds + }); + // ---------- MUTATION HOOKS ---------- // /** * For uploading a new submission. diff --git a/frontend2/src/api/loaders/homeLoader.ts b/frontend2/src/api/loaders/homeLoader.ts index b8355b246..7ffd87ec6 100644 --- a/frontend2/src/api/loaders/homeLoader.ts +++ b/frontend2/src/api/loaders/homeLoader.ts @@ -5,6 +5,11 @@ import { episodeInfoFactory, nextTournamentFactory, } from "../episode/episodeFactories"; +import { + ratingHistoryMeFactory, + scrimmagingRecordFactory, +} from "api/compete/competeFactories"; +import { CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum } from "api/_autogen"; export const homeLoader = (queryClient: QueryClient): LoaderFunction => @@ -24,5 +29,26 @@ export const homeLoader = queryFn: async () => await nextTournamentFactory.queryFn({ episodeId }), }); + // User Team Rating History + void queryClient.ensureQueryData({ + queryKey: buildKey(ratingHistoryMeFactory.queryKey, { episodeId }), + queryFn: async () => await ratingHistoryMeFactory.queryFn({ episodeId }), + }); + + // User Team Scrimmage Record + void queryClient.ensureQueryData({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All, + }), + }); + return null; }; diff --git a/frontend2/src/api/loaders/myTeamLoader.ts b/frontend2/src/api/loaders/myTeamLoader.ts index aefd6aa44..23ce57202 100644 --- a/frontend2/src/api/loaders/myTeamLoader.ts +++ b/frontend2/src/api/loaders/myTeamLoader.ts @@ -2,6 +2,8 @@ import type { QueryClient } from "@tanstack/react-query"; import type { LoaderFunction } from "react-router-dom"; import { myTeamFactory } from "../team/teamFactories"; import { buildKey } from "../helpers"; +import { scrimmagingRecordFactory } from "api/compete/competeFactories"; +import { CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum } from "api/_autogen"; export const myTeamLoader = (queryClient: QueryClient): LoaderFunction => @@ -16,5 +18,33 @@ export const myTeamLoader = queryFn: async () => await myTeamFactory.queryFn({ episodeId }), }); + // Ranked and Unranked Scrimmage Record + void queryClient.ensureQueryData({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Ranked, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Ranked, + }), + }); + void queryClient.ensureQueryData({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Unranked, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Unranked, + }), + }); + return null; }; diff --git a/frontend2/src/api/loaders/scrimmagingLoader.ts b/frontend2/src/api/loaders/scrimmagingLoader.ts index 9166ddbbd..bd6df7d45 100644 --- a/frontend2/src/api/loaders/scrimmagingLoader.ts +++ b/frontend2/src/api/loaders/scrimmagingLoader.ts @@ -3,11 +3,13 @@ import type { LoaderFunction } from "react-router-dom"; import { scrimmageInboxListFactory, scrimmageOutboxListFactory, + scrimmagingRecordFactory, tournamentMatchListFactory, userScrimmageListFactory, } from "../compete/competeFactories"; import { buildKey } from "../helpers"; import { myTeamFactory, searchTeamsFactory } from "../team/teamFactories"; +import { CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum } from "api/_autogen"; export const scrimmagingLoader = (queryClient: QueryClient): LoaderFunction => @@ -62,6 +64,53 @@ export const scrimmagingLoader = queryFn: async () => await myTeamFactory.queryFn({ episodeId }), }); + // Scrimmaging record (all types) + void queryClient.ensureQueryData({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + teamId: myTeamInfo.id, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + teamId: myTeamInfo.id, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All, + }), + }); + void queryClient.ensureQueryData({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + teamId: myTeamInfo.id, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Ranked, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + teamId: myTeamInfo.id, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Ranked, + }), + }); + void queryClient.ensureQueryData({ + queryKey: buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + teamId: myTeamInfo.id, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Unranked, + }), + queryFn: async () => + await scrimmagingRecordFactory.queryFn({ + episodeId, + teamId: myTeamInfo.id, + scrimmageType: + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Unranked, + }), + }); + // Tournament match list void queryClient.ensureQueryData({ queryKey: buildKey(tournamentMatchListFactory.queryKey, { diff --git a/frontend2/src/api/team/useTeam.ts b/frontend2/src/api/team/useTeam.ts index af62da899..2135ee012 100644 --- a/frontend2/src/api/team/useTeam.ts +++ b/frontend2/src/api/team/useTeam.ts @@ -33,6 +33,8 @@ import { searchTeamsFactory, } from "./teamFactories"; import { buildKey } from "../helpers"; +import { ratingHistoryMeFactory } from "api/compete/competeFactories"; +import { competeQueryKeys } from "api/compete/competeKeys"; // ---------- QUERY HOOKS ---------- // /** @@ -128,9 +130,18 @@ export const useJoinTeam = ( }); }, onSuccess: async () => { + // Refetch the user's team await queryClient.refetchQueries({ queryKey: buildKey(myTeamFactory.queryKey, { episodeId }), }); + // Refetch the user's rating history + await queryClient.refetchQueries({ + queryKey: buildKey(ratingHistoryMeFactory.queryKey, { episodeId }), + }); + // Refetch all scrimmage-related data + await queryClient.refetchQueries({ + queryKey: competeQueryKeys.scrimBase.key({ episodeId }), + }); }, }); @@ -152,10 +163,18 @@ export const useLeaveTeam = ( }); }, onSuccess: async () => { - onSuccess?.(); + // Invalidate the user's team await queryClient.invalidateQueries({ queryKey: buildKey(myTeamFactory.queryKey, { episodeId }), }); + // Invalidate the user's rating history + await queryClient.invalidateQueries({ + queryKey: buildKey(ratingHistoryMeFactory.queryKey, { episodeId }), + }); + // Invalidate all scrimmage-related data + await queryClient.invalidateQueries({ + queryKey: competeQueryKeys.scrimBase.key({ episodeId }), + }); }, }); diff --git a/frontend2/src/components/compete/ScrimmagingRecord.tsx b/frontend2/src/components/compete/ScrimmagingRecord.tsx new file mode 100644 index 000000000..48a101508 --- /dev/null +++ b/frontend2/src/components/compete/ScrimmagingRecord.tsx @@ -0,0 +1,103 @@ +import React, { useMemo } from "react"; +import { + CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum, + type ScrimmageRecord, + type TeamPublic, +} from "api/_autogen"; +import WinLossTie from "./WinLossTie"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { useQueryClient } from "@tanstack/react-query"; +import { scrimmagingRecordFactory } from "api/compete/competeFactories"; +import { buildKey } from "api/helpers"; +import { isNil } from "lodash"; + +interface ScrimmagingRecordProps { + team: Pick; + hideTeamName?: boolean; + hideAllScrimmages?: boolean; + hideUnranked?: boolean; + hideRanked?: boolean; + className?: string; +} + +const ScrimmagingRecord: React.FC = ({ + team, + hideTeamName = false, + hideAllScrimmages = false, + hideUnranked = false, + hideRanked = false, + className = "", +}) => { + const { episodeId } = useEpisodeId(); + const queryClient = useQueryClient(); + + const scrimTypeToCheck = useMemo(() => { + if (!isNil(hideAllScrimmages) && !hideAllScrimmages) { + return CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All; + } else if (!isNil(hideRanked) && !hideRanked) { + return CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Ranked; + } else if (!isNil(hideUnranked) && !hideUnranked) { + return CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Unranked; + } else { + return CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All; + } + }, [hideAllScrimmages, hideRanked, hideUnranked]); + + const recordWatcher = queryClient.getQueryState( + buildKey(scrimmagingRecordFactory.queryKey, { + episodeId, + teamId: team.id, + scrimmageType: scrimTypeToCheck, + }), + ); + + return ( +
+ {!hideTeamName && ( +
+ +
{team.name}
+
+ )} + {!isNil(recordWatcher) && !isNil(recordWatcher.error) ? ( +
+ Error fetching scrimmaging record. +
+ ) : ( +
+ {!hideAllScrimmages && ( + + )} + {!hideUnranked && ( + + )} + {!hideRanked && ( + + )} +
+ )} +
+ ); +}; + +export default ScrimmagingRecord; diff --git a/frontend2/src/components/compete/WinLossTie.tsx b/frontend2/src/components/compete/WinLossTie.tsx new file mode 100644 index 000000000..824ce8d2a --- /dev/null +++ b/frontend2/src/components/compete/WinLossTie.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from "react"; +import { CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum } from "api/_autogen"; +import Spinner from "components/Spinner"; +import { isNil } from "lodash"; +import { useScrimmagingRecord } from "api/compete/useCompete"; +import { useEpisodeId } from "contexts/EpisodeContext"; + +interface WinLossTieProps { + scrimmageType: CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum; + teamId: number; + className?: string; +} + +const scrimmageTypeToName = { + [CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.All]: + "All Scrimmages", + [CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Unranked]: "Unranked", + [CompeteMatchScrimmagingRecordRetrieveScrimmageTypeEnum.Ranked]: "Ranked", +} as const; + +const WinLossTie: React.FC = ({ + scrimmageType, + teamId, + className = "", +}) => { + const { episodeId } = useEpisodeId(); + const scrimRecord = useScrimmagingRecord({ + episodeId, + teamId, + scrimmageType, + }); + + const HEADER = + "w-full border-t-2 border-b-2 border-solid border-cyan-600 text-center"; + + const dataClassName = useMemo(() => { + const baseClassName = "w-full p-1"; + if (scrimRecord.isLoading) { + return `${baseClassName} flex flex-row items-center justify-center`; + } else { + return `${baseClassName} text-center font-semibold`; + } + }, [scrimRecord.isLoading]); + + const dataOrLoading = (count?: number): JSX.Element => { + if (scrimRecord.isLoading) { + return ; + } else if (isNil(count)) { + return <>—; + } else { + return <>{count}; + } + }; + + return ( +
+
+ {scrimmageTypeToName[scrimmageType]} +
+ + Wins + + Losses + + Ties + +
+ {dataOrLoading(scrimRecord.data?.wins)} +
+
+ {dataOrLoading(scrimRecord.data?.losses)} +
+
+ {dataOrLoading(scrimRecord.data?.ties)} +
+
+ ); +}; + +export default WinLossTie; diff --git a/frontend2/src/components/tables/RankingsTable.tsx b/frontend2/src/components/tables/RankingsTable.tsx index b9776fa0e..960d2455b 100644 --- a/frontend2/src/components/tables/RankingsTable.tsx +++ b/frontend2/src/components/tables/RankingsTable.tsx @@ -76,7 +76,7 @@ const RankingsTable: React.FC = ({ <> {trimString(member.username, 13)} diff --git a/frontend2/src/views/Home.tsx b/frontend2/src/views/Home.tsx index 5463bb42a..248a124b6 100644 --- a/frontend2/src/views/Home.tsx +++ b/frontend2/src/views/Home.tsx @@ -9,7 +9,12 @@ import TeamChart, { type ChartData, } from "../components/tables/chart/TeamChart"; import { useQueryClient } from "@tanstack/react-query"; -import { useTopRatingHistoryList } from "api/compete/useCompete"; +import { + useTopRatingHistoryList, + useUserRatingHistoryList, +} from "api/compete/useCompete"; +import ScrimmagingRecord from "components/compete/ScrimmagingRecord"; +import { useUserTeam } from "api/team/useTeam"; const Home: React.FC = () => { const { episodeId } = useEpisodeId(); @@ -17,24 +22,43 @@ const Home: React.FC = () => { const episode = useEpisodeInfo({ id: episodeId }); const nextTournament = useNextTournament({ episodeId }); const topRatingHistory = useTopRatingHistoryList({ episodeId }, queryClient); + const userTeam = useUserTeam({ episodeId }); + const userTeamRatingHistory = useUserRatingHistoryList({ episodeId }); const SOCIAL = "hover:drop-shadow-lg hover:opacity-80 transition-opacity duration-300 ease-in-out"; - const ratingData: Record | undefined = useMemo(() => { - if (!topRatingHistory.isSuccess) return undefined; - const ratingRecord: Record = {}; - return topRatingHistory.data.reduce((record, teamData) => { - if (teamData.team_rating !== undefined) { - record[teamData.team_rating.team.name] = - teamData.team_rating.rating_history.map((match) => [ - match.timestamp.getTime(), - match.rating, - ]); - } - return record; - }, ratingRecord); - }, [topRatingHistory]); + const userTeamRatingData: Record | undefined = + useMemo(() => { + if (!userTeamRatingHistory.isSuccess) return undefined; + const ratingRecord: Record = {}; + return userTeamRatingHistory.data.reduce((record, teamData) => { + if (teamData.team_rating !== undefined) { + record[teamData.team_rating.team.name] = + teamData.team_rating.rating_history.map((match) => [ + match.timestamp.getTime(), + match.rating, + ]); + } + return record; + }, ratingRecord); + }, [userTeamRatingHistory]); + + const topTeamRatingData: Record | undefined = + useMemo(() => { + if (!topRatingHistory.isSuccess) return undefined; + const ratingRecord: Record = {}; + return topRatingHistory.data.reduce((record, teamData) => { + if (teamData.team_rating !== undefined) { + record[teamData.team_rating.team.name] = + teamData.team_rating.rating_history.map((match) => [ + match.timestamp.getTime(), + match.rating, + ]); + } + return record; + }, ratingRecord); + }, [topRatingHistory]); return (
@@ -49,10 +73,23 @@ const Home: React.FC = () => { "No upcoming submission deadlines." )} - {/* MATCH STATISTICS (TODO) */} - {/* - RANKINGS GRAPH (TODO) - */} + {userTeam.isSuccess && ( + + + + )} + + +
@@ -79,7 +116,7 @@ const Home: React.FC = () => { diff --git a/frontend2/src/views/MyTeam.tsx b/frontend2/src/views/MyTeam.tsx index 208d45b48..afe55966f 100644 --- a/frontend2/src/views/MyTeam.tsx +++ b/frontend2/src/views/MyTeam.tsx @@ -21,6 +21,7 @@ import Loading from "components/Loading"; import { type SubmitHandler, useForm } from "react-hook-form"; import { FIELD_REQUIRED_ERROR_MSG } from "utils/constants"; import FormLabel from "components/elements/FormLabel"; +import ScrimmagingRecord from "components/compete/ScrimmagingRecord"; interface InfoFormInput { quote: string; @@ -61,9 +62,9 @@ const MyTeam: React.FC = () => { }, ); - const onInfoSubmit: SubmitHandler = async (data) => { + const onInfoSubmit: SubmitHandler = (data) => { if (updateTeam.isPending) return; - await updateTeam.mutateAsync({ + updateTeam.mutate({ profile: { quote: data.quote, biography: data.biography, @@ -72,13 +73,13 @@ const MyTeam: React.FC = () => { resetInfo(); }; - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const onLeaveTeam: EventHandler> = async ( + const onLeaveTeam: EventHandler> = ( event, ) => { if (leaveTeam.isPending) return; event.preventDefault(); - await leaveTeam.mutateAsync(); + event.stopPropagation(); + leaveTeam.mutate(); setIsLeaveModalOpen(false); }; @@ -154,19 +155,33 @@ const MyTeam: React.FC = () => {
- {/* The members list and file upload that display when on a smaller screen */} + {/* The members list, file upload, and win/loss that display when on a smaller screen */} {membersList} + + + - {/* Display the members list and file upload to the right when on a big screen. */} + {/* Display the members list, file upload, and win/loss to the right when on a big screen. */}
{membersList} + + + @@ -188,7 +203,6 @@ const MyTeam: React.FC = () => {