Compare commits
5 Commits
3a2a876f74
...
b68de0552f
Author | SHA1 | Date | |
---|---|---|---|
b68de0552f | |||
9e96d2f0ba | |||
f4192b5030 | |||
b610c5d97f | |||
67c4865697 |
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
@ -3,6 +3,7 @@ import { t } from "elysia";
|
|||||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
|
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
|
||||||
import { ScoreService } from "../service/score.service";
|
import { ScoreService } from "../service/score.service";
|
||||||
|
import { Timeframe } from "@ssr/common/timeframe";
|
||||||
|
|
||||||
@Controller("/scores")
|
@Controller("/scores")
|
||||||
export default class ScoresController {
|
export default class ScoresController {
|
||||||
@ -77,11 +78,30 @@ export default class ScoresController {
|
|||||||
|
|
||||||
@Get("/top", {
|
@Get("/top", {
|
||||||
config: {},
|
config: {},
|
||||||
|
query: t.Object({
|
||||||
|
limit: t.Number({ required: true }),
|
||||||
|
timeframe: t.String({ required: true }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
public async getTopScores(): Promise<TopScoresResponse> {
|
public async getTopScores({
|
||||||
const scores = await ScoreService.getTopScores();
|
query: { limit, timeframe },
|
||||||
|
}: {
|
||||||
|
query: { limit: number; timeframe: Timeframe };
|
||||||
|
}): Promise<TopScoresResponse> {
|
||||||
|
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 {
|
return {
|
||||||
scores,
|
scores,
|
||||||
|
timeframe,
|
||||||
|
limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -75,7 +75,7 @@ app.use(
|
|||||||
app.use(
|
app.use(
|
||||||
cron({
|
cron({
|
||||||
name: "player-scores-tracker-cron",
|
name: "player-scores-tracker-cron",
|
||||||
pattern: "*/1 * * * *", // Every day at 04:00
|
pattern: "0 4 * * *", // Every day at 04:00
|
||||||
timezone: "Europe/London", // UTC time
|
timezone: "Europe/London", // UTC time
|
||||||
protect: true,
|
protect: true,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
@ -264,7 +264,6 @@ export class PlayerService {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private static async refreshPlayerScoreSaberScores(player: PlayerDocument) {
|
private static async refreshPlayerScoreSaberScores(player: PlayerDocument) {
|
||||||
console.log(player);
|
|
||||||
console.log(`Refreshing scores for ${player.id}...`);
|
console.log(`Refreshing scores for ${player.id}...`);
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let hasMorePages = true;
|
let hasMorePages = true;
|
||||||
|
@ -38,6 +38,9 @@ import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
|
|||||||
import { Page, Pagination } from "@ssr/common/pagination";
|
import { Page, Pagination } from "@ssr/common/pagination";
|
||||||
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import Leaderboard from "@ssr/common/model/leaderboard/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({
|
const playerScoresCache = new SSRCache({
|
||||||
ttl: 1000 * 60, // 1 minute
|
ttl: 1000 * 60, // 1 minute
|
||||||
@ -189,9 +192,12 @@ export class ScoreService {
|
|||||||
if (player == undefined) {
|
if (player == undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update player name
|
// Update player name
|
||||||
player.name = playerName;
|
if (playerName !== "Unknown") {
|
||||||
await player.save();
|
player.name = playerName;
|
||||||
|
await player.save();
|
||||||
|
}
|
||||||
|
|
||||||
// The score has already been tracked, so ignore it.
|
// The score has already been tracked, so ignore it.
|
||||||
if (
|
if (
|
||||||
@ -203,9 +209,8 @@ export class ScoreService {
|
|||||||
score.score
|
score.score
|
||||||
)) !== null
|
)) !== null
|
||||||
) {
|
) {
|
||||||
await logToChannel(
|
console.log(
|
||||||
DiscordChannels.backendLogs,
|
`ScoreSaber score already tracked for "${playerName}"(${playerId}), difficulty: ${score.difficulty}, score: ${score.score}, leaderboard: ${leaderboard.id}, ignoring...`
|
||||||
new EmbedBuilder().setDescription(`Score ${score.scoreId} already tracked`)
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -303,97 +308,67 @@ export class ScoreService {
|
|||||||
* Gets the top tracked scores.
|
* Gets the top tracked scores.
|
||||||
*
|
*
|
||||||
* @param amount the amount of scores to get
|
* @param amount the amount of scores to get
|
||||||
|
* @param timeframe the timeframe to filter by
|
||||||
* @returns the top scores
|
* @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([
|
const foundScores = await ScoreSaberScoreModel.aggregate([
|
||||||
// Start sorting by timestamp descending using the new compound index
|
{ $match: { timestamp: { $gte: date } } },
|
||||||
{ $sort: { leaderboardId: 1, playerId: 1, timestamp: -1 } },
|
{ $sort: { timestamp: -1 } },
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: { leaderboardId: "$leaderboardId", playerId: "$playerId" },
|
_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: { "score.pp": -1 } },
|
||||||
{ $sort: { "latestScore.pp": -1 } },
|
|
||||||
{ $limit: amount },
|
{ $limit: amount },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Collect unique leaderboard IDs
|
const scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = [];
|
||||||
const leaderboardIds = [...new Set(foundScores.map(s => s.latestScore.leaderboardId))];
|
for (const { score: scoreData } of foundScores) {
|
||||||
const leaderboardMap = await this.fetchLeaderboardsInBatch(leaderboardIds);
|
const score = new ScoreSaberScoreModel(scoreData).toObject() as ScoreSaberScore;
|
||||||
|
const leaderboard = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
||||||
// Collect player IDs for batch retrieval
|
"scoresaber",
|
||||||
const playerIds = foundScores.map(result => result.latestScore.playerId);
|
score.leaderboardId + ""
|
||||||
const players = await PlayerModel.find({ _id: { $in: playerIds } }).exec();
|
);
|
||||||
const playerMap = new Map(players.map(player => [player._id.toString(), player]));
|
if (!leaderboard) {
|
||||||
|
continue;
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const { leaderboard, beatsaver } = leaderboardResponse;
|
const player = await PlayerService.getPlayer(score.playerId);
|
||||||
|
if (player !== undefined) {
|
||||||
// Fetch additional data concurrently
|
score.playerInfo = {
|
||||||
const [additionalData, previousScore] = await Promise.all([
|
id: player.id,
|
||||||
this.getAdditionalScoreData(
|
name: player.name,
|
||||||
score.playerId,
|
};
|
||||||
leaderboard.songHash,
|
}
|
||||||
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
} catch {
|
||||||
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) {
|
|
||||||
score.playerInfo = {
|
score.playerInfo = {
|
||||||
id: player._id,
|
id: score.playerId,
|
||||||
name: player.name,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
scores.push({
|
||||||
score: score as ScoreSaberScore,
|
score: score,
|
||||||
leaderboard: leaderboard,
|
leaderboard: leaderboard.leaderboard,
|
||||||
beatSaver: beatsaver,
|
beatSaver: leaderboard.beatsaver,
|
||||||
};
|
});
|
||||||
});
|
}
|
||||||
return (await Promise.all(scoreDataPromises)).filter(score => score !== null);
|
console.log(`Got ${scores.length} scores in ${Date.now() - before}ms (timeframe: ${timeframe}, limit: ${amount})`);
|
||||||
}
|
return scores;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<ScoreSaberLeaderboard>("scoresaber", id))
|
|
||||||
);
|
|
||||||
|
|
||||||
return leaderboardResponses.reduce(
|
|
||||||
(map, response) => {
|
|
||||||
if (response) map[response.leaderboard.id] = response;
|
|
||||||
return map;
|
|
||||||
},
|
|
||||||
{} as Record<string, { leaderboard: ScoreSaberLeaderboard; beatsaver?: BeatSaverMap }>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -488,8 +463,6 @@ export class ScoreService {
|
|||||||
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
|
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
|
||||||
if (!score) return undefined;
|
if (!score) return undefined;
|
||||||
|
|
||||||
console.log("boobs");
|
|
||||||
|
|
||||||
// Fetch additional data, previous score, and BeatSaver map concurrently
|
// Fetch additional data, previous score, and BeatSaver map concurrently
|
||||||
const [additionalData, previousScore, beatSaverMap] = await Promise.all([
|
const [additionalData, previousScore, beatSaverMap] = await Promise.all([
|
||||||
this.getAdditionalScoreData(
|
this.getAdditionalScoreData(
|
||||||
|
@ -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 Score from "../score";
|
||||||
import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
||||||
import { Document } from "mongoose";
|
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, {
|
@plugin(AutoIncrementID, {
|
||||||
field: "_id",
|
field: "_id",
|
||||||
startAt: 1,
|
startAt: 1,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import ScoreSaberPlayer from "./impl/scoresaber-player";
|
import ScoreSaberPlayer from "./impl/scoresaber-player";
|
||||||
import { ChangeRange } from "./player";
|
import { Timeframe } from "../timeframe";
|
||||||
|
|
||||||
export type PlayerStatValue = {
|
export type PlayerStatValue = {
|
||||||
/**
|
/**
|
||||||
@ -10,7 +10,7 @@ export type PlayerStatValue = {
|
|||||||
/**
|
/**
|
||||||
* The value of the stat.
|
* The value of the stat.
|
||||||
*/
|
*/
|
||||||
value: (player: ScoreSaberPlayer, range: ChangeRange) => number | undefined;
|
value: (player: ScoreSaberPlayer, range: Timeframe) => number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlayerStatChangeType =
|
export type PlayerStatChangeType =
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { PlayerHistory } from "./player-history";
|
import { PlayerHistory } from "./player-history";
|
||||||
|
import { Timeframe } from "../timeframe";
|
||||||
|
|
||||||
export default class Player {
|
export default class Player {
|
||||||
/**
|
/**
|
||||||
@ -55,7 +56,6 @@ export default class Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChangeRange = "daily" | "weekly" | "monthly";
|
|
||||||
export type StatisticChange = {
|
export type StatisticChange = {
|
||||||
[key in ChangeRange]: PlayerHistory;
|
[key in Timeframe]: PlayerHistory;
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,21 @@
|
|||||||
import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard";
|
import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import { ScoreSaberScore } from "../model/score/impl/scoresaber-score";
|
import { ScoreSaberScore } from "../model/score/impl/scoresaber-score";
|
||||||
import { PlayerScore } from "../score/player-score";
|
import { PlayerScore } from "../score/player-score";
|
||||||
|
import { Timeframe } from "../timeframe";
|
||||||
|
|
||||||
export type TopScoresResponse = {
|
export type TopScoresResponse = {
|
||||||
/**
|
/**
|
||||||
* The top scores.
|
* The top scores.
|
||||||
*/
|
*/
|
||||||
scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[];
|
scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeframe returned.
|
||||||
|
*/
|
||||||
|
timeframe: Timeframe;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of scores to fetch.
|
||||||
|
*/
|
||||||
|
limit: number;
|
||||||
};
|
};
|
||||||
|
1
projects/common/src/timeframe.ts
Normal file
1
projects/common/src/timeframe.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type Timeframe = "daily" | "weekly" | "monthly" | "all";
|
@ -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 <TopScoresData timeframe={timeframe} />;
|
||||||
|
}
|
@ -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<TopScoresResponse>(`${Config.apiUrl}/scores/top`);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="flex flex-col gap-2 w-full xl:w-[75%]">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold'">Top 100 ScoreSaber Scores</p>
|
|
||||||
<p className="text-gray-400">This will only show scores that have been tracked.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!scores ? (
|
|
||||||
<p>No scores found</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2 divide-y divide-border">
|
|
||||||
{scores.scores.map(({ score, leaderboard, beatSaver }, index) => {
|
|
||||||
const player = score.playerInfo;
|
|
||||||
const name = score.playerInfo ? player.name || player.id : score.playerId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} className="flex flex-col pt-2">
|
|
||||||
<p className="text-sm">
|
|
||||||
Set by{" "}
|
|
||||||
<Link href={`/player/${player.id}`}>
|
|
||||||
<span className="text-ssr hover:brightness-[66%] transition-all transform-gpu">{name}</span>
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<Score
|
|
||||||
score={score}
|
|
||||||
leaderboard={leaderboard}
|
|
||||||
beatSaverMap={beatSaver}
|
|
||||||
settings={{
|
|
||||||
hideLeaderboardDropdown: true,
|
|
||||||
hideAccuracyChanger: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
@ -44,7 +44,7 @@ const items: NavbarItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Top Scores",
|
name: "Top Scores",
|
||||||
link: "/scores/top",
|
link: "/scores/top/weekly",
|
||||||
openInNewTab: false,
|
openInNewTab: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
6
projects/website/src/components/loading-icon.tsx
Normal file
6
projects/website/src/components/loading-icon.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { ArrowPathIcon } from "@heroicons/react/24/solid";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function LoadingIcon() {
|
||||||
|
return <ArrowPathIcon className="w-5 h-5 animate-spin" />;
|
||||||
|
}
|
125
projects/website/src/components/score/top/top-scores-data.tsx
Normal file
125
projects/website/src/components/score/top/top-scores-data.tsx
Normal file
@ -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>(timeframe);
|
||||||
|
const [scores, setScores] = useState<TopScoresResponse | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["top-scores", selectedTimeframe],
|
||||||
|
queryFn: async () => {
|
||||||
|
return kyFetch<TopScoresResponse>(`${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 (
|
||||||
|
<Card className="flex flex-col gap-2 w-full xl:w-[75%] justify-center">
|
||||||
|
<div className="flex flex-row flex-wrap gap-2 justify-center">
|
||||||
|
{timeframes.map((timeframe, index) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
className="w-32"
|
||||||
|
variant={selectedTimeframe === timeframe.timeframe ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setScores(null);
|
||||||
|
setSelectedTimeframe(timeframe.timeframe);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeframe.display}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center flex-col text-center">
|
||||||
|
<p className="font-semibold'">Top 50 ScoreSaber Scores ({capitalizeFirstLetter(selectedTimeframe)})</p>
|
||||||
|
<p className="text-gray-400">This will only show scores that have been tracked.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isLoading || !scores) && (
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scores && !isLoading && (
|
||||||
|
<div className="flex flex-col gap-2 divide-y divide-border">
|
||||||
|
{scores.scores.map(({ score, leaderboard, beatSaver }, index) => {
|
||||||
|
const player = score.playerInfo;
|
||||||
|
const name = score.playerInfo ? player.name || player.id : score.playerId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex flex-col pt-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
Set by{" "}
|
||||||
|
<Link href={`/player/${player.id}`}>
|
||||||
|
<span className="text-ssr hover:brightness-[66%] transition-all transform-gpu">{name}</span>
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<Score
|
||||||
|
score={score}
|
||||||
|
leaderboard={leaderboard}
|
||||||
|
beatSaverMap={beatSaver}
|
||||||
|
settings={{
|
||||||
|
hideLeaderboardDropdown: true,
|
||||||
|
hideAccuracyChanger: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@ -3,8 +3,8 @@ import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils
|
|||||||
import { capitalizeFirstLetter } from "@/common/string-utils";
|
import { capitalizeFirstLetter } from "@/common/string-utils";
|
||||||
import Tooltip from "@/components/tooltip";
|
import Tooltip from "@/components/tooltip";
|
||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
import { ChangeRange } from "@ssr/common/player/player";
|
|
||||||
import { PlayerStatValue } from "@ssr/common/player/player-stat-change";
|
import { PlayerStatValue } from "@ssr/common/player/player-stat-change";
|
||||||
|
import { Timeframe } from "@ssr/common/timeframe";
|
||||||
|
|
||||||
type ChangeOverTimeProps = {
|
type ChangeOverTimeProps = {
|
||||||
/**
|
/**
|
||||||
@ -40,7 +40,7 @@ export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Renders the change for a given time frame
|
// Renders the change for a given time frame
|
||||||
const renderChange = (value: number | undefined, range: ChangeRange) => (
|
const renderChange = (value: number | undefined, range: Timeframe) => (
|
||||||
<p>
|
<p>
|
||||||
{capitalizeFirstLetter(range)} Change:{" "}
|
{capitalizeFirstLetter(range)} Change:{" "}
|
||||||
<span className={value === undefined ? "" : value >= 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}>
|
<span className={value === undefined ? "" : value >= 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}>
|
||||||
|
Reference in New Issue
Block a user