diff --git a/projects/backend/src/controller/scores.controller.ts b/projects/backend/src/controller/scores.controller.ts index 59f47f2..f3a633e 100644 --- a/projects/backend/src/controller/scores.controller.ts +++ b/projects/backend/src/controller/scores.controller.ts @@ -2,34 +2,38 @@ import { Controller, Get } from "elysia-decorators"; import { t } from "elysia"; import { Leaderboards } from "@ssr/common/leaderboard"; import { ScoreService } from "../service/score.service"; +import { ScoreSortType } from "@ssr/common/sorter/sort-type"; +import { SortDirection } from "@ssr/common/sorter/sort-direction"; @Controller("/scores") export default class ScoresController { - @Get("/player/:leaderboard/:id/:page/:sort", { + @Get("/player/:leaderboard/:id/:page/:sort/:direction", { config: {}, params: t.Object({ leaderboard: t.String({ required: true }), id: t.String({ required: true }), page: t.Number({ required: true }), sort: t.String({ required: true }), + direction: t.String({ required: true }), }), query: t.Object({ search: t.Optional(t.String()), }), }) public async getScores({ - params: { leaderboard, id, page, sort }, + params: { leaderboard, id, page, sort, direction }, query: { search }, }: { params: { leaderboard: Leaderboards; id: string; page: number; - sort: string; + sort: ScoreSortType; + direction: SortDirection; }; query: { search?: string }; }): Promise { - return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search); + return await ScoreService.getPlayerScores(leaderboard, id, page, sort, direction, search); } @Get("/leaderboard/:leaderboard/:id/:page", { diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index 37d3e54..d460c83 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -24,6 +24,7 @@ import { connectScoresaberWebsocket } from "@ssr/common/websocket/scoresaber-web import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket"; import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot"; import { EmbedBuilder } from "discord.js"; +import { ScoreSort } from "@ssr/common/score/score-sort"; // Load .env file dotenv.config({ @@ -38,7 +39,9 @@ await mongoose.connect(Config.mongoUri!); // Connect to MongoDB // Connect to websockets connectScoresaberWebsocket({ onScore: async score => { - await ScoreService.trackScoreSaberScore(score); + await ScoreService.trackScoreSaberScore(score.score, score.leaderboard); + await ScoreService.updatePlayerScoresSet(score); + await ScoreService.notifyNumberOne(score); }, onDisconnect: async error => { @@ -106,6 +109,46 @@ app.use( }) ); +app.use( + cron({ + name: "scores-background-refresh", + pattern: "*/1 * * * *", + protect: true, + run: async () => { + console.log(`Refreshing player score data...`); + const players = await PlayerModel.find({}); + console.log(`Found ${players.length} players to refresh.`); + + for (const player of players) { + console.log(`Refreshing scores for ${player.id}...`); + let page = 1; + let hasMorePages = true; + + while (hasMorePages) { + const scoresPage = await scoresaberService.lookupPlayerScores({ + playerId: player.id, + page: page, + limit: 100, + sort: ScoreSort.recent, + }); + if (!scoresPage) { + break; + } + if (scoresPage.metadata.total <= page * 100) { + hasMorePages = false; + } + page++; + + for (const score of scoresPage.playerScores) { + await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id); + } + } + console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`); + } + }, + }) +); + /** * Custom error handler */ diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 2815bdf..3ab75a8 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -3,9 +3,7 @@ import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils import { isProduction } from "@ssr/common/utils/utils"; import { Metadata } from "@ssr/common/types/metadata"; import { NotFoundError } from "elysia"; -import BeatSaverService from "./beatsaver.service"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; -import { ScoreSort } from "@ssr/common/score/score-sort"; import { Leaderboards } from "@ssr/common/leaderboard"; import Leaderboard from "@ssr/common/leaderboard/leaderboard"; import LeaderboardService from "./leaderboard.service"; @@ -25,10 +23,24 @@ import { AdditionalScoreDataModel, } from "@ssr/common/model/additional-score-data/additional-score-data"; import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement"; -import { ScoreType } from "@ssr/common/model/score/score"; +import Score, { ScoreType } from "@ssr/common/model/score/score"; import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators"; -import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; +import { + ScoreSaberScore, + ScoreSaberScoreInternal, + ScoreSaberScoreModel, +} from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import { ScoreSorters } from "@ssr/common/sorter/sorters"; +import { ScoreSortType } from "@ssr/common/sorter/sort-type"; +import { SortDirection } from "@ssr/common/sorter/sort-direction"; +import { Pagination } from "../../../common/src/pagination"; +import { PlayerService } from "./player.service"; +import { ScoreSort } from "@ssr/common/score/score-sort"; +import BeatSaverService from "./beatsaver.service"; +import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; const playerScoresCache = new SSRCache({ ttl: 1000 * 60, // 1 minute @@ -38,6 +50,8 @@ const leaderboardScoresCache = new SSRCache({ ttl: 1000 * 60, // 1 minute }); +const ITEMS_PER_PAGE = 8; + export class ScoreService { /** * Notifies the number one score in Discord. @@ -118,15 +132,17 @@ export class ScoreService { } /** - * Tracks ScoreSaber score. + * Updates the players set scores count for today. * - * @param score the score to track - * @param leaderboard the leaderboard to track + * @param score the score */ - public static async trackScoreSaberScore({ score, leaderboard: leaderboardToken }: ScoreSaberPlayerScoreToken) { + public static async updatePlayerScoresSet({ + score: scoreToken, + leaderboard: leaderboardToken, + }: ScoreSaberPlayerScoreToken) { + const playerId = scoreToken.leaderboardPlayerInfo.id; + const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken); - const playerId = score.leaderboardPlayerInfo.id; - const playerName = score.leaderboardPlayerInfo.name; const player: PlayerDocument | null = await PlayerModel.findById(playerId); // Player is not tracked, so ignore the score. if (player == undefined) { @@ -147,37 +163,36 @@ export class ScoreService { history.scores = scores; player.setStatisticHistory(today, history); - player.sortStatisticHistory(); - - // Save the changes - player.markModified("statisticHistory"); await player.save(); + } - const scoreToken = getScoreSaberScoreFromToken(score, leaderboard, playerId); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - delete scoreToken.playerInfo; + /** + * Tracks ScoreSaber score. + * + * @param scoreToken the score to track + * @param leaderboardToken the leaderboard for the score + * @param playerId the id of the player + */ + public static async trackScoreSaberScore( + scoreToken: ScoreSaberScoreToken, + leaderboardToken: ScoreSaberLeaderboardToken, + playerId?: string + ) { + playerId = playerId || scoreToken.leaderboardPlayerInfo.id; - // Check if the score already exists - if ( - await ScoreSaberScoreModel.exists({ - playerId: playerId, - leaderboardId: leaderboard.id, - score: scoreToken.score, - difficulty: leaderboard.difficulty.difficulty, - characteristic: leaderboard.difficulty.characteristic, - }) - ) { - console.log( - `Score already exists for "${playerName}"(${playerId}), scoreId=${scoreToken.scoreId}, score=${scoreToken.score}` - ); + const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken); + const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, playerId); + const player: PlayerDocument | null = await PlayerModel.findById(playerId); + // Player is not tracked, so ignore the score. + if (player == undefined) { return; } - await ScoreSaberScoreModel.create(scoreToken); - console.log( - `Tracked score and updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked` - ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + delete score.playerInfo; + + await ScoreSaberScoreModel.create(score); } /** @@ -292,7 +307,8 @@ export class ScoreService { * @param leaderboardName the leaderboard to get the scores from * @param playerId the players id * @param page the page to get - * @param sort the sort to use + * @param sort the sort type to use + * @param direction the direction to sort the scores * @param search the search to use * @returns the scores */ @@ -300,62 +316,132 @@ export class ScoreService { leaderboardName: Leaderboards, playerId: string, page: number, - sort: string, + sort: ScoreSortType, + direction: SortDirection, search?: string ): Promise | undefined> { + console.log( + `Fetching scores for ${playerId} on ${leaderboardName}, page: ${page}, sort: ${sort}, direction: ${direction}, search: ${search}` + ); + return fetchWithCache( playerScoresCache, `player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`, async () => { - const scores: PlayerScore[] | undefined = []; + const toReturn: PlayerScore[] | undefined = []; let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values switch (leaderboardName) { case "scoresaber": { - const leaderboardScores = await scoresaberService.lookupPlayerScores({ - playerId: playerId, - page: page, - sort: sort as ScoreSort, - search: search, - }); - if (leaderboardScores == undefined) { - break; + let isPlayerTracked = false; + try { + isPlayerTracked = (await PlayerService.getPlayer(playerId, false)) != undefined; + } catch { + /* ignored */ } - - metadata = new Metadata( - Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage), - leaderboardScores.metadata.total, - leaderboardScores.metadata.page, - leaderboardScores.metadata.itemsPerPage - ); - - for (const token of leaderboardScores.playerScores) { - const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard); - if (leaderboard == undefined) { - continue; - } - - const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId); - if (score == undefined) { - continue; - } - - const additionalData = await this.getAdditionalScoreData( - playerId, - leaderboard.songHash, - `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`, - score.score + if (isPlayerTracked) { + const rawScores = ScoreSorters.scoreSaber.sort( + sort, + direction, + (await ScoreSaberScoreModel.find({ playerId: playerId }).exec()) as unknown as ScoreSaberScore[] ); - if (additionalData !== undefined) { - score.additionalData = additionalData; + if (!rawScores || rawScores.length === 0) { + break; } - scores.push({ - score: score, - leaderboard: leaderboard, - beatSaver: await BeatSaverService.getMap(leaderboard.songHash), + const pagination = new Pagination().setItemsPerPage(ITEMS_PER_PAGE).setItems(rawScores); + const paginatedPage = pagination.getPage(page); + metadata = paginatedPage.metadata; + + for (const score of paginatedPage.items) { + const { leaderboard, beatsaver } = await LeaderboardService.getLeaderboard( + "scoresaber", + String(score.leaderboardId) + ); + if (leaderboard == undefined) { + continue; + } + + const additionalData = await this.getAdditionalScoreData( + playerId, + leaderboard.songHash, + `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`, + score.score + ); + if (additionalData == undefined) { + continue; + } + + toReturn.push({ + score: score, + leaderboard: leaderboard, + beatSaver: beatsaver, + }); + } + } else { + // Convert the sort type + let scoreSaberSort: ScoreSort; + switch (sort) { + case ScoreSortType.date: { + scoreSaberSort = ScoreSort.recent; + break; + } + case ScoreSortType.pp: { + scoreSaberSort = ScoreSort.top; + break; + } + default: { + scoreSaberSort = ScoreSort.recent; + break; + } + } + + const rawScores = await scoresaberService.lookupPlayerScores({ + playerId: playerId, + page: page, + sort: scoreSaberSort, + search: search, }); + if (!rawScores || rawScores.playerScores.length === 0) { + break; + } + + metadata = new Metadata( + Math.ceil(rawScores.metadata.total / rawScores.metadata.itemsPerPage), + rawScores.metadata.total, + rawScores.metadata.page, + rawScores.metadata.itemsPerPage + ); + + for (const token of rawScores.playerScores) { + const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard); + if (leaderboard == undefined) { + continue; + } + + const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId); + if (score == undefined) { + continue; + } + + const additionalData = await this.getAdditionalScoreData( + playerId, + leaderboard.songHash, + `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`, + score.score + ); + if (additionalData !== undefined) { + score.additionalData = additionalData; + } + + toReturn.push({ + score: score, + leaderboard: leaderboard, + beatSaver: await BeatSaverService.getMap(leaderboard.songHash), + }); + } } + break; } default: { @@ -363,8 +449,9 @@ export class ScoreService { } } + console.log(metadata); return { - scores: scores, + scores: toReturn, metadata: metadata, }; } diff --git a/projects/common/src/pagination.ts b/projects/common/src/pagination.ts new file mode 100644 index 0000000..3f93894 --- /dev/null +++ b/projects/common/src/pagination.ts @@ -0,0 +1,73 @@ +import { NotFoundError } from "backend/src/error/not-found-error"; +import { Metadata } from "./types/metadata"; + +export class Pagination { + /** + * The amount of items per page. + * @private + */ + private itemsPerPage: number = 0; + + /** + * The amount of items in total. + * @private + */ + private totalItems: number = 0; + + /** + * The items to paginate. + * @private + */ + private items: T[] = []; + + /** + * Sets the number of items per page. + * + * @param itemsPerPage - The number of items per page. + * @returns the pagination + */ + setItemsPerPage(itemsPerPage: number): Pagination { + this.itemsPerPage = itemsPerPage; + return this; + } + + /** + * Sets the items to paginate. + * + * @param items the items to paginate + * @returns the pagination + */ + setItems(items: T[]): Pagination { + this.items = items; + this.totalItems = items.length; + return this; + } + + /** + * Gets a page of items. + * + * @param page the page number to retrieve. + * @returns the page of items. + * @throws throws an error if the page number is invalid. + */ + getPage(page: number): Page { + const totalPages = Math.ceil(this.totalItems / this.itemsPerPage); + + if (page < 1 || page > totalPages) { + throw new NotFoundError("Invalid page number"); + } + + const items = this.items.slice((page - 1) * this.itemsPerPage, page * this.itemsPerPage); + return new Page(items, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage)); + } +} + +class Page { + readonly items: T[]; + readonly metadata: Metadata; + + constructor(items: T[], metadata: Metadata) { + this.items = items; + this.metadata = metadata; + } +} diff --git a/projects/common/src/service/impl/scoresaber.ts b/projects/common/src/service/impl/scoresaber.ts index 2d618a1..d0bb439 100644 --- a/projects/common/src/service/impl/scoresaber.ts +++ b/projects/common/src/service/impl/scoresaber.ts @@ -168,6 +168,7 @@ class ScoreSaberService extends Service { * @param playerId the ID of the player to look up * @param sort the sort to use * @param page the page to get scores for + * @param limit the amount of scores to fetch * @param search * @returns the scores of the player, or undefined */ @@ -175,11 +176,13 @@ class ScoreSaberService extends Service { playerId, sort, page, + limit = 8, search, }: { playerId: string; sort: ScoreSort; page: number; + limit?: number; search?: string; useProxy?: boolean; }): Promise { @@ -189,7 +192,7 @@ class ScoreSaberService extends Service { ); const response = await this.fetch( LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId) - .replace(":limit", 8 + "") + .replace(":limit", limit + "") .replace(":sort", sort) .replace(":page", page + "") + (search ? `&search=${search}` : "") ); diff --git a/projects/common/src/sorter/impl/scoresaber-sorter.ts b/projects/common/src/sorter/impl/scoresaber-sorter.ts new file mode 100644 index 0000000..f5aea08 --- /dev/null +++ b/projects/common/src/sorter/impl/scoresaber-sorter.ts @@ -0,0 +1,69 @@ +import { ScoreSorter } from "../score-sorter"; +import { ScoreSaberScore } from "../../model/score/impl/scoresaber-score"; +import { ScoreSortType } from "../sort-type"; +import { SortDirection } from "../sort-direction"; + +export class ScoreSaberScoreSorter extends ScoreSorter { + sort(type: ScoreSortType, direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] { + switch (type) { + case ScoreSortType.date: + return this.sortRecent(direction, items); + case ScoreSortType.pp: + return this.sortPp(direction, items); + case ScoreSortType.accuracy: + return this.sortAccuracy(direction, items); + case ScoreSortType.misses: + return this.sortMisses(direction, items); + default: + return items; + } + } + + /** + * Sorts the scores by the time they were set. + * + * @param direction the direction to sort the scores + * @param items the scores to sort + * @returns the sorted scores + */ + sortRecent(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] { + return items.sort((a, b) => + direction === SortDirection.ASC + ? a.timestamp.getTime() - b.timestamp.getTime() + : b.timestamp.getTime() - a.timestamp.getTime() + ); + } + + /** + * Sorts the scores by their pp value + * + * @param direction the direction to sort the scores + * @param items the scores to sort + * @returns the sorted scores + */ + sortPp(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] { + return items.sort((a, b) => (direction === SortDirection.ASC ? a.pp - b.pp : b.pp - a.pp)); + } + + /** + * Sorts the scores by their accuracy value + * + * @param direction the direction to sort the scores + * @param items the scores to sort + * @returns the sorted scores + */ + sortAccuracy(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] { + return items.sort((a, b) => (direction === SortDirection.ASC ? a.accuracy - b.accuracy : b.accuracy - a.accuracy)); + } + + /** + * Sorts the scores by their misses + * + * @param direction the direction to sort the scores + * @param items the scores to sort + * @returns the sorted scores + */ + sortMisses(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] { + return items.sort((a, b) => (direction === SortDirection.ASC ? a.misses - b.misses : b.misses - a.misses)); + } +} diff --git a/projects/common/src/sorter/score-sorter.ts b/projects/common/src/sorter/score-sorter.ts new file mode 100644 index 0000000..2808a33 --- /dev/null +++ b/projects/common/src/sorter/score-sorter.ts @@ -0,0 +1,14 @@ +import { ScoreSortType } from "./sort-type"; +import { SortDirection } from "./sort-direction"; + +export abstract class ScoreSorter { + /** + * Sorts the items + * + * @param type the type of sort + * @param direction the direction of the sort + * @param items the items to sort + * @returns the sorted items + */ + public abstract sort(type: ScoreSortType, direction: SortDirection, items: T[]): T[]; +} diff --git a/projects/common/src/sorter/sort-direction.ts b/projects/common/src/sorter/sort-direction.ts new file mode 100644 index 0000000..0aafd19 --- /dev/null +++ b/projects/common/src/sorter/sort-direction.ts @@ -0,0 +1,4 @@ +export enum SortDirection { + ASC = "asc", + DESC = "desc", +} diff --git a/projects/common/src/sorter/sort-type.ts b/projects/common/src/sorter/sort-type.ts new file mode 100644 index 0000000..ab24709 --- /dev/null +++ b/projects/common/src/sorter/sort-type.ts @@ -0,0 +1,6 @@ +export enum ScoreSortType { + date = "date", + pp = "pp", + accuracy = "accuracy", + misses = "misses", +} diff --git a/projects/common/src/sorter/sorters.ts b/projects/common/src/sorter/sorters.ts new file mode 100644 index 0000000..ab3f01f --- /dev/null +++ b/projects/common/src/sorter/sorters.ts @@ -0,0 +1,5 @@ +import { ScoreSaberScoreSorter } from "./impl/scoresaber-sorter"; + +export const ScoreSorters = { + scoreSaber: new ScoreSaberScoreSorter(), +}; diff --git a/projects/common/src/utils/cookie-utils.ts b/projects/common/src/utils/cookie-utils.ts index 9ef76b9..c70c962 100644 --- a/projects/common/src/utils/cookie-utils.ts +++ b/projects/common/src/utils/cookie-utils.ts @@ -1,6 +1,6 @@ import { isServer } from "./utils"; -export type CookieName = "playerId" | "lastScoreSort"; +export type CookieName = "playerId" | "lastScoreSort" | "lastScoreSortDirection"; /** * Gets the value of a cookie diff --git a/projects/common/src/utils/score-utils.ts b/projects/common/src/utils/score-utils.ts index a5b351b..7ecba93 100644 --- a/projects/common/src/utils/score-utils.ts +++ b/projects/common/src/utils/score-utils.ts @@ -4,6 +4,8 @@ import PlayerScoresResponse from "../response/player-scores-response"; import { Config } from "../config"; import { ScoreSort } from "../score/score-sort"; import LeaderboardScoresResponse from "../response/leaderboard-scores-response"; +import { ScoreSortType } from "../sorter/sort-type"; +import { SortDirection } from "../sorter/sort-direction"; /** * Fetches the player's scores @@ -12,17 +14,19 @@ import LeaderboardScoresResponse from "../response/leaderboard-scores-response"; * @param id the player id * @param page the page * @param sort the sort + * @param direction the direction to sort * @param search the search */ export async function fetchPlayerScores( leaderboard: Leaderboards, id: string, page: number, - sort: ScoreSort, + sort: ScoreSortType, + direction: SortDirection, search?: string ) { return kyFetch>( - `${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}${search ? `?search=${search}` : ""}` + `${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}/${direction}${search ? `?search=${search}` : ""}` ); } diff --git a/projects/website/src/app/(pages)/player/[...slug]/page.tsx b/projects/website/src/app/(pages)/player/[...slug]/page.tsx index c17ed59..2cae052 100644 --- a/projects/website/src/app/(pages)/player/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/player/[...slug]/page.tsx @@ -7,13 +7,14 @@ import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { getCookieValue } from "@ssr/common/utils/cookie-utils"; import { Config } from "@ssr/common/config"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; -import { ScoreSort } from "@ssr/common/score/score-sort"; import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { fetchPlayerScores } from "@ssr/common/utils/score-utils"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; import { SSRCache } from "@ssr/common/cache"; import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators"; +import { ScoreSortType } from "@ssr/common/sorter/sort-type"; +import { SortDirection } from "@ssr/common/sorter/sort-direction"; const UNKNOWN_PLAYER = { title: "ScoreSaber Reloaded - Unknown Player", @@ -32,7 +33,8 @@ type Props = { type PlayerData = { player: ScoreSaberPlayer | undefined; scores: PlayerScoresResponse | undefined; - sort: ScoreSort; + sort: ScoreSortType; + direction: SortDirection; page: number; search: string; }; @@ -51,9 +53,11 @@ const playerCache = new SSRCache({ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Promise => { const { slug } = await params; const id = slug[0]; // The players id - const sort: ScoreSort = (slug[1] as ScoreSort) || (await getCookieValue("lastScoreSort", "recent")); // The sorting method - const page = parseInt(slug[2]) || 1; // The page number - const search = (slug[3] as string) || ""; // The search query + const sort: ScoreSortType = (slug[1] as ScoreSortType) || (await getCookieValue("lastScoreSort", ScoreSortType.date)); // The sorting method + const direction: SortDirection = + (slug[2] as SortDirection) || (await getCookieValue("lastScoreSortDirection", SortDirection.DESC)); // The sorting direction + const page = parseInt(slug[3]) || 1; // The page number + const search = (slug[4] as string) || ""; // The search query const cacheId = `${id}-${sort}-${page}-${search}-${fetchScores}`; if (playerCache.has(cacheId)) { @@ -64,11 +68,19 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId"))); let scores: PlayerScoresResponse | undefined; if (fetchScores) { - scores = await fetchPlayerScores("scoresaber", id, page, sort, search); + scores = await fetchPlayerScores( + "scoresaber", + id, + page, + sort, + direction, + search + ); } const playerData = { sort: sort, + direction: direction, page: page, search: search, player: player, @@ -123,14 +135,21 @@ export async function generateViewport(props: Props): Promise { } export default async function PlayerPage(props: Props) { - const { player, scores, sort, page, search } = await getPlayerData(props); + const { player, scores, sort, direction, page, search } = await getPlayerData(props); if (player == undefined) { return redirect("/"); } return (
- +
); } diff --git a/projects/website/src/components/player/player-data.tsx b/projects/website/src/components/player/player-data.tsx index 7ac6763..b1dd4cf 100644 --- a/projects/website/src/components/player/player-data.tsx +++ b/projects/website/src/components/player/player-data.tsx @@ -19,16 +19,26 @@ import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators"; +import { ScoreSortType } from "@ssr/common/sorter/sort-type"; +import { SortDirection } from "@ssr/common/sorter/sort-direction"; type Props = { initialPlayerData: ScoreSaberPlayer; initialScoreData?: PlayerScoresResponse; initialSearch?: string; - sort: ScoreSort; + sort: ScoreSortType; + direction: SortDirection; page: number; }; -export default function PlayerData({ initialPlayerData, initialScoreData, initialSearch, sort, page }: Props) { +export default function PlayerData({ + initialPlayerData, + initialScoreData, + initialSearch, + sort, + direction, + page, +}: Props) { const isMobile = useIsMobile(); const miniRankingsRef = useRef(null); const isMiniRankingsVisible = useIsVisible(miniRankingsRef); @@ -65,6 +75,7 @@ export default function PlayerData({ initialPlayerData, initialScoreData, initia initialSearch={initialSearch} player={player} sort={sort} + direction={direction} page={page} /> diff --git a/projects/website/src/components/player/player-scores.tsx b/projects/website/src/components/player/player-scores.tsx index 26ade96..60624a4 100644 --- a/projects/website/src/components/player/player-scores.tsx +++ b/projects/website/src/components/player/player-scores.tsx @@ -1,6 +1,6 @@ import { capitalizeFirstLetter } from "@/common/string-utils"; import useWindowDimensions from "@/hooks/use-window-dimensions"; -import { ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid"; +import { ArrowDownIcon, ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid"; import { useQuery } from "@tanstack/react-query"; import { motion, useAnimation } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -13,44 +13,55 @@ import { clsx } from "clsx"; import { useDebounce } from "@uidotdev/usehooks"; import { scoreAnimation } from "@/components/score/score-animation"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player"; -import { ScoreSort } from "@ssr/common/score/score-sort"; import { setCookieValue } from "@ssr/common/utils/cookie-utils"; import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { fetchPlayerScores } from "@ssr/common/utils/score-utils"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; +import { SortDirection } from "@ssr/common/sorter/sort-direction"; +import { ScoreSortType } from "@ssr/common/sorter/sort-type"; type Props = { initialScoreData?: PlayerScoresResponse; initialSearch?: string; player: ScoreSaberPlayer; - sort: ScoreSort; + sort: ScoreSortType; + direction: SortDirection; page: number; }; type PageState = { page: number; - sort: ScoreSort; + sort: ScoreSortType; + direction: SortDirection; }; const scoreSort = [ { - name: "Top", - value: ScoreSort.top, - icon: , + name: "PP", + value: ScoreSortType.pp, }, { - name: "Recent", - value: ScoreSort.recent, - icon: , + name: "Date", + value: ScoreSortType.date, + }, + { + name: "Acc", + value: ScoreSortType.accuracy, + requiresTrackedPlayer: true, + }, + { + name: "Misses", + value: ScoreSortType.misses, + requiresTrackedPlayer: true, }, ]; -export default function PlayerScores({ initialScoreData, initialSearch, player, sort, page }: Props) { +export default function PlayerScores({ initialScoreData, initialSearch, player, sort, direction, page }: Props) { const { width } = useWindowDimensions(); const controls = useAnimation(); - const [pageState, setPageState] = useState({ page, sort }); + const [pageState, setPageState] = useState({ page, sort, direction }); const [previousPage, setPreviousPage] = useState(page); const [scores, setScores] = useState | undefined>( initialScoreData @@ -69,6 +80,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, player.id, pageState.page, pageState.sort, + pageState.direction, debouncedSearchTerm ), enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0), @@ -88,14 +100,30 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, * * @param newSort the new sort */ - const handleSortChange = async (newSort: ScoreSort) => { + const handleSortChange = async (newSort: ScoreSortType) => { if (newSort !== pageState.sort) { - setPageState({ page: 1, sort: newSort }); + setPageState({ ...pageState, page: 1, sort: newSort, direction: SortDirection.DESC }); setShouldFetch(true); // Set to true to trigger fetch await setCookieValue("lastScoreSort", newSort); // Set the default score sort } }; + /** + * Change the score sort direction. + * + * @param newDirection the new sort direction + */ + const handleSortDirectionChange = async (newDirection: SortDirection) => { + // Player doesn't have scores tracked anyway, so no need to change this. + if (!player.isBeingTracked) { + return; + } + + setPageState({ ...pageState, page: 1, direction: newDirection }); + setShouldFetch(true); // Set to true to trigger fetch + await setCookieValue("lastScoreSortDirection", newDirection); // Set the default score sort + }; + /** * Change the score search term. * @@ -131,9 +159,9 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, */ const getUrl = useCallback( (page: number) => { - return `/player/${player.id}/${pageState.sort}/${page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`; + return `/player/${player.id}/${pageState.sort}/${pageState.direction}/${page}${isSearchActive ? `?search=${debouncedSearchTerm}` : ""}`; }, - [debouncedSearchTerm, player.id, pageState.sort, isSearchActive] + [player.id, pageState.sort, pageState.direction, isSearchActive, debouncedSearchTerm] ); /** @@ -167,18 +195,32 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
- {scoreSort.map(sortOption => ( - - ))} + {scoreSort + .filter(sort => !(!player.isBeingTracked && sort.requiresTrackedPlayer)) + .map(sortOption => ( + + ))}
@@ -223,7 +265,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player, ))} - {scores.metadata.totalPages > 1 && ( + {scores.metadata.totalPages >= 1 && (