diff --git a/bun.lockb b/bun.lockb index e9d5616..49524e8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/backend/src/controller/scores.controller.ts b/projects/backend/src/controller/scores.controller.ts index 9a332e1..eb42a17 100644 --- a/projects/backend/src/controller/scores.controller.ts +++ b/projects/backend/src/controller/scores.controller.ts @@ -3,6 +3,7 @@ import { t } from "elysia"; import { Leaderboards } from "@ssr/common/leaderboard"; import { TopScoresResponse } from "@ssr/common/response/top-scores-response"; import { ScoreService } from "../service/score.service"; +import { Timeframe } from "@ssr/common/timeframe"; @Controller("/scores") export default class ScoresController { @@ -77,11 +78,30 @@ export default class ScoresController { @Get("/top", { config: {}, + query: t.Object({ + limit: t.Number({ required: true }), + timeframe: t.String({ required: true }), + }), }) - public async getTopScores(): Promise { - const scores = await ScoreService.getTopScores(); + public async getTopScores({ + query: { limit, timeframe }, + }: { + query: { limit: number; timeframe: Timeframe }; + }): Promise { + if (limit <= 0) { + limit = 1; + } else if (limit > 100) { + limit = 100; + } + if ((timeframe.toLowerCase() as keyof Timeframe) === undefined) { + timeframe = "all"; + } + + const scores = await ScoreService.getTopScores(limit, timeframe); return { scores, + timeframe, + limit, }; } } diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index d6c2be5..0235ebc 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -38,6 +38,9 @@ import { MapCharacteristic } from "@ssr/common/types/map-characteristic"; import { Page, Pagination } from "@ssr/common/pagination"; import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard"; import Leaderboard from "@ssr/common/model/leaderboard/leaderboard"; +import { Timeframe } from "@ssr/common/timeframe"; +import { getDaysAgoDate } from "@ssr/common/utils/time-utils"; +import { PlayerService } from "./player.service"; const playerScoresCache = new SSRCache({ ttl: 1000 * 60, // 1 minute @@ -305,97 +308,67 @@ export class ScoreService { * Gets the top tracked scores. * * @param amount the amount of scores to get + * @param timeframe the timeframe to filter by * @returns the top scores */ - public static async getTopScores(amount: number = 100) { + public static async getTopScores(amount: number = 100, timeframe: Timeframe) { + console.log(`Getting top scores for timeframe: ${timeframe}, limit: ${amount}...`); + const before = Date.now(); + + let daysAgo = -1; + if (timeframe === "daily") { + daysAgo = 1; + } else if (timeframe === "weekly") { + daysAgo = 8; + } else if (timeframe === "monthly") { + daysAgo = 31; + } + const date: Date = daysAgo == -1 ? new Date(0) : getDaysAgoDate(daysAgo); const foundScores = await ScoreSaberScoreModel.aggregate([ - // Start sorting by timestamp descending using the new compound index - { $sort: { leaderboardId: 1, playerId: 1, timestamp: -1 } }, + { $match: { timestamp: { $gte: date } } }, + { $sort: { timestamp: -1 } }, { $group: { _id: { leaderboardId: "$leaderboardId", playerId: "$playerId" }, - latestScore: { $first: "$$ROOT" }, // Retrieve the latest score per group + score: { $first: "$$ROOT" }, }, }, - // Sort by pp of the latest scores in descending order - { $sort: { "latestScore.pp": -1 } }, + { $sort: { "score.pp": -1 } }, { $limit: amount }, ]); - // Collect unique leaderboard IDs - const leaderboardIds = [...new Set(foundScores.map(s => s.latestScore.leaderboardId))]; - const leaderboardMap = await this.fetchLeaderboardsInBatch(leaderboardIds); - - // Collect player IDs for batch retrieval - const playerIds = foundScores.map(result => result.latestScore.playerId); - const players = await PlayerModel.find({ _id: { $in: playerIds } }).exec(); - const playerMap = new Map(players.map(player => [player._id.toString(), player])); - - // Prepare to fetch additional data concurrently - const scoreDataPromises = foundScores.map(async result => { - const score: ScoreSaberScore = result.latestScore; - const leaderboardResponse = leaderboardMap[score.leaderboardId]; - if (!leaderboardResponse) { - return null; // Skip if leaderboard data is not available + const scores: PlayerScore[] = []; + for (const { score: scoreData } of foundScores) { + const score = new ScoreSaberScoreModel(scoreData).toObject() as ScoreSaberScore; + const leaderboard = await LeaderboardService.getLeaderboard( + "scoresaber", + score.leaderboardId + "" + ); + if (!leaderboard) { + continue; } - - const { leaderboard, beatsaver } = leaderboardResponse; - - // Fetch additional data concurrently - const [additionalData, previousScore] = await Promise.all([ - this.getAdditionalScoreData( - score.playerId, - leaderboard.songHash, - `${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`, - score.score - ), - this.getPreviousScore(score.playerId, leaderboard.id + "", score.timestamp), - ]); - - // Attach additional and previous score data if available - if (additionalData) score.additionalData = additionalData; - if (previousScore) score.previousScore = previousScore; - - // Attach player info if available - const player = playerMap.get(score.playerId.toString()); - if (player) { + try { + const player = await PlayerService.getPlayer(score.playerId); + if (player !== undefined) { + score.playerInfo = { + id: player.id, + name: player.name, + }; + } + } catch { score.playerInfo = { - id: player._id, - name: player.name, + id: score.playerId, }; } - return { - score: score as ScoreSaberScore, - leaderboard: leaderboard, - beatSaver: beatsaver, - }; - }); - return (await Promise.all(scoreDataPromises)).filter(score => score !== null); - } - - /** - * Fetches leaderboards in a batch. - * - * @param leaderboardIds the ids of the leaderboards - * @returns the fetched leaderboards - * @private - */ - private static async fetchLeaderboardsInBatch(leaderboardIds: string[]) { - // Remove duplicates from leaderboardIds - const uniqueLeaderboardIds = Array.from(new Set(leaderboardIds)); - - const leaderboardResponses = await Promise.all( - uniqueLeaderboardIds.map(id => LeaderboardService.getLeaderboard("scoresaber", id)) - ); - - return leaderboardResponses.reduce( - (map, response) => { - if (response) map[response.leaderboard.id] = response; - return map; - }, - {} as Record - ); + scores.push({ + score: score, + leaderboard: leaderboard.leaderboard, + beatSaver: leaderboard.beatsaver, + }); + } + console.log(`Got ${scores.length} scores in ${Date.now() - before}ms (timeframe: ${timeframe}, limit: ${amount})`); + return scores; } /** diff --git a/projects/common/src/model/score/impl/scoresaber-score.ts b/projects/common/src/model/score/impl/scoresaber-score.ts index 06920be..5aa88b7 100644 --- a/projects/common/src/model/score/impl/scoresaber-score.ts +++ b/projects/common/src/model/score/impl/scoresaber-score.ts @@ -1,4 +1,4 @@ -import { getModelForClass, index, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose"; +import { getModelForClass, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose"; import Score from "../score"; import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token"; import { Document } from "mongoose"; @@ -20,7 +20,6 @@ import { PreviousScore } from "../previous-score"; }, }, }) -@index({ leaderboardId: 1, playerId: 1, timestamp: -1 }) // Compound index for optimized queries @plugin(AutoIncrementID, { field: "_id", startAt: 1, diff --git a/projects/common/src/player/player-stat-change.ts b/projects/common/src/player/player-stat-change.ts index 13d89a6..e771d7c 100644 --- a/projects/common/src/player/player-stat-change.ts +++ b/projects/common/src/player/player-stat-change.ts @@ -1,5 +1,5 @@ import ScoreSaberPlayer from "./impl/scoresaber-player"; -import { ChangeRange } from "./player"; +import { Timeframe } from "../timeframe"; export type PlayerStatValue = { /** @@ -10,7 +10,7 @@ export type PlayerStatValue = { /** * The value of the stat. */ - value: (player: ScoreSaberPlayer, range: ChangeRange) => number | undefined; + value: (player: ScoreSaberPlayer, range: Timeframe) => number | undefined; }; export type PlayerStatChangeType = diff --git a/projects/common/src/player/player.ts b/projects/common/src/player/player.ts index 41532e4..3eee493 100644 --- a/projects/common/src/player/player.ts +++ b/projects/common/src/player/player.ts @@ -1,4 +1,5 @@ import { PlayerHistory } from "./player-history"; +import { Timeframe } from "../timeframe"; export default class Player { /** @@ -55,7 +56,6 @@ export default class Player { } } -export type ChangeRange = "daily" | "weekly" | "monthly"; export type StatisticChange = { - [key in ChangeRange]: PlayerHistory; + [key in Timeframe]: PlayerHistory; }; diff --git a/projects/common/src/response/top-scores-response.ts b/projects/common/src/response/top-scores-response.ts index c42e798..761a8b5 100644 --- a/projects/common/src/response/top-scores-response.ts +++ b/projects/common/src/response/top-scores-response.ts @@ -1,10 +1,21 @@ import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard"; import { ScoreSaberScore } from "../model/score/impl/scoresaber-score"; import { PlayerScore } from "../score/player-score"; +import { Timeframe } from "../timeframe"; export type TopScoresResponse = { /** * The top scores. */ scores: PlayerScore[]; + + /** + * The timeframe returned. + */ + timeframe: Timeframe; + + /** + * The amount of scores to fetch. + */ + limit: number; }; diff --git a/projects/common/src/timeframe.ts b/projects/common/src/timeframe.ts new file mode 100644 index 0000000..d456ce3 --- /dev/null +++ b/projects/common/src/timeframe.ts @@ -0,0 +1 @@ +export type Timeframe = "daily" | "weekly" | "monthly" | "all"; diff --git a/projects/website/src/app/(pages)/scores/top/[timeframe]/page.tsx b/projects/website/src/app/(pages)/scores/top/[timeframe]/page.tsx new file mode 100644 index 0000000..95320ff --- /dev/null +++ b/projects/website/src/app/(pages)/scores/top/[timeframe]/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from "next"; +import { Timeframe } from "@ssr/common/timeframe"; +import { TopScoresData } from "@/components/score/top/top-scores-data"; + +export const metadata: Metadata = { + title: "Top Scores", + openGraph: { + title: "ScoreSaber Reloaded - Top Scores", + description: "View the top 50 scores set by players on ScoreSaber.", + }, +}; + +type TopScoresPageProps = { + params: Promise<{ + timeframe: Timeframe; + }>; +}; + +export default async function TopScoresPage({ params }: TopScoresPageProps) { + const { timeframe } = await params; + + return ; +} diff --git a/projects/website/src/app/(pages)/scores/top/page.tsx b/projects/website/src/app/(pages)/scores/top/page.tsx deleted file mode 100644 index 63f4de2..0000000 --- a/projects/website/src/app/(pages)/scores/top/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Metadata } from "next"; -import Card from "@/components/card"; -import { kyFetch } from "@ssr/common/utils/utils"; -import { Config } from "@ssr/common/config"; -import { TopScoresResponse } from "@ssr/common/response/top-scores-response"; -import Score from "@/components/score/score"; -import Link from "next/link"; - -export const metadata: Metadata = { - title: "Top Scores", - openGraph: { - title: "ScoreSaber Reloaded - Top Scores", - description: "View the top 100 scores set by players on ScoreSaber.", - }, -}; - -export default async function TopScoresPage() { - const scores = await kyFetch(`${Config.apiUrl}/scores/top`); - - return ( - -
-

