diff --git a/projects/backend/src/bot/bot.ts b/projects/backend/src/bot/bot.ts index f51e645..7f63a9f 100644 --- a/projects/backend/src/bot/bot.ts +++ b/projects/backend/src/bot/bot.ts @@ -54,13 +54,17 @@ export async function initDiscordBot() { * @param message the message to log */ export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) { - const channel = await client.channels.fetch(channelId); - if (channel == undefined) { - throw new Error(`Channel "${channelId}" not found`); - } - if (!channel.isSendable()) { - throw new Error(`Channel "${channelId}" is not sendable`); - } + try { + const channel = await client.channels.fetch(channelId); + if (channel == undefined) { + throw new Error(`Channel "${channelId}" not found`); + } + if (!channel.isSendable()) { + throw new Error(`Channel "${channelId}" is not sendable`); + } - channel.send({ embeds: [message] }); + channel.send({ embeds: [message] }); + } catch (error) { + console.error(error); + } } diff --git a/projects/backend/src/controller/scores.controller.ts b/projects/backend/src/controller/scores.controller.ts index 59f47f2..8c59e7e 100644 --- a/projects/backend/src/controller/scores.controller.ts +++ b/projects/backend/src/controller/scores.controller.ts @@ -52,4 +52,25 @@ export default class ScoresController { }): Promise { return await ScoreService.getLeaderboardScores(leaderboard, id, page); } + + @Get("/history/:playerId/:leaderboardId/:page", { + config: {}, + params: t.Object({ + playerId: t.String({ required: true }), + leaderboardId: t.String({ required: true }), + page: t.Number({ required: true }), + }), + }) + public async getScoreHistory({ + params: { playerId, leaderboardId, page }, + }: { + params: { + playerId: string; + leaderboardId: string; + page: number; + }; + query: { search?: string }; + }): Promise { + return (await ScoreService.getPreviousScores(playerId, leaderboardId, page)).toJSON(); + } } diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 2f22bae..e117fa5 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -27,12 +27,13 @@ import { import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement"; import { 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, ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import { MapDifficulty } from "@ssr/common/score/map-difficulty"; import { MapCharacteristic } from "@ssr/common/types/map-characteristic"; +import { Page, Pagination } from "@ssr/common/pagination"; const playerScoresCache = new SSRCache({ ttl: 1000 * 60, // 1 minute @@ -487,4 +488,59 @@ export class ScoreService { } ); } + + /** + * Gets the previous scores for a player. + * + * @param playerId the player's id to get the previous scores for + * @param leaderboardId the leaderboard to get the previous scores on + * @param page the page to get + */ + public static async getPreviousScores( + playerId: string, + leaderboardId: string, + page: number + ): Promise>> { + const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId }) + .sort({ timestamp: -1 }) + .skip(1); + if (scores == null || scores.length == 0) { + throw new NotFoundError(`No previous scores found for ${playerId} in ${leaderboardId}`); + } + + return new Pagination>() + .setItemsPerPage(8) + .setTotalItems(scores.length) + .getPage(page, async () => { + const toReturn: PlayerScore[] = []; + for (const score of scores) { + const leaderboardResponse = await LeaderboardService.getLeaderboard( + "scoresaber", + leaderboardId + ); + if (leaderboardResponse == undefined) { + throw new NotFoundError(`Leaderboard "${leaderboardId}" not found`); + } + const { leaderboard, beatsaver } = leaderboardResponse; + + 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 as unknown as ScoreSaberScore, + leaderboard: leaderboard, + beatSaver: beatsaver, + }); + } + + return toReturn; + }); + } } diff --git a/projects/common/src/model/score/score.ts b/projects/common/src/model/score/score.ts index d212282..bb3f623 100644 --- a/projects/common/src/model/score/score.ts +++ b/projects/common/src/model/score/score.ts @@ -12,7 +12,7 @@ export default class Score { * The internal score id. */ @prop() - private _id?: number; + public _id?: number; /** * The id of the player who set the score. diff --git a/projects/common/src/pagination.ts b/projects/common/src/pagination.ts new file mode 100644 index 0000000..014ce36 --- /dev/null +++ b/projects/common/src/pagination.ts @@ -0,0 +1,103 @@ +import { NotFoundError } from "backend/src/error/not-found-error"; +import { Metadata } from "./types/metadata"; + +type FetchItemsFunction = (fetchItems: FetchItems) => Promise; + +export class Pagination { + private itemsPerPage: number = 0; + private totalItems: number = 0; + private items: T[] | null = null; // Optional array to hold set items + + /** + * 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; + } + + /** + * Sets the total number of items. + * @param totalItems - Total number of items. + * @returns the pagination + */ + setTotalItems(totalItems: number): Pagination { + this.totalItems = totalItems; + return this; + } + + /** + * Gets a page of items, using either static items or a dynamic fetchItems callback. + * @param page - The page number to retrieve. + * @param fetchItems - The async function to fetch items if setItems was not used. + * @returns A promise resolving to the page of items. + * @throws throws an error if the page number is invalid. + */ + async getPage(page: number, fetchItems?: FetchItemsFunction): Promise> { + const totalPages = Math.ceil(this.totalItems / this.itemsPerPage); + + if (page < 1 || page > totalPages) { + throw new NotFoundError("Invalid page number"); + } + + // Calculate the range of items to fetch for the current page + const start = (page - 1) * this.itemsPerPage; + const end = start + this.itemsPerPage; + + let pageItems: T[]; + + // Use set items if they are present, otherwise use fetchItems callback + if (this.items) { + pageItems = this.items.slice(start, end); + } else if (fetchItems) { + pageItems = await fetchItems(new FetchItems(start, end)); + } else { + throw new Error("Items function is not set and no fetchItems callback provided"); + } + + return new Page(pageItems, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage)); + } +} + +class FetchItems { + readonly start: number; + readonly end: number; + + constructor(start: number, end: number) { + this.start = start; + this.end = end; + } +} + +export class Page { + readonly items: T[]; + readonly metadata: Metadata; + + constructor(items: T[], metadata: Metadata) { + this.items = items; + this.metadata = metadata; + } + + /** + * Converts the page to a JSON object. + */ + toJSON() { + return { + items: this.items, + metadata: this.metadata, + }; + } +} diff --git a/projects/common/src/utils/score-utils.ts b/projects/common/src/utils/score-utils.ts index a5b351b..7059082 100644 --- a/projects/common/src/utils/score-utils.ts +++ b/projects/common/src/utils/score-utils.ts @@ -4,6 +4,23 @@ 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 { Page } from "../pagination"; +import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score"; +import { PlayerScore } from "../score/player-score"; +import ScoreSaberLeaderboard from "../leaderboard/impl/scoresaber-leaderboard"; + +/** + * Fetches the player's scores + * + * @param playerId the id of the player + * @param leaderboardId the id of the leaderboard + * @param page the page + */ +export async function fetchPlayerScoresHistory(playerId: string, leaderboardId: string, page: number) { + return kyFetch>>( + `${Config.apiUrl}/scores/history/${playerId}/${leaderboardId}/${page}` + ); +} /** * Fetches the player's scores diff --git a/projects/website/src/components/score/score-buttons.tsx b/projects/website/src/components/score/score-buttons.tsx index fbdb420..4d6d470 100644 --- a/projects/website/src/components/score/score-buttons.tsx +++ b/projects/website/src/components/score/score-buttons.tsx @@ -21,6 +21,8 @@ type Props = { leaderboard: ScoreSaberLeaderboard; beatSaverMap?: BeatSaverMap; alwaysSingleLine?: boolean; + hideLeaderboardDropdown?: boolean; + hideAccuracyChanger?: boolean; isLeaderboardLoading?: boolean; setIsLeaderboardExpanded?: (isExpanded: boolean) => void; updateScore?: (score: ScoreSaberScore) => void; @@ -31,6 +33,8 @@ export default function ScoreButtons({ leaderboard, beatSaverMap, alwaysSingleLine, + hideLeaderboardDropdown, + hideAccuracyChanger, isLeaderboardLoading, setIsLeaderboardExpanded, updateScore, @@ -103,12 +107,12 @@ export default function ScoreButtons({ className={`flex gap-2 ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`} > {/* Edit score button */} - {score && leaderboard && updateScore && ( + {score && leaderboard && updateScore && !hideAccuracyChanger && ( )} {/* View Leaderboard button */} - {leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && ( + {leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && !hideLeaderboardDropdown && (
{isLeaderboardLoading ? ( diff --git a/projects/website/src/components/score/score-views/score-history.tsx b/projects/website/src/components/score/score-views/score-history.tsx new file mode 100644 index 0000000..45cc4f8 --- /dev/null +++ b/projects/website/src/components/score/score-views/score-history.tsx @@ -0,0 +1,63 @@ +"use client"; + +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import Score from "@/components/score/score"; +import { fetchPlayerScoresHistory } from "@ssr/common/utils/score-utils"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import Pagination from "@/components/input/pagination"; +import { useIsMobile } from "@/hooks/use-is-mobile"; + +type ScoreHistoryProps = { + /** + * The player who set this score. + */ + playerId: string; + + /** + * The leaderboard the score was set on. + */ + leaderboard: ScoreSaberLeaderboard; +}; + +export function ScoreHistory({ playerId, leaderboard }: ScoreHistoryProps) { + const isMobile = useIsMobile(); + const [page, setPage] = useState(1); + + const { data, isError, isLoading } = useQuery({ + queryKey: [`scoresHistory:${leaderboard.id}`, leaderboard.id, page], + queryFn: async () => fetchPlayerScoresHistory(playerId, leaderboard.id + "", page), + staleTime: 30 * 1000, + }); + + if (!data || isError) { + return

No score history found.

; + } + + return ( + <> + {data.items.map(({ score, leaderboard, beatSaver }) => ( + + ))} + + { + setPage(newPage); + }} + /> + + ); +} diff --git a/projects/website/src/components/score/score-views/score-overview.tsx b/projects/website/src/components/score/score-views/score-overview.tsx new file mode 100644 index 0000000..41a6480 --- /dev/null +++ b/projects/website/src/components/score/score-views/score-overview.tsx @@ -0,0 +1,47 @@ +import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart"; +import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; +import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats"; +import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; +import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; +import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; + +type ScoreOverviewProps = { + /** + * The initial page to show + */ + initialPage: number; + + /** + * The score stats for this score. + */ + scoreStats?: ScoreStatsToken; + + /** + * The leaderboard the score was set on. + */ + leaderboard: ScoreSaberLeaderboard; + + /** + * The scores so show. + */ + scores?: LeaderboardScoresResponse; +}; + +export function ScoreOverview({ scoreStats, initialPage, leaderboard, scores }: ScoreOverviewProps) { + return ( + <> + {scoreStats && ( +
+ +
+ )} + + + + ); +} diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index 2d3ed6b..7d6b4ba 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -1,104 +1,104 @@ "use client"; -import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { useQuery } from "@tanstack/react-query"; +import { CubeIcon } from "@heroicons/react/24/solid"; +import { GitGraph } from "lucide-react"; import ScoreButtons from "./score-buttons"; import ScoreSongInfo from "./score-song-info"; import ScoreRankInfo from "./score-rank-info"; import ScoreStats from "./score-stats"; -import { motion } from "framer-motion"; +import Card from "@/components/card"; +import { MapStats } from "@/components/score/map-stats"; +import { Button } from "@/components/ui/button"; +import { ScoreOverview } from "@/components/score/score-views/score-overview"; +import { ScoreHistory } from "@/components/score/score-views/score-history"; + import { getPageFromRank } from "@ssr/common/utils/utils"; +import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import { beatLeaderService } from "@ssr/common/service/impl/beatleader"; +import { useIsMobile } from "@/hooks/use-is-mobile"; + import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/map"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import Card from "@/components/card"; -import { MapStats } from "@/components/score/map-stats"; -import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart"; -import { useQuery } from "@tanstack/react-query"; -import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats"; -import { beatLeaderService } from "@ssr/common/service/impl/beatleader"; type Props = { - /** - * The score to display. - */ score: ScoreSaberScore; - - /** - * The leaderboard. - */ leaderboard: ScoreSaberLeaderboard; - - /** - * The beat saver map for this song. - */ beatSaverMap?: BeatSaverMap; - - /** - * Score settings - */ settings?: { - noScoreButtons: boolean; + noScoreButtons?: boolean; + hideLeaderboardDropdown?: boolean; + hideAccuracyChanger?: boolean; }; }; type LeaderboardDropdownData = { - /** - * The initial scores. - */ scores?: LeaderboardScoresResponse; - - /** - * The score stats for this score, - */ scoreStats?: ScoreStatsToken; }; -export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) { - const scoresPage = getPageFromRank(score.rank, 12); +type Mode = { + name: string; + icon: React.ReactElement; +}; - const isMobile = useIsMobile(); - const [baseScore, setBaseScore] = useState(score.score); +const modes: Mode[] = [ + { name: "Overview", icon: }, + { name: "Score History", icon: }, +]; + +export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) { + const [baseScore, setBaseScore] = useState(score.score); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const [loading, setLoading] = useState(false); const [leaderboardDropdownData, setLeaderboardDropdownData] = useState(); + const [selectedMode, setSelectedMode] = useState(modes[0]); - const { data, isError, isLoading } = useQuery({ - queryKey: ["leaderboardDropdownData", leaderboard.id, score.scoreId, isLeaderboardExpanded], + const scoresPage = getPageFromRank(score.rank, 12); + const isMobile = useIsMobile(); + + const { data, isLoading } = useQuery({ + queryKey: [`leaderboardDropdownData:${leaderboard.id}`, leaderboard.id, score.scoreId, isLeaderboardExpanded], queryFn: async () => { const scores = await fetchLeaderboardScores( "scoresaber", - leaderboard.id + "", + leaderboard.id.toString(), scoresPage ); const scoreStats = score.additionalData ? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId) : undefined; - - return { - scores: scores, - scoreStats: scoreStats, - }; + return { scores, scoreStats }; }, - staleTime: 30 * 1000, + staleTime: 30000, enabled: loading, }); useEffect(() => { if (data) { - setLeaderboardDropdownData({ - ...data, - scores: data.scores, - scoreStats: data.scoreStats, - }); + setLeaderboardDropdownData(data); setLoading(false); } }, [data]); + useEffect(() => { + setIsLeaderboardExpanded(false); + setLeaderboardDropdownData(undefined); + }, [score.scoreId]); + + useEffect(() => { + setBaseScore(score.score); + }, [score]); + + const accuracy = (baseScore / leaderboard.maxScore) * 100; + const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); + const handleLeaderboardOpen = (isExpanded: boolean) => { if (!isExpanded) { setLeaderboardDropdownData(undefined); @@ -108,63 +108,35 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr setIsLeaderboardExpanded(isExpanded); }; - /** - * Set the base score - */ - useEffect(() => { - if (score?.score) { - setBaseScore(score.score); - } - }, [score]); + const handleModeChange = (mode: Mode) => { + setSelectedMode(mode); + }; - /** - * Close the leaderboard when the score changes - */ - useEffect(() => { - setIsLeaderboardExpanded(false); - setLeaderboardDropdownData(undefined); - }, [score.scoreId]); - - const accuracy = (baseScore / leaderboard.maxScore) * 100; - const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); - - // Dynamic grid column classes const gridColsClass = settings?.noScoreButtons - ? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" // Fewer columns if no buttons - : "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; // Original with buttons + ? "grid-cols-[20px_1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" + : "grid-cols-[20px_1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; return (
- {/* Score Info */}
- {settings?.noScoreButtons !== true && ( + {!settings?.noScoreButtons && ( { - handleLeaderboardOpen(isExpanded); - }} + hideLeaderboardDropdown={settings?.hideLeaderboardDropdown} + hideAccuracyChanger={settings?.hideAccuracyChanger} + setIsLeaderboardExpanded={handleLeaderboardOpen} isLeaderboardLoading={isLoading} - updateScore={score => { - setBaseScore(score.score); - }} + updateScore={updatedScore => setBaseScore(updatedScore.score)} /> )} - +
- {/* Leaderboard */} {isLeaderboardExpanded && leaderboardDropdownData && !loading && ( - - - {leaderboardDropdownData.scoreStats && ( -
- +
+
+ {modes.map((mode, i) => ( + + ))}
+ +
+ +
+
+ + {selectedMode.name === "Overview" && ( + )} - + {selectedMode.name === "Score History" && ( + + )} )}