diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index d622f61ad..000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/backend/siarnaq/api/compete/serializers.py b/backend/siarnaq/api/compete/serializers.py index b94dca300..231ecc680 100644 --- a/backend/siarnaq/api/compete/serializers.py +++ b/backend/siarnaq/api/compete/serializers.py @@ -21,7 +21,7 @@ from siarnaq.api.episodes.models import Map, ReleaseStatus from siarnaq.api.episodes.serializers import TournamentRoundSerializer from siarnaq.api.teams.models import Team, TeamStatus -from siarnaq.api.teams.serializers import RatingField +from siarnaq.api.teams.serializers import RatingField, TeamPublicSerializer logger = structlog.get_logger(__name__) @@ -466,10 +466,20 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class HistoricalRatingSerializer(serializers.Serializer): +class MatchRatingSerializer(serializers.Serializer): rating = RatingField() timestamp = serializers.DateTimeField() +class TeamRatingSerializer(serializers.Serializer): + team = TeamPublicSerializer() + rating_history = MatchRatingSerializer(many=True) + + +class HistoricalRatingSerializer(serializers.Serializer): + team_id = serializers.IntegerField() + team_rating = TeamRatingSerializer(default=None) + + class EmptySerializer(serializers.Serializer): pass diff --git a/backend/siarnaq/api/compete/views.py b/backend/siarnaq/api/compete/views.py index 8cb119613..ab949de87 100644 --- a/backend/siarnaq/api/compete/views.py +++ b/backend/siarnaq/api/compete/views.py @@ -388,30 +388,34 @@ def scrimmage(self, request, pk=None, *, episode_id): @extend_schema( parameters=[ OpenApiParameter( - name="team_id", + name="team_ids", type=int, - description="A team to filter for. Defaults to your own team.", + description="A list of teams to filter for. Defaults to your own team.", + many=True, ), ], responses={ status.HTTP_204_NO_CONTENT: OpenApiResponse( description="No ranked matches found." ), - status.HTTP_200_OK: HistoricalRatingSerializer(), + status.HTTP_200_OK: HistoricalRatingSerializer(many=True), }, ) @action( detail=False, methods=["get"], permission_classes=(IsEpisodeMutable,), + # needed so that the generated schema is not paginated + pagination_class=None, ) def historical_rating(self, request, pk=None, *, episode_id): - """List the historical rating of a team.""" + """List the historical ratings of a list of teams.""" queryset = Match.objects.all().filter(tournament_round__isnull=True) - team_id = parse_int(self.request.query_params.get("team_id")) - if team_id is not None: - queryset = queryset.filter(participants__team=team_id) + team_ids = self.request.query_params.getlist("team_ids") + if team_ids is not None and len(team_ids) > 0: + 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: queryset = queryset.filter(participants__team__members=request.user.pk) else: @@ -420,21 +424,30 @@ def historical_rating(self, request, pk=None, *, episode_id): participants__team__status=TeamStatus.INVISIBLE ) queryset = queryset.exclude(pk__in=Subquery(has_invisible.values("pk"))) - queryset = queryset.filter(is_ranked=True) - - matches = queryset.all().order_by("created") + queryset = queryset.filter(is_ranked=True).filter( + participants__rating__isnull=False + ) - ordered = [ - { - "timestamp": match.created, - "rating": match.participants.get(team=team_id).rating - if team_id is not None - else match.participants.get(team__members__pk=request.user.pk).rating, - } - for match in matches - ] + matches = queryset.all().order_by("-created") + grouped = { + team_id: {"team_id": team_id, "team_rating": None} for team_id in team_ids + } - results = HistoricalRatingSerializer(ordered, many=True).data + for match in matches: + matching_participants = match.participants.filter(team__in=team_ids).all() + for participant in matching_participants: + match_info = {"timestamp": match.created, "rating": participant.rating} + if grouped[participant.team.id]["team_rating"] is None: + grouped[participant.team.id]["team_rating"] = { + "team": participant.team, + "rating_history": [match_info], + } + else: + grouped[participant.team.id]["team_rating"][ + "rating_history" + ].append(match_info) + + results = HistoricalRatingSerializer(grouped.values(), many=True).data return Response(results, status=status.HTTP_200_OK) @extend_schema( diff --git a/frontend2/schema.yml b/frontend2/schema.yml index a3dbb37fc..5f96700f9 100644 --- a/frontend2/schema.yml +++ b/frontend2/schema.yml @@ -143,8 +143,8 @@ paths: description: This match was already finalized /api/compete/{episode_id}/match/historical_rating/: get: - operationId: compete_match_historical_rating_retrieve - description: List the historical rating of a team. + operationId: compete_match_historical_rating_list + description: List the historical ratings of a list of teams. parameters: - in: path name: episode_id @@ -153,10 +153,12 @@ paths: pattern: ^[^\/.]+$ required: true - in: query - name: team_id + name: team_ids schema: - type: integer - description: A team to filter for. Defaults to your own team. + type: array + items: + type: integer + description: A list of teams to filter for. Defaults to just your own team. tags: - compete security: @@ -168,7 +170,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HistoricalRating' + type: array + items: + $ref: '#/components/schemas/HistoricalRating' description: '' /api/compete/{episode_id}/match/scrimmage/: get: @@ -1817,6 +1821,8 @@ components: type: integer min_score: type: integer + maximum: 32767 + minimum: 0 required: - episode - maps @@ -2187,15 +2193,12 @@ components: HistoricalRating: type: object properties: - rating: - type: number - format: double - timestamp: - type: string - format: date-time + team_id: + type: integer + team_rating: + $ref: '#/components/schemas/TeamRating' required: - - rating - - timestamp + - team_id LanguageEnum: enum: - java8 @@ -2292,6 +2295,18 @@ components: - submission - team - teamname + MatchRating: + type: object + properties: + rating: + type: number + format: double + timestamp: + type: string + format: date-time + required: + - rating + - timestamp MatchReportRequest: type: object properties: @@ -2962,6 +2977,18 @@ components: - members - name - status + TeamRating: + type: object + properties: + team: + $ref: '#/components/schemas/TeamPublic' + rating_history: + type: array + items: + $ref: '#/components/schemas/MatchRating' + required: + - rating_history + - team TeamReportRequest: type: object properties: @@ -3084,6 +3111,8 @@ components: type: string external_id: type: integer + maximum: 32767 + minimum: -32768 nullable: true name: type: string @@ -3093,9 +3122,14 @@ components: items: type: integer release_status: - $ref: '#/components/schemas/ReleaseStatusEnum' + allOf: + - $ref: '#/components/schemas/ReleaseStatusEnum' + minimum: -2147483648 + maximum: 2147483647 display_order: type: integer + maximum: 32767 + minimum: 0 required: - display_order - id diff --git a/frontend2/src/App.tsx b/frontend2/src/App.tsx index c61f45fad..4f9a177dc 100644 --- a/frontend2/src/App.tsx +++ b/frontend2/src/App.tsx @@ -47,6 +47,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"; const queryClient = new QueryClient({ queryCache: new QueryCache({ @@ -93,6 +94,17 @@ const episodeLoader: LoaderFunction = ({ params }) => { staleTime: Infinity, }); + // Prefetch the top 10 ranked teams' rating histories. + void queryClient.ensureQueryData({ + queryKey: buildKey(searchTeamsFactory.queryKey, { episodeId: id, page: 1 }), + queryFn: async () => + await searchTeamsFactory.queryFn( + { episodeId: id, page: 1 }, + queryClient, + false, // We don't want to prefetch teams 11-20 + ), + }); + return null; }; diff --git a/frontend2/src/api/_autogen/.openapi-generator/FILES b/frontend2/src/api/_autogen/.openapi-generator/FILES index 4c07de7b8..2d1757da7 100644 --- a/frontend2/src/api/_autogen/.openapi-generator/FILES +++ b/frontend2/src/api/_autogen/.openapi-generator/FILES @@ -20,6 +20,7 @@ models/HistoricalRating.ts models/LanguageEnum.ts models/Match.ts models/MatchParticipant.ts +models/MatchRating.ts models/MatchReportRequest.ts models/PaginatedClassRequirementList.ts models/PaginatedEpisodeList.ts @@ -57,6 +58,7 @@ models/TeamProfilePrivate.ts models/TeamProfilePrivateRequest.ts models/TeamProfilePublic.ts models/TeamPublic.ts +models/TeamRating.ts models/TeamReportRequest.ts models/TokenObtainPair.ts models/TokenObtainPairRequest.ts diff --git a/frontend2/src/api/_autogen/apis/CompeteApi.ts b/frontend2/src/api/_autogen/apis/CompeteApi.ts index d8502b2f3..99febc04d 100644 --- a/frontend2/src/api/_autogen/apis/CompeteApi.ts +++ b/frontend2/src/api/_autogen/apis/CompeteApi.ts @@ -55,9 +55,9 @@ import { TournamentSubmissionToJSON, } from '../models'; -export interface CompeteMatchHistoricalRatingRetrieveRequest { +export interface CompeteMatchHistoricalRatingListRequest { episodeId: string; - teamId?: number; + teamIds?: Array; } export interface CompeteMatchListRequest { @@ -169,17 +169,17 @@ export interface CompeteSubmissionTournamentListRequest { export class CompeteApi extends runtime.BaseAPI { /** - * List the historical rating of a team. + * List the historical ratings of a list of teams. */ - async competeMatchHistoricalRatingRetrieveRaw(requestParameters: CompeteMatchHistoricalRatingRetrieveRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async competeMatchHistoricalRatingListRaw(requestParameters: CompeteMatchHistoricalRatingListRequest, 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 competeMatchHistoricalRatingRetrieve.'); + throw new runtime.RequiredError('episodeId','Required parameter requestParameters.episodeId was null or undefined when calling competeMatchHistoricalRatingList.'); } const queryParameters: any = {}; - if (requestParameters.teamId !== undefined) { - queryParameters['team_id'] = requestParameters.teamId; + if (requestParameters.teamIds) { + queryParameters['team_ids'] = requestParameters.teamIds; } const headerParameters: runtime.HTTPHeaders = {}; @@ -199,14 +199,14 @@ export class CompeteApi extends runtime.BaseAPI { query: queryParameters, }, initOverrides); - return new runtime.JSONApiResponse(response, (jsonValue) => HistoricalRatingFromJSON(jsonValue)); + return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(HistoricalRatingFromJSON)); } /** - * List the historical rating of a team. + * List the historical ratings of a list of teams. */ - async competeMatchHistoricalRatingRetrieve(requestParameters: CompeteMatchHistoricalRatingRetrieveRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { - const response = await this.competeMatchHistoricalRatingRetrieveRaw(requestParameters, initOverrides); + async competeMatchHistoricalRatingList(requestParameters: CompeteMatchHistoricalRatingListRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const response = await this.competeMatchHistoricalRatingListRaw(requestParameters, initOverrides); return await response.value(); } diff --git a/frontend2/src/api/_autogen/models/HistoricalRating.ts b/frontend2/src/api/_autogen/models/HistoricalRating.ts index c2c661b62..d27364ad7 100644 --- a/frontend2/src/api/_autogen/models/HistoricalRating.ts +++ b/frontend2/src/api/_autogen/models/HistoricalRating.ts @@ -13,6 +13,13 @@ */ import { exists, mapValues } from '../runtime'; +import type { TeamRating } from './TeamRating'; +import { + TeamRatingFromJSON, + TeamRatingFromJSONTyped, + TeamRatingToJSON, +} from './TeamRating'; + /** * * @export @@ -24,13 +31,13 @@ export interface HistoricalRating { * @type {number} * @memberof HistoricalRating */ - rating: number; + team_id: number; /** * - * @type {Date} + * @type {TeamRating} * @memberof HistoricalRating */ - timestamp: Date; + team_rating?: TeamRating; } /** @@ -38,8 +45,7 @@ export interface HistoricalRating { */ export function instanceOfHistoricalRating(value: object): boolean { let isInstance = true; - isInstance = isInstance && "rating" in value; - isInstance = isInstance && "timestamp" in value; + isInstance = isInstance && "team_id" in value; return isInstance; } @@ -54,8 +60,8 @@ export function HistoricalRatingFromJSONTyped(json: any, ignoreDiscriminator: bo } return { - 'rating': json['rating'], - 'timestamp': (new Date(json['timestamp'])), + 'team_id': json['team_id'], + 'team_rating': !exists(json, 'team_rating') ? undefined : TeamRatingFromJSON(json['team_rating']), }; } @@ -68,8 +74,8 @@ export function HistoricalRatingToJSON(value?: HistoricalRating | null): any { } return { - 'rating': value.rating, - 'timestamp': (value.timestamp.toISOString()), + 'team_id': value.team_id, + 'team_rating': TeamRatingToJSON(value.team_rating), }; } diff --git a/frontend2/src/api/_autogen/models/MatchRating.ts b/frontend2/src/api/_autogen/models/MatchRating.ts new file mode 100644 index 000000000..8bd5091f8 --- /dev/null +++ b/frontend2/src/api/_autogen/models/MatchRating.ts @@ -0,0 +1,75 @@ +/* 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 MatchRating + */ +export interface MatchRating { + /** + * + * @type {number} + * @memberof MatchRating + */ + rating: number; + /** + * + * @type {Date} + * @memberof MatchRating + */ + timestamp: Date; +} + +/** + * Check if a given object implements the MatchRating interface. + */ +export function instanceOfMatchRating(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "rating" in value; + isInstance = isInstance && "timestamp" in value; + + return isInstance; +} + +export function MatchRatingFromJSON(json: any): MatchRating { + return MatchRatingFromJSONTyped(json, false); +} + +export function MatchRatingFromJSONTyped(json: any, ignoreDiscriminator: boolean): MatchRating { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'rating': json['rating'], + 'timestamp': (new Date(json['timestamp'])), + }; +} + +export function MatchRatingToJSON(value?: MatchRating | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'rating': value.rating, + 'timestamp': (value.timestamp.toISOString()), + }; +} + diff --git a/frontend2/src/api/_autogen/models/TeamRating.ts b/frontend2/src/api/_autogen/models/TeamRating.ts new file mode 100644 index 000000000..aa65b89fa --- /dev/null +++ b/frontend2/src/api/_autogen/models/TeamRating.ts @@ -0,0 +1,88 @@ +/* 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'; +import type { MatchRating } from './MatchRating'; +import { + MatchRatingFromJSON, + MatchRatingFromJSONTyped, + MatchRatingToJSON, +} from './MatchRating'; +import type { TeamPublic } from './TeamPublic'; +import { + TeamPublicFromJSON, + TeamPublicFromJSONTyped, + TeamPublicToJSON, +} from './TeamPublic'; + +/** + * + * @export + * @interface TeamRating + */ +export interface TeamRating { + /** + * + * @type {TeamPublic} + * @memberof TeamRating + */ + team: TeamPublic; + /** + * + * @type {Array} + * @memberof TeamRating + */ + rating_history: Array; +} + +/** + * Check if a given object implements the TeamRating interface. + */ +export function instanceOfTeamRating(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "team" in value; + isInstance = isInstance && "rating_history" in value; + + return isInstance; +} + +export function TeamRatingFromJSON(json: any): TeamRating { + return TeamRatingFromJSONTyped(json, false); +} + +export function TeamRatingFromJSONTyped(json: any, ignoreDiscriminator: boolean): TeamRating { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'team': TeamPublicFromJSON(json['team']), + 'rating_history': ((json['rating_history'] as Array).map(MatchRatingFromJSON)), + }; +} + +export function TeamRatingToJSON(value?: TeamRating | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'team': TeamPublicToJSON(value.team), + 'rating_history': ((value.rating_history as Array).map(MatchRatingToJSON)), + }; +} + diff --git a/frontend2/src/api/_autogen/models/index.ts b/frontend2/src/api/_autogen/models/index.ts index 5d85304c8..8d8127cd6 100644 --- a/frontend2/src/api/_autogen/models/index.ts +++ b/frontend2/src/api/_autogen/models/index.ts @@ -13,6 +13,7 @@ export * from './HistoricalRating'; export * from './LanguageEnum'; export * from './Match'; export * from './MatchParticipant'; +export * from './MatchRating'; export * from './MatchReportRequest'; export * from './PaginatedClassRequirementList'; export * from './PaginatedEpisodeList'; @@ -50,6 +51,7 @@ export * from './TeamProfilePrivate'; export * from './TeamProfilePrivateRequest'; export * from './TeamProfilePublic'; export * from './TeamPublic'; +export * from './TeamRating'; export * from './TeamReportRequest'; export * from './TokenObtainPair'; export * from './TokenObtainPairRequest'; diff --git a/frontend2/src/api/compete/competeApi.ts b/frontend2/src/api/compete/competeApi.ts index a40fbf903..83016171a 100644 --- a/frontend2/src/api/compete/competeApi.ts +++ b/frontend2/src/api/compete/competeApi.ts @@ -19,6 +19,8 @@ import { type CompeteMatchListRequest, type CompeteSubmissionTournamentListRequest, type CompeteRequestDestroyRequest, + type CompeteMatchHistoricalRatingListRequest, + type HistoricalRating, } from "../_autogen"; import { DEFAULT_API_CONFIGURATION, downloadFile } from "../helpers"; @@ -209,3 +211,16 @@ export const getMatchesList = async ({ page, }: CompeteMatchListRequest): Promise => await API.competeMatchList({ episodeId, page }); + +/** + * Get the rating history for a list of teams in a given episode. + * Defaults to the logged in user's team if no team IDs are provided. + * + * @param episodeId The episode ID to retrieve rating data for. + * @param teamIds The team IDs to retrieve rating data for. + */ +export const getRatingList = async ({ + episodeId, + teamIds, +}: CompeteMatchHistoricalRatingListRequest): Promise => + await API.competeMatchHistoricalRatingList({ episodeId, teamIds }); diff --git a/frontend2/src/api/compete/competeFactories.ts b/frontend2/src/api/compete/competeFactories.ts index 08a2b8d27..67c75deaf 100644 --- a/frontend2/src/api/compete/competeFactories.ts +++ b/frontend2/src/api/compete/competeFactories.ts @@ -1,4 +1,5 @@ import type { + CompeteMatchHistoricalRatingListRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, CompeteMatchTournamentListRequest, @@ -6,6 +7,7 @@ import type { CompeteRequestOutboxListRequest, CompeteSubmissionListRequest, CompeteSubmissionTournamentListRequest, + HistoricalRating, PaginatedMatchList, PaginatedScrimmageRequestList, PaginatedSubmissionList, @@ -16,6 +18,7 @@ import { competeQueryKeys } from "./competeKeys"; import { getAllUserTournamentSubmissions, getMatchesList, + getRatingList, getScrimmagesListByTeam, getSubmissionsList, getTournamentMatchesList, @@ -187,3 +190,21 @@ export const tournamentMatchListFactory: PaginatedQueryFactory< return result; }, } as const; + +export const ratingHistoryTopFactory: QueryFactory< + CompeteMatchHistoricalRatingListRequest, + HistoricalRating[] +> = { + queryKey: competeQueryKeys.ratingHistoryTopList, + queryFn: async ({ episodeId, teamIds }) => + await getRatingList({ episodeId, teamIds }), +} as const; + +export const ratingHistoryMeFactory: QueryFactory< + { episodeId: string }, + HistoricalRating[] +> = { + queryKey: competeQueryKeys.ratingHistoryMeList, + queryFn: async ({ episodeId }) => + await getRatingList({ episodeId, teamIds: undefined }), +} as const; diff --git a/frontend2/src/api/compete/competeKeys.ts b/frontend2/src/api/compete/competeKeys.ts index 7a7ebe58d..a51124c9c 100644 --- a/frontend2/src/api/compete/competeKeys.ts +++ b/frontend2/src/api/compete/competeKeys.ts @@ -1,4 +1,5 @@ import type { + CompeteMatchHistoricalRatingListRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, CompeteMatchTournamentListRequest, @@ -10,17 +11,24 @@ import type { import type { QueryKeyBuilder } from "../apiTypes"; interface CompeteKeys { + // --- SUBMISSIONS --- // subBase: QueryKeyBuilder<{ episodeId: string }>; subList: QueryKeyBuilder; tourneySubs: QueryKeyBuilder; + // --- SCRIMMAGES --- // scrimBase: QueryKeyBuilder<{ episodeId: string }>; inbox: QueryKeyBuilder; outbox: QueryKeyBuilder; scrimsMeList: QueryKeyBuilder; scrimsOtherList: QueryKeyBuilder; + // --- MATCHES --- // matchBase: QueryKeyBuilder<{ episodeId: string }>; matchList: QueryKeyBuilder; tourneyMatchList: QueryKeyBuilder; + // --- PERFORMANCE --- // + ratingHistoryBase: QueryKeyBuilder<{ episodeId: string }>; + ratingHistoryTopList: QueryKeyBuilder; + ratingHistoryMeList: QueryKeyBuilder; } // ---------- KEY RECORDS ---------- // @@ -113,6 +121,25 @@ export const competeQueryKeys: CompeteKeys = { page, ] as const, }, + + // --- PERFORMANCE --- // + ratingHistoryBase: { + key: ({ episodeId }: { episodeId: string }) => + ["compete", episodeId, "ratingHistory"] as const, + }, + + ratingHistoryTopList: { + key: ({ episodeId }: CompeteMatchHistoricalRatingListRequest) => + [ + ...competeQueryKeys.ratingHistoryBase.key({ episodeId }), + "top", + ] as const, + }, + + ratingHistoryMeList: { + key: ({ episodeId }: CompeteMatchHistoricalRatingListRequest) => + [...competeQueryKeys.ratingHistoryBase.key({ episodeId }), "me"] as const, + }, }; export const competeMutationKeys = { diff --git a/frontend2/src/api/compete/useCompete.ts b/frontend2/src/api/compete/useCompete.ts index f9a53eb74..ea477e887 100644 --- a/frontend2/src/api/compete/useCompete.ts +++ b/frontend2/src/api/compete/useCompete.ts @@ -9,6 +9,7 @@ import { } from "@tanstack/react-query"; import { competeMutationKeys, competeQueryKeys } from "./competeKeys"; import type { + CompeteMatchHistoricalRatingListRequest, CompeteMatchListRequest, CompeteMatchScrimmageListRequest, CompeteMatchTournamentListRequest, @@ -21,9 +22,11 @@ import type { CompeteSubmissionCreateRequest, CompeteSubmissionListRequest, CompeteSubmissionTournamentListRequest, + HistoricalRating, PaginatedMatchList, PaginatedScrimmageRequestList, PaginatedSubmissionList, + PaginatedTeamPublicList, ResponseError, ScrimmageRequest, Submission, @@ -40,6 +43,8 @@ import toast from "react-hot-toast"; import { buildKey } from "../helpers"; import { matchListFactory, + ratingHistoryMeFactory, + ratingHistoryTopFactory, scrimmageInboxListFactory, scrimmageOutboxListFactory, subsListFactory, @@ -48,6 +53,8 @@ import { tournamentSubsListFactory, userScrimmageListFactory, } from "./competeFactories"; +import { searchTeamsFactory } from "api/team/teamFactories"; +import { isPresent } from "utils/utilTypes"; // ---------- QUERY HOOKS ---------- // /** @@ -194,6 +201,56 @@ export const useTournamentMatchList = ( ), }); +/** + * For retrieving a list of the top 10 teams' historical ratings in a given episode. + */ +export const useTopRatingHistoryList = ( + { episodeId }: CompeteMatchHistoricalRatingListRequest, + queryClient: QueryClient, +): UseQueryResult => + useQuery({ + queryKey: buildKey(ratingHistoryTopFactory.queryKey, { episodeId }), + queryFn: async () => { + // Get the query data for the top 10 teams in this episode + const topTeamsData: PaginatedTeamPublicList | undefined = + await queryClient.ensureQueryData({ + queryKey: buildKey(searchTeamsFactory.queryKey, { episodeId }), + queryFn: async () => + await searchTeamsFactory.queryFn( + { episodeId, page: 1 }, + queryClient, + false, // We don't want to prefetch teams 11-20 + ), + }); + // Fetch their rating histories + if (isPresent(topTeamsData) && isPresent(topTeamsData.results)) { + const topTeamsIds = topTeamsData.results.map((team) => team.id); + return await ratingHistoryTopFactory.queryFn({ + episodeId, + teamIds: topTeamsIds, + }); + } else { + return []; + } + }, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + +/** + * For retrieving a list of the currently logged in user's team's historical rating in a given episode. + */ +export const useUserRatingHistoryList = ({ + episodeId, +}: CompeteMatchHistoricalRatingListRequest): UseQueryResult< + HistoricalRating[], + Error +> => + useQuery({ + queryKey: buildKey(ratingHistoryMeFactory.queryKey, { episodeId }), + queryFn: async () => await ratingHistoryMeFactory.queryFn({ episodeId }), + staleTime: 1000 * 60 * 5, // 5 minutes + }); + // ---------- MUTATION HOOKS ---------- // /** * For uploading a new submission. diff --git a/frontend2/src/components/tables/chart/TeamChart.tsx b/frontend2/src/components/tables/chart/TeamChart.tsx index caeeb1c3a..7ccd7f1d5 100644 --- a/frontend2/src/components/tables/chart/TeamChart.tsx +++ b/frontend2/src/components/tables/chart/TeamChart.tsx @@ -1,10 +1,15 @@ -import React from "react"; +import React, { useMemo, useState } from "react"; import Highcharts from "highcharts"; import HighchartsReact from "highcharts-react-official"; +import NoDataToDisplay from "highcharts/modules/no-data-to-display"; import * as chart from "./chartUtil"; +NoDataToDisplay(Highcharts); + type UTCMilliTimestamp = number; +export type ChartData = [UTCMilliTimestamp, number]; + // `yAxisLabel` is the name that is shown to the right of the graph. // // `values` holds a dict where the string keys are the name of the team, and @@ -14,15 +19,23 @@ type UTCMilliTimestamp = number; // { "Gone Sharkin" : [ [ 1673826806000.0, 1000 ], [1673826805999.0, 900], ... ], ...} export interface TeamChartProps { yAxisLabel: string; - values: Record>; + values?: Record; + loading?: boolean; + loadingMessage?: string; } -const TeamChart = ({ yAxisLabel, values }: TeamChartProps): JSX.Element => { - const seriesData: Highcharts.SeriesOptionsType[] = []; - +const TeamChart: React.FC = ({ + yAxisLabel, + values, + loading = false, + loadingMessage, +}) => { // Translate values into Highcharts compatible options - for (const team in values) { - const formattedEntry: Highcharts.SeriesOptionsType = { + const [myChart, setChart] = useState(); + + const seriesData: Highcharts.SeriesOptionsType[] = useMemo(() => { + if (values === undefined) return []; + return Object.keys(values).map((team) => ({ type: "line", name: team, data: values[team], @@ -30,12 +43,38 @@ const TeamChart = ({ yAxisLabel, values }: TeamChartProps): JSX.Element => { enabled: false, symbol: "circle", }, - }; - seriesData.push(formattedEntry); + })); + }, [values]); + + if (myChart !== undefined) { + try { + if (loading) myChart.showLoading(loadingMessage ?? "Loading..."); + else myChart.hideLoading(); + } catch (e) { + // Ignore internal highcharts errors... + } } const options: Highcharts.Options = { ...chart.highchartsOptionsBase, + lang: { + loading: loadingMessage ?? "Loading...", + noData: "No data found to display.", + }, + noData: { + style: { + fontWeight: "bold", + fontSize: "15px", + color: "red", + }, + }, + loading: { + style: { + fontWeight: "bold", + fontSize: "18px", + color: "teal", + }, + }, chart: { ...chart.highchartsOptionsBase.chart, height: 400, @@ -86,7 +125,7 @@ const TeamChart = ({ yAxisLabel, values }: TeamChartProps): JSX.Element => { return names; }, }, - series: seriesData, + series: seriesData ?? [], legend: { layout: "vertical", @@ -100,7 +139,13 @@ const TeamChart = ({ yAxisLabel, values }: TeamChartProps): JSX.Element => { return (
- + { + setChart(chart); + }} + />
); }; diff --git a/frontend2/src/views/Home.tsx b/frontend2/src/views/Home.tsx index 7d20beaab..5463bb42a 100644 --- a/frontend2/src/views/Home.tsx +++ b/frontend2/src/views/Home.tsx @@ -1,21 +1,41 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useEpisodeId } from "../contexts/EpisodeContext"; import { useEpisodeInfo, useNextTournament } from "../api/episode/useEpisode"; import SectionCard from "../components/SectionCard"; import CountdownDigital from "../components/CountdownDigital"; import Spinner from "../components/Spinner"; import { SocialIcon } from "react-social-icons"; -import TeamChart from "../components/tables/chart/TeamChart"; -import * as random_data from "../components/tables/chart/randomData"; +import TeamChart, { + type ChartData, +} from "../components/tables/chart/TeamChart"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTopRatingHistoryList } from "api/compete/useCompete"; const Home: React.FC = () => { const { episodeId } = useEpisodeId(); + const queryClient = useQueryClient(); const episode = useEpisodeInfo({ id: episodeId }); const nextTournament = useNextTournament({ episodeId }); + const topRatingHistory = useTopRatingHistoryList({ episodeId }, queryClient); 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]); + return (
@@ -56,20 +76,12 @@ const Home: React.FC = () => {
- + {/* ANNOUNCEMENTS (TODO) */}