Top 100 ScoreSaber Scores

-

This will only show scores that have been tracked.

-
- - {!scores ? ( -

No scores found

- ) : ( -
- {scores.scores.map(({ score, leaderboard, beatSaver }, index) => { - const player = score.playerInfo; - const name = score.playerInfo ? player.name || player.id : score.playerId; - - return ( -
-

- Set by{" "} - - {name} - -

- -
- ); - })} -
- )} -
- ); -} diff --git a/projects/website/src/components/footer.tsx b/projects/website/src/components/footer.tsx index 0cb1a90..53a09bb 100644 --- a/projects/website/src/components/footer.tsx +++ b/projects/website/src/components/footer.tsx @@ -44,7 +44,7 @@ const items: NavbarItem[] = [ }, { name: "Top Scores", - link: "/scores/top", + link: "/scores/top/weekly", openInNewTab: false, }, ]; diff --git a/projects/website/src/components/loading-icon.tsx b/projects/website/src/components/loading-icon.tsx new file mode 100644 index 0000000..31eec36 --- /dev/null +++ b/projects/website/src/components/loading-icon.tsx @@ -0,0 +1,6 @@ +import { ArrowPathIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; + +export function LoadingIcon() { + return ; +} diff --git a/projects/website/src/components/score/top/top-scores-data.tsx b/projects/website/src/components/score/top/top-scores-data.tsx new file mode 100644 index 0000000..82c512b --- /dev/null +++ b/projects/website/src/components/score/top/top-scores-data.tsx @@ -0,0 +1,125 @@ +"use client"; + +import Card from "@/components/card"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import Score from "@/components/score/score"; +import { useEffect, useState } from "react"; +import { Timeframe } from "@ssr/common/timeframe"; +import { TopScoresResponse } from "@ssr/common/response/top-scores-response"; +import { Config } from "@ssr/common/config"; +import { kyFetch } from "@ssr/common/utils/utils"; +import { useQuery } from "@tanstack/react-query"; +import { LoadingIcon } from "@/components/loading-icon"; +import { capitalizeFirstLetter } from "@/common/string-utils"; + +type TimeframesType = { + timeframe: Timeframe; + display: string; +}; +const timeframes: TimeframesType[] = [ + { + timeframe: "daily", + display: "Today", + }, + { + timeframe: "weekly", + display: "This Week", + }, + { + timeframe: "monthly", + display: "This Month", + }, + { + timeframe: "all", + display: "All Time", + }, +]; + +type TopScoresDataProps = { + timeframe: Timeframe; +}; + +export function TopScoresData({ timeframe }: TopScoresDataProps) { + const [selectedTimeframe, setSelectedTimeframe] = useState(timeframe); + const [scores, setScores] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ["top-scores", selectedTimeframe], + queryFn: async () => { + return kyFetch(`${Config.apiUrl}/scores/top?limit=50&timeframe=${selectedTimeframe}`); + }, + }); + + useEffect(() => { + // Update the URL + window.history.replaceState(null, "", `/scores/top/${selectedTimeframe}`); + }, [selectedTimeframe]); + + useEffect(() => { + if (data) { + setScores(data); + } + }, [data]); + + return ( + +
+ {timeframes.map((timeframe, index) => { + return ( + + ); + })} +
+ +
+

Top 50 ScoreSaber Scores ({capitalizeFirstLetter(selectedTimeframe)})

+

This will only show scores that have been tracked.

+
+ + {(isLoading || !scores) && ( +
+ +
+ )} + {scores && !isLoading && ( +
+ {scores.scores.map(({ score, leaderboard, beatSaver }, index) => { + const player = score.playerInfo; + const name = score.playerInfo ? player.name || player.id : score.playerId; + + return ( +
+

+ Set by{" "} + + {name} + +

+ +
+ ); + })} +
+ )} +
+ ); +} diff --git a/projects/website/src/components/statistic/change-over-time.tsx b/projects/website/src/components/statistic/change-over-time.tsx index 3a0fc0c..9981a26 100644 --- a/projects/website/src/components/statistic/change-over-time.tsx +++ b/projects/website/src/components/statistic/change-over-time.tsx @@ -3,8 +3,8 @@ import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils import { capitalizeFirstLetter } from "@/common/string-utils"; import Tooltip from "@/components/tooltip"; import { ReactElement } from "react"; -import { ChangeRange } from "@ssr/common/player/player"; import { PlayerStatValue } from "@ssr/common/player/player-stat-change"; +import { Timeframe } from "@ssr/common/timeframe"; type ChangeOverTimeProps = { /** @@ -40,7 +40,7 @@ export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps) }; // Renders the change for a given time frame - const renderChange = (value: number | undefined, range: ChangeRange) => ( + const renderChange = (value: number | undefined, range: Timeframe) => (

{capitalizeFirstLetter(range)} Change:{" "} = 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}>