move score page fetching to the backend
Some checks failed
Deploy Backend / deploy (push) Successful in 2m26s
Deploy Website / deploy (push) Failing after 1m52s

This commit is contained in:
Lee 2024-10-17 15:30:14 +01:00
parent 118dc9d9f1
commit b3c124631a
78 changed files with 1150 additions and 494 deletions

BIN
bun.lockb

Binary file not shown.

@ -0,0 +1,26 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import LeaderboardService from "../service/leaderboard.service";
@Controller("/leaderboard")
export default class LeaderboardController {
@Get("/:leaderboard/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
leaderboard: t.String({ required: true }),
}),
})
public async getLeaderboard({
params: { leaderboard, id },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
};
}): Promise<unknown> {
return await LeaderboardService.getLeaderboard(leaderboard, id);
}
}

@ -1,8 +1,8 @@
import { Controller, Get } from "elysia-decorators"; import { Controller, Get } from "elysia-decorators";
import { PlayerService } from "../service/player.service"; import { PlayerService } from "../service/player.service";
import { t } from "elysia"; import { t } from "elysia";
import { PlayerHistory } from "@ssr/common/types/player/player-history"; import { PlayerHistory } from "@ssr/common/player/player-history";
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since"; import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
@Controller("/player") @Controller("/player")
export default class PlayerController { export default class PlayerController {

@ -0,0 +1,55 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import { ScoreService } from "../service/score.service";
@Controller("/scores")
export default class ScoresController {
@Get("/player/:leaderboard/:id/:page/:sort", {
config: {},
params: t.Object({
leaderboard: t.String({ required: true }),
id: t.String({ required: true }),
page: t.Number({ required: true }),
sort: t.String({ required: true }),
}),
query: t.Object({
search: t.Optional(t.String()),
}),
})
public async getScores({
params: { leaderboard, id, page, sort },
query: { search },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
sort: string;
};
query: { search?: string };
}): Promise<unknown> {
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, search);
}
@Get("/leaderboard/:leaderboard/:id/:page", {
config: {},
params: t.Object({
leaderboard: t.String({ required: true }),
id: t.String({ required: true }),
page: t.Number({ required: true }),
}),
})
public async getLeaderboardScores({
params: { leaderboard, id, page },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
};
query: { search?: string };
}): Promise<unknown> {
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
}
}

@ -14,7 +14,6 @@ import { setLogLevel } from "@typegoose/typegoose";
import PlayerController from "./controller/player.controller"; import PlayerController from "./controller/player.controller";
import { PlayerService } from "./service/player.service"; import { PlayerService } from "./service/player.service";
import { cron } from "@elysiajs/cron"; import { cron } from "@elysiajs/cron";
import { PlayerDocument, PlayerModel } from "./model/player";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { delay } from "@ssr/common/utils/utils"; import { delay } from "@ssr/common/utils/utils";
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket"; import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
@ -22,6 +21,9 @@ import ImageController from "./controller/image.controller";
import ReplayController from "./controller/replay.controller"; import ReplayController from "./controller/replay.controller";
import { ScoreService } from "./service/score.service"; import { ScoreService } from "./service/score.service";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import ScoresController from "./controller/scores.controller";
import LeaderboardController from "./controller/leaderboard.controller";
// Load .env file // Load .env file
dotenv.config({ dotenv.config({
@ -159,7 +161,14 @@ app.use(
*/ */
app.use( app.use(
decorators({ decorators({
controllers: [AppController, PlayerController, ImageController, ReplayController], controllers: [
AppController,
PlayerController,
ImageController,
ReplayController,
ScoresController,
LeaderboardController,
],
}) })
); );

@ -1,4 +1,4 @@
import { PlayerModel } from "../model/player"; import { PlayerModel } from "@ssr/common/model/player";
import { AppStatistics } from "@ssr/common/types/backend/app-statistics"; import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
export class AppService { export class AppService {

@ -0,0 +1,30 @@
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
export default class BeatSaverService {
/**
* Gets a map by its hash.
*
* @param hash the hash of the map
* @returns the beatsaver map
*/
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
let map = await BeatSaverMapModel.findById(hash);
if (map != undefined) {
return map.toObject() as BeatSaverMap;
}
const token = await beatsaverService.lookupMap(hash);
if (token == undefined) {
return undefined;
}
map = await BeatSaverMapModel.create({
_id: hash,
bsr: token.id,
author: {
id: token.uploader.id,
},
});
return map.toObject() as BeatSaverMap;
}
}

@ -7,7 +7,7 @@ import { StarIcon } from "../../components/star-icon";
import { GlobeIcon } from "../../components/globe-icon"; import { GlobeIcon } from "../../components/globe-icon";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
import { Jimp } from "jimp"; import { Jimp } from "jimp";
import { extractColors } from "extract-colors"; import { extractColors } from "extract-colors";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";

@ -0,0 +1,78 @@
import { Leaderboards } from "@ssr/common/leaderboard";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { SSRCache } from "@ssr/common/cache";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { NotFoundError } from "elysia";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24,
});
export default class LeaderboardService {
/**
* Gets the leaderboard.
*
* @param leaderboard the leaderboard
* @param id the id
*/
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> {
const cacheKey = `${leaderboard}-${id}`;
if (leaderboardCache.has(cacheKey)) {
return leaderboardCache.get(cacheKey) as T;
}
switch (leaderboard) {
case "scoresaber": {
const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T;
leaderboardCache.set(cacheKey, leaderboard);
return leaderboard;
}
default: {
return undefined;
}
}
}
/**
* Gets a leaderboard.
*
* @param leaderboardName the leaderboard to get
* @param id the players id
* @returns the scores
*/
public static async getLeaderboard(
leaderboardName: Leaderboards,
id: string
): Promise<LeaderboardResponse<Leaderboard>> {
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
switch (leaderboardName) {
case "scoresaber": {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
leaderboardName,
id
);
if (leaderboardToken == undefined) {
throw new NotFoundError(`Leaderboard not found for "${id}"`);
}
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
leaderboard: leaderboard,
beatsaver: beatSaverMap,
};
}
}

@ -1,4 +1,4 @@
import { PlayerDocument, PlayerModel } from "../model/player"; import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { NotFoundError } from "../error/not-found-error"; import { NotFoundError } from "../error/not-found-error";
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils"; import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";

@ -5,6 +5,21 @@ import { MessageBuilder, Webhook } from "discord-webhook-node";
import { formatPp } from "@ssr/common/utils/number-utils"; import { formatPp } from "@ssr/common/utils/number-utils";
import { isProduction } from "@ssr/common/utils/utils"; import { isProduction } from "@ssr/common/utils/utils";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import { Metadata } from "@ssr/common/types/metadata";
import { NotFoundError } from "elysia";
import BeatSaverService from "./beatsaver.service";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
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";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { PlayerScore } from "@ssr/common/score/player-score";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import Score from "@ssr/common/score/score";
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
export class ScoreService { export class ScoreService {
/** /**
@ -45,4 +60,137 @@ export class ScoreService {
embed.setColor("#00ff00"); embed.setColor("#00ff00");
await hook.send(embed); await hook.send(embed);
} }
/**
* Gets scores for a player.
*
* @param leaderboardName the leaderboard to get the scores from
* @param id the players id
* @param page the page to get
* @param sort the sort to use
* @param search the search to use
* @returns the scores
*/
public static async getPlayerScores(
leaderboardName: Leaderboards,
id: string,
page: number,
sort: string,
search?: string
): Promise<PlayerScoresResponse<unknown, unknown>> {
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
const leaderboardScores = await scoresaberService.lookupPlayerScores({
playerId: id,
page: page,
sort: sort as ScoreSort,
search: search,
});
if (leaderboardScores == undefined) {
throw new NotFoundError(
`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}", sort "${sort}", search "${search}"`
);
}
for (const token of leaderboardScores.playerScores) {
const score = getScoreSaberScoreFromToken(token.score);
if (score == undefined) {
continue;
}
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (tokenLeaderboard == undefined) {
continue;
}
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
scores.push({
score: score,
leaderboard: tokenLeaderboard,
beatSaver: beatSaverMap,
});
}
metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
leaderboardScores.metadata.total,
leaderboardScores.metadata.page,
leaderboardScores.metadata.itemsPerPage
);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
scores: scores,
metadata: metadata,
};
}
/**
* Gets scores for a leaderboard.
*
* @param leaderboardName the leaderboard to get the scores from
* @param id the leaderboard id
* @param page the page to get
* @returns the scores
*/
public static async getLeaderboardScores(
leaderboardName: Leaderboards,
id: string,
page: number
): Promise<LeaderboardScoresResponse<unknown>> {
const scores: Score[] = [];
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(id, page);
if (leaderboardScores == undefined) {
throw new NotFoundError(`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}""`);
}
const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
leaderboard = leaderboardResponse.leaderboard;
beatSaverMap = leaderboardResponse.beatsaver;
for (const token of leaderboardScores.scores) {
const score = getScoreSaberScoreFromToken(token);
if (score == undefined) {
continue;
}
scores.push(score);
}
metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
leaderboardScores.metadata.total,
leaderboardScores.metadata.page,
leaderboardScores.metadata.itemsPerPage
);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
scores: scores,
leaderboard: leaderboard,
beatSaver: beatSaverMap,
metadata: metadata,
};
}
} }

@ -10,6 +10,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"jsx": "react" "jsx": "react",
} },
} }

@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"ky": "^1.7.2", "ky": "^1.7.2",
"ws": "^8.18.0" "ws": "^8.18.0",
"@typegoose/typegoose": "^12.8.0"
} }
} }

@ -0,0 +1,5 @@
const Leaderboards = {
SCORESABER: "scoresaber",
} as const;
export type Leaderboards = (typeof Leaderboards)[keyof typeof Leaderboards];

@ -0,0 +1,63 @@
import Leaderboard from "../leaderboard";
import LeaderboardDifficulty from "../leaderboard-difficulty";
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
import { parseDate } from "../../utils/time-utils";
export default interface ScoreSaberLeaderboard extends Leaderboard {
/**
* The star count for the leaderboard.
*/
readonly stars: number;
/**
* The total amount of plays.
*/
readonly plays: number;
/**
* The amount of plays today.
*/
readonly dailyPlays: number;
}
/**
* Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}.
*
* @param token the token to parse
*/
export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard {
const difficulty: LeaderboardDifficulty = {
leaderboardId: token.difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
gameMode: token.difficulty.gameMode,
difficultyRaw: token.difficulty.difficultyRaw,
};
return {
id: token.id,
songHash: token.songHash,
songName: token.songName,
songSubName: token.songSubName,
songAuthorName: token.songAuthorName,
levelAuthorName: token.levelAuthorName,
difficulty: difficulty,
difficulties:
token.difficulties != undefined && token.difficulties.length > 0
? token.difficulties.map(difficulty => {
return {
leaderboardId: difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
gameMode: difficulty.gameMode,
difficultyRaw: difficulty.difficultyRaw,
};
})
: [difficulty],
maxScore: token.maxScore,
ranked: token.ranked,
songArt: token.coverImage,
timestamp: parseDate(token.createdDate),
stars: token.stars,
plays: token.plays,
dailyPlays: token.dailyPlays,
};
}

@ -0,0 +1,23 @@
import { Difficulty } from "../score/difficulty";
export default interface LeaderboardDifficulty {
/**
* The id of the leaderboard.
*/
leaderboardId: number;
/**
* The difficulty of the leaderboard.
*/
difficulty: Difficulty;
/**
* The game mode of the leaderboard.
*/
gameMode: string;
/**
* The raw difficulty of the leaderboard.
*/
difficultyRaw: string;
}

@ -0,0 +1,75 @@
import LeaderboardDifficulty from "./leaderboard-difficulty";
export default interface Leaderboard {
/**
* The id of the leaderboard.
* @private
*/
readonly id: number;
/**
* The hash of the song this leaderboard is for.
* @private
*/
readonly songHash: string;
/**
* The name of the song this leaderboard is for.
* @private
*/
readonly songName: string;
/**
* The sub name of the leaderboard.
* @private
*/
readonly songSubName: string;
/**
* The author of the song this leaderboard is for.
* @private
*/
readonly songAuthorName: string;
/**
* The author of the level this leaderboard is for.
* @private
*/
readonly levelAuthorName: string;
/**
* The difficulty of the leaderboard.
* @private
*/
readonly difficulty: LeaderboardDifficulty;
/**
* The difficulties of the leaderboard.
* @private
*/
readonly difficulties: LeaderboardDifficulty[];
/**
* The maximum score of the leaderboard.
* @private
*/
readonly maxScore: number;
/**
* Whether the leaderboard is ranked.
* @private
*/
readonly ranked: boolean;
/**
* The link to the song art.
* @private
*/
readonly songArt: string;
/**
* The date the leaderboard was created.
* @private
*/
readonly timestamp: Date;
}

@ -0,0 +1,13 @@
import { prop } from "@typegoose/typegoose";
export default class BeatsaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
constructor(id: number) {
this.id = id;
}
}

@ -0,0 +1,51 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatsaverAuthor from "./beatsaver-author";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
private _id!: string;
/**
* The bsr code for the map.
* @private
*/
@prop({ required: true })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: true, _id: false, type: () => BeatsaverAuthor })
public author!: BeatsaverAuthor;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

@ -1,7 +1,7 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose"; import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose"; import { Document } from "mongoose";
import { PlayerHistory } from "@ssr/common/types/player/player-history"; import { PlayerHistory } from "../player/player-history";
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils"; import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../utils/time-utils";
/** /**
* The model for a player. * The model for a player.
@ -109,8 +109,5 @@ export class Player {
} }
} }
// This type defines a Mongoose document based on Player.
export type PlayerDocument = Player & Document; export type PlayerDocument = Player & Document;
// This type ensures that PlayerModel returns Mongoose documents (PlayerDocument) that have Mongoose methods (save, remove, etc.)
export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player); export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player);

@ -1,10 +1,10 @@
import Player, { StatisticChange } from "../player"; import Player, { StatisticChange } from "../player";
import ky from "ky"; import ky from "ky";
import { PlayerHistory } from "../player-history"; import { PlayerHistory } from "../player-history";
import ScoreSaberPlayerToken from "../../token/scoresaber/score-saber-player-token"; import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../../utils/time-utils"; import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../utils/time-utils";
import { getPageFromRank } from "../../../utils/utils"; import { getPageFromRank } from "../../utils/utils";
import { Config } from "../../../config"; import { Config } from "../../config";
/** /**
* A ScoreSaber player. * A ScoreSaber player.

@ -0,0 +1,13 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
export type LeaderboardResponse<L> = {
/**
* The leaderboard.
*/
leaderboard: L;
/**
* The beatsaver map.
*/
beatsaver?: BeatSaverMap;
};

@ -0,0 +1,25 @@
import { Metadata } from "../types/metadata";
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import Score from "../score/score";
export default interface LeaderboardScoresResponse<L> {
/**
* The scores that were set.
*/
readonly scores: Score[];
/**
* The leaderboard that was used.
*/
readonly leaderboard: L;
/**
* The beatsaver map for the song.
*/
readonly beatSaver?: BeatSaverMap;
/**
* The pagination metadata.
*/
readonly metadata: Metadata;
}

@ -0,0 +1,14 @@
import { Metadata } from "../types/metadata";
import { PlayerScore } from "../score/player-score";
export default interface PlayerScoresResponse<S, L> {
/**
* The scores that were set.
*/
readonly scores: PlayerScore<S, L>[];
/**
* The pagination metadata.
*/
readonly metadata: Metadata;
}

@ -0,0 +1 @@
export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown";

@ -0,0 +1,62 @@
import Score from "../score";
import { Modifier } from "../modifier";
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
export default interface ScoreSaberScore extends Score {
/**
* The score's id.
*/
readonly id: string;
/**
* The amount of pp for the score.
* @private
*/
readonly pp: number;
/**
* The weight of the score, or undefined if not ranked.s
* @private
*/
readonly weight?: number;
/**
* The player who set the score
*/
readonly playerInfo: ScoreSaberLeaderboardPlayerInfoToken;
}
/**
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
*
* @param token the token to convert
*/
export function getScoreSaberScoreFromToken(token: ScoreSaberScoreToken): ScoreSaberScore {
const modifiers: Modifier[] =
token.modifiers == undefined || token.modifiers === ""
? []
: token.modifiers.split(",").map(mod => {
mod = mod.toUpperCase();
const modifier = Modifier[mod as keyof typeof Modifier];
if (modifier === undefined) {
throw new Error(`Unknown modifier: ${mod}`);
}
return modifier;
});
return {
leaderboard: "scoresaber",
score: token.baseScore,
rank: token.rank,
modifiers: modifiers,
misses: token.missedNotes,
badCuts: token.badCuts,
fullCombo: token.fullCombo,
timestamp: new Date(token.timeSet),
id: token.id,
pp: token.pp,
weight: token.weight,
playerInfo: token.leaderboardPlayerInfo,
};
}

@ -15,4 +15,6 @@ export enum Modifier {
CS = "Fail on Saber Clash", CS = "Fail on Saber Clash",
IF = "One Life", IF = "One Life",
BE = "Battery Energy", BE = "Battery Energy",
NF = "No Fail",
NB = "No Bombs",
} }

@ -0,0 +1,6 @@
export default interface PlayerLeaderboardScore<S> {
/**
* The score that was set.
*/
readonly score: S;
}

@ -0,0 +1,18 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
export interface PlayerScore<S, L> {
/**
* The score.
*/
readonly score: S;
/**
* The leaderboard the score was set on.
*/
readonly leaderboard: L;
/**
* The BeatSaver of the song.
*/
readonly beatSaver?: BeatSaverMap;
}

@ -0,0 +1,51 @@
import { Modifier } from "./modifier";
import { Leaderboards } from "../leaderboard";
export default interface Score {
/**
* The leaderboard the score is from.
*/
readonly leaderboard: Leaderboards;
/**
* The base score for the score.
* @private
*/
readonly score: number;
/**
* The rank for the score.
* @private
*/
readonly rank: number;
/**
* The modifiers used on the score.
* @private
*/
readonly modifiers: Modifier[];
/**
* The amount missed notes.
* @private
*/
readonly misses: number;
/**
* The amount of bad cuts.
* @private
*/
readonly badCuts: number;
/**
* Whether every note was hit.
* @private
*/
readonly fullCombo: boolean;
/**
* The time the score was set.
* @private
*/
readonly timestamp: Date;
}

@ -2,7 +2,7 @@ import Service from "../service";
import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token"; import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token";
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token"; import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token";
import { ScoreSort } from "../../types/score/score-sort"; import { ScoreSort } from "../../score/score-sort";
import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token"; import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token";
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token"; import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";

@ -40,7 +40,6 @@ export default class Service {
try { try {
return await ky.get<T>(this.buildRequestUrl(true, url)).json(); return await ky.get<T>(this.buildRequestUrl(true, url)).json();
} catch (error) { } catch (error) {
console.error(`Error fetching data from ${url}:`, error);
return undefined; return undefined;
} }
} }

@ -0,0 +1,28 @@
export class Metadata {
/**
* The amount of pages in the pagination
*/
public readonly totalPages: number;
/**
* The total amount of items
*/
public readonly totalItems: number;
/**
* The current page
*/
public readonly page: number;
/**
* The amount of items per page
*/
public readonly itemsPerPage: number;
constructor(totalPages: number, totalItems: number, page: number, itemsPerPage: number) {
this.totalPages = totalPages;
this.totalItems = totalItems;
this.page = page;
this.itemsPerPage = itemsPerPage;
}
}

@ -0,0 +1,13 @@
import { Metadata } from "./metadata";
export type Page<T> = {
/**
* The data to return.
*/
data: T[];
/**
* The metadata of the page.
*/
metadata: Metadata;
};

@ -1,47 +0,0 @@
import Score from "../score";
import { Modifier } from "../modifier";
import ScoreSaberScoreToken from "../../token/scoresaber/score-saber-score-token";
export default class ScoreSaberScore extends Score {
constructor(
score: number,
weight: number | undefined,
rank: number,
worth: number,
modifiers: Modifier[],
misses: number,
badCuts: number,
fullCombo: boolean,
timestamp: Date
) {
super(score, weight, rank, worth, modifiers, misses, badCuts, fullCombo, timestamp);
}
/**
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
*
* @param token the token to convert
*/
public static fromToken(token: ScoreSaberScoreToken): ScoreSaberScore {
const modifiers: Modifier[] = token.modifiers.split(",").map(mod => {
mod = mod.toUpperCase();
const modifier = Modifier[mod as keyof typeof Modifier];
if (modifier === undefined) {
throw new Error(`Unknown modifier: ${mod}`);
}
return modifier;
});
return new ScoreSaberScore(
token.baseScore,
token.weight,
token.rank,
token.pp,
modifiers,
token.missedNotes,
token.badCuts,
token.fullCombo,
new Date(token.timeSet)
);
}
}

@ -1,116 +0,0 @@
import { Modifier } from "./modifier";
export default class Score {
/**
* The base score for the score.
* @private
*/
private readonly _score: number;
/**
* The weight of the score, or undefined if not ranked.s
* @private
*/
private readonly _weight: number | undefined;
/**
* The rank for the score.
* @private
*/
private readonly _rank: number;
/**
* The worth of the score (this could be pp, ap, cr, etc.),
* or undefined if not ranked.
* @private
*/
private readonly _worth: number;
/**
* The modifiers used on the score.
* @private
*/
private readonly _modifiers: Modifier[];
/**
* The amount missed notes.
* @private
*/
private readonly _misses: number;
/**
* The amount of bad cuts.
* @private
*/
private readonly _badCuts: number;
/**
* Whether every note was hit.
* @private
*/
private readonly _fullCombo: boolean;
/**
* The time the score was set.
* @private
*/
private readonly _timestamp: Date;
constructor(
score: number,
weight: number | undefined,
rank: number,
worth: number,
modifiers: Modifier[],
misses: number,
badCuts: number,
fullCombo: boolean,
timestamp: Date
) {
this._score = score;
this._weight = weight;
this._rank = rank;
this._worth = worth;
this._modifiers = modifiers;
this._misses = misses;
this._badCuts = badCuts;
this._fullCombo = fullCombo;
this._timestamp = timestamp;
}
get score(): number {
return this._score;
}
get weight(): number | undefined {
return this._weight;
}
get rank(): number {
return this._rank;
}
get worth(): number {
return this._worth;
}
get modifiers(): Modifier[] {
return this._modifiers;
}
get misses(): number {
return this._misses;
}
get badCuts(): number {
return this._badCuts;
}
get fullCombo(): boolean {
return this._fullCombo;
}
get timestamp(): Date {
return this._timestamp;
}
}

@ -19,8 +19,8 @@ export default interface ScoreSaberLeaderboardToken {
maxPP: number; maxPP: number;
stars: number; stars: number;
positiveModifiers: boolean; positiveModifiers: boolean;
plays: boolean; plays: number;
dailyPlays: boolean; dailyPlays: number;
coverImage: string; coverImage: string;
difficulties: ScoreSaberDifficultyToken[]; difficulties: ScoreSaberDifficultyToken[];
} }

@ -0,0 +1,14 @@
import { Config } from "../config";
import { LeaderboardResponse } from "../response/leaderboard-response";
import { kyFetch } from "./utils";
import { Leaderboards } from "../leaderboard";
/**
* Fetches the leaderboard
*
* @param id the leaderboard id
* @param leaderboard the leaderboard
*/
export async function fetchLeaderboard<L>(leaderboard: Leaderboards, id: string) {
return kyFetch<LeaderboardResponse<L>>(`${Config.apiUrl}/leaderboard/${leaderboard}/${id}`);
}

@ -1,4 +1,4 @@
import { PlayerHistory } from "../types/player/player-history"; import { PlayerHistory } from "../player/player-history";
import { kyFetch } from "./utils"; import { kyFetch } from "./utils";
import { Config } from "../config"; import { Config } from "../config";

@ -0,0 +1,37 @@
import { Leaderboards } from "../leaderboard";
import { kyFetch } from "./utils";
import PlayerScoresResponse from "../response/player-scores-response";
import { Config } from "../config";
import { ScoreSort } from "../score/score-sort";
/**
* Fetches the player's scores
*
* @param leaderboard the leaderboard
* @param id the player id
* @param page the page
* @param sort the sort
* @param search the search
*/
export async function fetchPlayerScores<S, L>(
leaderboard: Leaderboards,
id: string,
page: number,
sort: ScoreSort,
search?: string
) {
return kyFetch<PlayerScoresResponse<S, L>>(
`${Config.apiUrl}/scores/player/${leaderboard}/${id}/${page}/${sort}${search ? `?search=${search}` : ""}`
);
}
/**
* Fetches the player's scores
*
* @param leaderboard the leaderboard
* @param id the player id
* @param page the page
*/
export async function fetchLeaderboardScores<S, L>(leaderboard: Leaderboards, id: string, page: number) {
return kyFetch<PlayerScoresResponse<S, L>>(`${Config.apiUrl}/scores/leaderboard/${leaderboard}/${id}/${page}`);
}

@ -1,9 +1,11 @@
import { Difficulty } from "../score/difficulty";
/** /**
* Formats the ScoreSaber difficulty number * Formats the ScoreSaber difficulty number
* *
* @param diff the diffuiclity number * @param diff the diffuiclity number
*/ */
export function getDifficultyFromScoreSaberDifficulty(diff: number) { export function getDifficultyFromScoreSaberDifficulty(diff: number): Difficulty {
switch (diff) { switch (diff) {
case 1: { case 1: {
return "Easy"; return "Easy";

@ -7,6 +7,8 @@
"moduleResolution": "node", "moduleResolution": "node",
"skipLibCheck": true, "skipLibCheck": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true, "strict": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {

@ -3,11 +3,14 @@ import { redirect } from "next/navigation";
import { Colors } from "@/common/colors"; import { Colors } from "@/common/colors";
import { getAverageColor } from "@/common/image-utils"; import { getAverageColor } from "@/common/image-utils";
import { LeaderboardData } from "@/components/leaderboard/leaderboard-data"; import { LeaderboardData } from "@/components/leaderboard/leaderboard-data";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
import PlayerScoresResponse from "../../../../../../common/src/response/player-scores-response.ts";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
const UNKNOWN_LEADERBOARD = { const UNKNOWN_LEADERBOARD = {
title: "ScoreSaber Reloaded - Unknown Leaderboard", title: "ScoreSaber Reloaded - Unknown Leaderboard",
@ -24,8 +27,8 @@ type Props = {
}; };
type LeaderboardData = { type LeaderboardData = {
leaderboard: ScoreSaberLeaderboardToken | undefined; leaderboardResponse: LeaderboardResponse<ScoreSaberLeaderboard>;
scores: ScoreSaberLeaderboardScoresPageToken | undefined; scores?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
page: number; page: number;
}; };
@ -38,7 +41,10 @@ const leaderboardCache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
* @param fetchScores whether to fetch the scores * @param fetchScores whether to fetch the scores
* @returns the leaderboard data and scores * @returns the leaderboard data and scores
*/ */
const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true) => { const getLeaderboardData = async (
{ params }: Props,
fetchScores: boolean = true
): Promise<LeaderboardData | undefined> => {
const { slug } = await params; const { slug } = await params;
const id = slug[0]; // The leaderboard id const id = slug[0]; // The leaderboard id
const page = parseInt(slug[1]) || 1; // The page number const page = parseInt(slug[1]) || 1; // The page number
@ -47,16 +53,17 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true
if (leaderboardCache.has(cacheId)) { if (leaderboardCache.has(cacheId)) {
return leaderboardCache.get(cacheId) as LeaderboardData; return leaderboardCache.get(cacheId) as LeaderboardData;
} }
const leaderboard = await fetchLeaderboard<ScoreSaberLeaderboard>("scoresaber", id + "");
const leaderboard = await scoresaberService.lookupLeaderboard(id); if (leaderboard === undefined) {
let scores: ScoreSaberLeaderboardScoresPageToken | undefined; return undefined;
if (fetchScores) {
scores = await scoresaberService.lookupLeaderboardScores(id + "", page);
} }
const leaderboardData = { const scores = fetchScores
? await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>("scoresaber", id + "", page)
: undefined;
const leaderboardData: LeaderboardData = {
leaderboardResponse: leaderboard,
page: page, page: page,
leaderboard: leaderboard,
scores: scores, scores: scores,
}; };
@ -65,8 +72,8 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true
}; };
export async function generateMetadata(props: Props): Promise<Metadata> { export async function generateMetadata(props: Props): Promise<Metadata> {
const { leaderboard } = await getLeaderboardData(props, false); const response = await getLeaderboardData(props, false);
if (leaderboard === undefined) { if (response === undefined) {
return { return {
title: UNKNOWN_LEADERBOARD.title, title: UNKNOWN_LEADERBOARD.title,
description: UNKNOWN_LEADERBOARD.description, description: UNKNOWN_LEADERBOARD.description,
@ -77,6 +84,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
}; };
} }
const { leaderboard } = response.leaderboardResponse;
return { return {
title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`, title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`,
openGraph: { openGraph: {
@ -95,24 +103,25 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
} }
export async function generateViewport(props: Props): Promise<Viewport> { export async function generateViewport(props: Props): Promise<Viewport> {
const { leaderboard } = await getLeaderboardData(props, false); const response = await getLeaderboardData(props, false);
if (leaderboard === undefined) { if (response === undefined) {
return { return {
themeColor: Colors.primary, themeColor: Colors.primary,
}; };
} }
const color = await getAverageColor(leaderboard.coverImage); const color = await getAverageColor(response.leaderboardResponse.leaderboard.songArt);
return { return {
themeColor: color.color, themeColor: color.color,
}; };
} }
export default async function LeaderboardPage(props: Props) { export default async function LeaderboardPage(props: Props) {
const { leaderboard, scores, page } = await getLeaderboardData(props); const response = await getLeaderboardData(props);
if (leaderboard == undefined) { if (response == undefined) {
return redirect("/"); return redirect("/");
} }
const { leaderboardResponse, scores } = response;
return <LeaderboardData initialLeaderboard={leaderboard} initialPage={page} initialScores={scores} />; return <LeaderboardData initialLeaderboard={response.leaderboardResponse} initialScores={scores} />;
} }

@ -3,13 +3,16 @@ import { Metadata, Viewport } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Colors } from "@/common/colors"; import { Colors } from "@/common/colors";
import { getAverageColor } from "@/common/image-utils"; import { getAverageColor } from "@/common/image-utils";
import { ScoreSort } from "@ssr/common/types/score/score-sort";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import { getCookieValue } from "@ssr/common/utils/cookie-utils"; import { getCookieValue } from "@ssr/common/utils/cookie-utils";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
import { ScoreSort } from "@ssr/common/score/score-sort";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { fetchPlayerScores } from "@ssr/common/utils/score-utils";
import PlayerScoresResponse from "../../../../../../common/src/response/player-scores-response";
const UNKNOWN_PLAYER = { const UNKNOWN_PLAYER = {
title: "ScoreSaber Reloaded - Unknown Player", title: "ScoreSaber Reloaded - Unknown Player",
@ -27,7 +30,7 @@ type Props = {
type PlayerData = { type PlayerData = {
player: ScoreSaberPlayer | undefined; player: ScoreSaberPlayer | undefined;
scores: ScoreSaberPlayerScoresPageToken | undefined; scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
sort: ScoreSort; sort: ScoreSort;
page: number; page: number;
search: string; search: string;
@ -56,14 +59,9 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr
const playerToken = await scoresaberService.lookupPlayer(id); const playerToken = await scoresaberService.lookupPlayer(id);
const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId"))); const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, await getCookieValue("playerId")));
let scores: ScoreSaberPlayerScoresPageToken | undefined; let scores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined;
if (fetchScores) { if (fetchScores) {
scores = await scoresaberService.lookupPlayerScores({ scores = await fetchPlayerScores("scoresaber", id, page, sort, search);
playerId: id,
sort,
page,
search,
});
} }
const playerData = { const playerData = {

@ -1,5 +1,4 @@
import Dexie, { EntityTable } from "dexie"; import Dexie, { EntityTable } from "dexie";
import BeatSaverMap from "./types/beatsaver-map";
import Settings from "./types/settings"; import Settings from "./types/settings";
import { Friend } from "@/common/database/types/friends"; import { Friend } from "@/common/database/types/friends";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
@ -15,11 +14,6 @@ export default class Database extends Dexie {
*/ */
settings!: EntityTable<Settings, "id">; settings!: EntityTable<Settings, "id">;
/**
* Cached BeatSaver maps
*/
beatSaverMaps!: EntityTable<BeatSaverMap, "hash">;
/** /**
* The added friends * The added friends
*/ */
@ -37,7 +31,6 @@ export default class Database extends Dexie {
// Mapped tables // Mapped tables
this.settings.mapToClass(Settings); this.settings.mapToClass(Settings);
this.beatSaverMaps.mapToClass(BeatSaverMap);
// Populate default settings if the table is empty // Populate default settings if the table is empty
this.on("populate", () => this.populateDefaults()); this.on("populate", () => this.populateDefaults());

@ -1,23 +0,0 @@
import { Entity } from "dexie";
import Database from "../database";
import { BeatSaverMapToken } from "@ssr/common/types/token/beatsaver/beat-saver-map-token";
/**
* A beat saver map.
*/
export default class BeatSaverMap extends Entity<Database> {
/**
* The hash of the map.
*/
hash!: string;
/**
* The bsr code for the map.
*/
bsr!: string;
/**
* The full data for the map.
*/
fullData!: BeatSaverMapToken;
}

@ -36,7 +36,7 @@ export function ApiHealth() {
? "The API has recovered connectivity." ? "The API has recovered connectivity."
: "The API has lost connectivity, some data may be unavailable.", : "The API has lost connectivity, some data may be unavailable.",
variant: online ? "success" : "destructive", variant: online ? "success" : "destructive",
duration: 10_000, // 10 seconds duration: 5_000, // 5 seconds
}); });
} }

@ -6,7 +6,7 @@ import { useToast } from "@/hooks/use-toast";
import Tooltip from "../tooltip"; import Tooltip from "../tooltip";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { PersonIcon } from "@radix-ui/react-icons"; import { PersonIcon } from "@radix-ui/react-icons";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { trackPlayer } from "@ssr/common/utils/player-utils"; import { trackPlayer } from "@ssr/common/utils/player-utils";
type Props = { type Props = {

@ -2,70 +2,55 @@
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info"; import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import BeatSaverMap from "@/common/database/types/beatsaver-map"; import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; const REFRESH_INTERVAL = 1000 * 60 * 5;
import { lookupBeatSaverMap } from "@/common/beatsaver-utils";
type LeaderboardDataProps = { type LeaderboardDataProps = {
/** /**
* The page to show when opening the leaderboard. * The initial leaderboard data.
*/ */
initialPage?: number; initialLeaderboard: LeaderboardResponse<ScoreSaberLeaderboard>;
/** /**
* The initial scores to show. * The initial score data.
*/ */
initialScores?: ScoreSaberLeaderboardScoresPageToken; initialScores: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
/**
* The leaderboard to display.
*/
initialLeaderboard: ScoreSaberLeaderboardToken;
}; };
export function LeaderboardData({ initialPage, initialScores, initialLeaderboard }: LeaderboardDataProps) { export function LeaderboardData({ initialLeaderboard, initialScores }: LeaderboardDataProps) {
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>(); const [currentLeaderboardId, setCurrentLeaderboardId] = useState(initialLeaderboard.leaderboard.id);
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(initialLeaderboard.id);
let currentLeaderboard = initialLeaderboard; let leaderboard = initialLeaderboard;
const { data } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ["leaderboard", selectedLeaderboardId], queryKey: ["leaderboard", currentLeaderboardId],
queryFn: () => scoresaberService.lookupLeaderboard(selectedLeaderboardId + ""), queryFn: async (): Promise<LeaderboardResponse<ScoreSaberLeaderboard> | undefined> => {
initialData: initialLeaderboard, return fetchLeaderboard<ScoreSaberLeaderboard>("scoresaber", currentLeaderboardId + "");
},
refetchInterval: REFRESH_INTERVAL,
refetchIntervalInBackground: false,
}); });
if (data) { if (data && (!isLoading || !isError)) {
currentLeaderboard = data; leaderboard = data;
}
const fetchBeatSaverData = useCallback(async () => {
const beatSaverMap = await lookupBeatSaverMap(initialLeaderboard.songHash);
setBeatSaverMap(beatSaverMap);
}, [initialLeaderboard.songHash]);
useEffect(() => {
fetchBeatSaverData();
}, [fetchBeatSaverData]);
if (!currentLeaderboard) {
return null;
} }
return ( return (
<main className="flex flex-col-reverse xl:flex-row w-full gap-2"> <main className="flex flex-col-reverse xl:flex-row w-full gap-2">
<LeaderboardScores <LeaderboardScores
leaderboard={currentLeaderboard} leaderboard={leaderboard.leaderboard}
initialScores={initialScores} initialScores={initialScores}
initialPage={initialPage} leaderboardChanged={newId => setCurrentLeaderboardId(newId)}
showDifficulties showDifficulties
isLeaderboardPage isLeaderboardPage
leaderboardChanged={id => setSelectedLeaderboardId(id)}
/> />
<LeaderboardInfo leaderboard={currentLeaderboard} beatSaverMap={beatSaverMap} /> <LeaderboardInfo leaderboard={leaderboard.leaderboard} beatSaverMap={leaderboard.beatsaver} />
</main> </main>
); );
} }

@ -2,14 +2,14 @@ import Card from "@/components/card";
import Image from "next/image"; import Image from "next/image";
import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count"; import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count";
import ScoreButtons from "@/components/score/score-buttons"; import ScoreButtons from "@/components/score/score-buttons";
import BeatSaverMap from "@/common/database/types/beatsaver-map"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
type LeaderboardInfoProps = { type LeaderboardInfoProps = {
/** /**
* The leaderboard to display. * The leaderboard to display.
*/ */
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
/** /**
* The beat saver map associated with the leaderboard. * The beat saver map associated with the leaderboard.
@ -46,7 +46,7 @@ export function LeaderboardInfo({ leaderboard, beatSaverMap }: LeaderboardInfoPr
</div> </div>
</div> </div>
<Image <Image
src={leaderboard.coverImage} src={leaderboard.songArt}
alt={`${leaderboard.songName} Cover Image`} alt={`${leaderboard.songName} Cover Image`}
className="rounded-md w-[96px] h-[96px]" className="rounded-md w-[96px] h-[96px]"
width={96} width={96}

@ -1,7 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
type Props = { type Props = {
/** /**
@ -12,11 +12,11 @@ type Props = {
/** /**
* The score to display. * The score to display.
*/ */
score: ScoreSaberScoreToken; score: ScoreSaberScore;
}; };
export default function LeaderboardPlayer({ player, score }: Props) { export default function LeaderboardPlayer({ player, score }: Props) {
const scorePlayer = score.leaderboardPlayerInfo; const scorePlayer = score.playerInfo;
const isPlayerWhoSetScore = player && scorePlayer.id === player.id; const isPlayerWhoSetScore = player && scorePlayer.id === player.id;
return ( return (

@ -4,8 +4,8 @@ import clsx from "clsx";
import { getScoreBadgeFromAccuracy } from "@/common/song-utils"; import { getScoreBadgeFromAccuracy } from "@/common/song-utils";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge"; import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
const badges: ScoreBadge[] = [ const badges: ScoreBadge[] = [
{ {
@ -13,7 +13,7 @@ const badges: ScoreBadge[] = [
color: () => { color: () => {
return "bg-pp"; return "bg-pp";
}, },
create: (score: ScoreSaberScoreToken) => { create: (score: ScoreSaberScore) => {
const pp = score.pp; const pp = score.pp;
if (pp === 0) { if (pp === 0) {
return undefined; return undefined;
@ -23,12 +23,12 @@ const badges: ScoreBadge[] = [
}, },
{ {
name: "Accuracy", name: "Accuracy",
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.baseScore / leaderboard.maxScore) * 100; const acc = (score.score / leaderboard.maxScore) * 100;
return getScoreBadgeFromAccuracy(acc).color; return getScoreBadgeFromAccuracy(acc).color;
}, },
create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.baseScore / leaderboard.maxScore) * 100; const acc = (score.score / leaderboard.maxScore) * 100;
const scoreBadge = getScoreBadgeFromAccuracy(acc); const scoreBadge = getScoreBadgeFromAccuracy(acc);
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`; let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
if (scoreBadge.max == null) { if (scoreBadge.max == null) {
@ -56,12 +56,12 @@ const badges: ScoreBadge[] = [
}, },
{ {
name: "Full Combo", name: "Full Combo",
create: (score: ScoreSaberScoreToken) => { create: (score: ScoreSaberScore) => {
const fullCombo = score.missedNotes === 0; const fullCombo = score.misses === 0;
return ( return (
<> <>
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.missedNotes)}</p> <p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.misses)}</p>
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} /> <XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
</> </>
); );
@ -70,8 +70,8 @@ const badges: ScoreBadge[] = [
]; ];
type Props = { type Props = {
score: ScoreSaberScoreToken; score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
}; };
export default function LeaderboardScoreStats({ score, leaderboard }: Props) { export default function LeaderboardScoreStats({ score, leaderboard }: Props) {

@ -1,9 +1,9 @@
import LeaderboardPlayer from "./leaderboard-player"; import LeaderboardPlayer from "./leaderboard-player";
import LeaderboardScoreStats from "./leaderboard-score-stats"; import LeaderboardScoreStats from "./leaderboard-score-stats";
import ScoreRankInfo from "@/components/score/score-rank-info"; import ScoreRankInfo from "@/components/score/score-rank-info";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
type Props = { type Props = {
/** /**
@ -14,12 +14,12 @@ type Props = {
/** /**
* The score to display. * The score to display.
*/ */
score: ScoreSaberScoreToken; score: ScoreSaberScore;
/** /**
* The leaderboard to display. * The leaderboard to display.
*/ */
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
}; };
export default function LeaderboardScore({ player, score, leaderboard }: Props) { export default function LeaderboardScore({ player, score, leaderboard }: Props) {

@ -11,10 +11,11 @@ import { scoreAnimation } from "@/components/score/score-animation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { getDifficultyFromRawDifficulty } from "@/common/song-utils"; import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts";
type LeaderboardScoresProps = { type LeaderboardScoresProps = {
/** /**
@ -25,18 +26,18 @@ type LeaderboardScoresProps = {
/** /**
* The initial scores to show. * The initial scores to show.
*/ */
initialScores?: ScoreSaberLeaderboardScoresPageToken; initialScores?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
/**
* The leaderboard to display.
*/
leaderboard: ScoreSaberLeaderboard;
/** /**
* The player who set the score. * The player who set the score.
*/ */
player?: ScoreSaberPlayer; player?: ScoreSaberPlayer;
/**
* The leaderboard to display.
*/
leaderboard: ScoreSaberLeaderboardToken;
/** /**
* Whether to show the difficulties. * Whether to show the difficulties.
*/ */
@ -73,17 +74,20 @@ export default function LeaderboardScores({
const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id); const [selectedLeaderboardId, setSelectedLeaderboardId] = useState(leaderboard.id);
const [previousPage, setPreviousPage] = useState(initialPage); const [previousPage, setPreviousPage] = useState(initialPage);
const [currentPage, setCurrentPage] = useState(initialPage); const [currentPage, setCurrentPage] = useState(initialPage);
const [currentScores, setCurrentScores] = useState<ScoreSaberLeaderboardScoresPageToken | undefined>(initialScores); const [currentScores, setCurrentScores] = useState<
PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined
>(initialScores);
const topOfScoresRef = useRef<HTMLDivElement>(null); const topOfScoresRef = useRef<HTMLDivElement>(null);
const [shouldFetch, setShouldFetch] = useState(false); const [shouldFetch, setShouldFetch] = useState(true);
const { const { data, isError, isLoading } = useQuery({
data: scores, queryKey: ["leaderboardScores", selectedLeaderboardId, currentPage],
isError, queryFn: () =>
isLoading, fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
} = useQuery({ "scoresaber",
queryKey: ["leaderboardScores-" + leaderboard.id, selectedLeaderboardId, currentPage], selectedLeaderboardId + "",
queryFn: () => scoresaberService.lookupLeaderboardScores(selectedLeaderboardId + "", currentPage), currentPage
),
staleTime: 30 * 1000, staleTime: 30 * 1000,
enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage, enabled: (shouldFetch && isLeaderboardPage) || !isLeaderboardPage,
}); });
@ -93,9 +97,9 @@ export default function LeaderboardScores({
*/ */
const handleScoreAnimation = useCallback(async () => { const handleScoreAnimation = useCallback(async () => {
await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft"); await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft");
setCurrentScores(scores); setCurrentScores(data);
await controls.start("visible"); await controls.start("visible");
}, [controls, currentPage, previousPage, scores]); }, [controls, currentPage, previousPage, data]);
/** /**
* Set the selected leaderboard. * Set the selected leaderboard.
@ -118,10 +122,10 @@ export default function LeaderboardScores({
* Set the current scores. * Set the current scores.
*/ */
useEffect(() => { useEffect(() => {
if (scores) { if (data) {
handleScoreAnimation(); handleScoreAnimation();
} }
}, [scores, handleScoreAnimation]); }, [data, handleScoreAnimation]);
/** /**
* Handle scrolling to the top of the * Handle scrolling to the top of the
@ -185,17 +189,19 @@ export default function LeaderboardScores({
variants={scoreAnimation} variants={scoreAnimation}
className="grid min-w-full grid-cols-1 divide-y divide-border" className="grid min-w-full grid-cols-1 divide-y divide-border"
> >
{currentScores.scores.map((playerScore, index) => ( {currentScores.scores.map((playerScore, index) => {
<motion.div key={index} variants={scoreAnimation}> return (
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} /> <motion.div key={index} variants={scoreAnimation}>
</motion.div> <LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
))} </motion.div>
);
})}
</motion.div> </motion.div>
<Pagination <Pagination
mobilePagination={width < 768} mobilePagination={width < 768}
page={currentPage} page={currentPage}
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)} totalPages={currentScores.metadata.totalPages}
loadingPage={isLoading ? currentPage : undefined} loadingPage={isLoading ? currentPage : undefined}
generatePageUrl={page => { generatePageUrl={page => {
return `/leaderboard/${selectedLeaderboardId}/${page}`; return `/leaderboard/${selectedLeaderboardId}/${page}`;

@ -1,13 +1,12 @@
import { songDifficultyToColor } from "@/common/song-utils"; import { songDifficultyToColor } from "@/common/song-utils";
import { StarIcon } from "@heroicons/react/24/solid"; import { StarIcon } from "@heroicons/react/24/solid";
import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
type LeaderboardSongStarCountProps = { type LeaderboardSongStarCountProps = {
/** /**
* The leaderboard for the song * The leaderboard for the song
*/ */
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
}; };
export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCountProps) { export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCountProps) {
@ -15,12 +14,11 @@ export function LeaderboardSongStarCount({ leaderboard }: LeaderboardSongStarCou
return null; return null;
} }
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
return ( return (
<div <div
className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default" className="w-fit h-[20px] rounded-sm flex justify-center items-center text-xs cursor-default"
style={{ style={{
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255) backgroundColor: songDifficultyToColor(leaderboard.difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255)
}} }}
> >
<div className="flex gap-1 items-center justify-center p-1"> <div className="flex gap-1 items-center justify-center p-1">

@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart"; import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
import { getValueFromHistory } from "@/common/player-utils"; import { getValueFromHistory } from "@/common/player-utils";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { parseDate } from "@ssr/common/utils/time-utils"; import { parseDate } from "@ssr/common/utils/time-utils";
type Props = { type Props = {

@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import { DatasetConfig } from "@/components/chart/generic-chart"; import { DatasetConfig } from "@/components/chart/generic-chart";
import GenericPlayerChart from "@/components/player/chart/generic-player-chart"; import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { isWholeNumber } from "@ssr/common/utils/number-utils"; import { isWholeNumber } from "@ssr/common/utils/number-utils";
type Props = { type Props = {

@ -6,7 +6,7 @@ import Tooltip from "@/components/tooltip";
import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart"; import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart";
import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
import { TrendingUpIcon } from "lucide-react"; import { TrendingUpIcon } from "lucide-react";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
type PlayerChartsProps = { type PlayerChartsProps = {
/** /**

@ -4,7 +4,7 @@ import { formatNumberWithCommas, isWholeNumber } from "@ssr/common/utils/number-
import React from "react"; import React from "react";
import { DatasetConfig } from "@/components/chart/generic-chart"; import { DatasetConfig } from "@/components/chart/generic-chart";
import GenericPlayerChart from "@/components/player/chart/generic-player-chart"; import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
type Props = { type Props = {
player: ScoreSaberPlayer; player: ScoreSaberPlayer;

@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
type Props = { type Props = {
player: ScoreSaberPlayer; player: ScoreSaberPlayer;

@ -10,30 +10,26 @@ import { useIsMobile } from "@/hooks/use-is-mobile";
import { useIsVisible } from "@/hooks/use-is-visible"; import { useIsVisible } from "@/hooks/use-is-visible";
import { useRef } from "react"; import { useRef } from "react";
import PlayerCharts from "@/components/player/chart/player-charts"; import PlayerCharts from "@/components/player/chart/player-charts";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token";
import { ScoreSort } from "@ssr/common/types/score/score-sort";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import useDatabase from "@/hooks/use-database"; import useDatabase from "@/hooks/use-database";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
import { ScoreSort } from "@ssr/common/score/score-sort";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import PlayerScoresResponse from "../../../../common/src/response/player-scores-response.ts";
const REFRESH_INTERVAL = 1000 * 60 * 5; const REFRESH_INTERVAL = 1000 * 60 * 5;
type Props = { type Props = {
initialPlayerData: ScoreSaberPlayer; initialPlayerData: ScoreSaberPlayer;
initialScoreData?: ScoreSaberPlayerScoresPageToken; initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
initialSearch?: string; initialSearch?: string;
sort: ScoreSort; sort: ScoreSort;
page: number; page: number;
}; };
export default function PlayerData({ export default function PlayerData({ initialPlayerData, initialScoreData, initialSearch, sort, page }: Props) {
initialPlayerData: initialPlayerData,
initialScoreData,
initialSearch,
sort,
page,
}: Props) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const miniRankingsRef = useRef<HTMLDivElement>(null); const miniRankingsRef = useRef<HTMLDivElement>(null);
const isMiniRankingsVisible = useIsVisible(miniRankingsRef); const isMiniRankingsVisible = useIsVisible(miniRankingsRef);

@ -8,7 +8,7 @@ import PlayerStats from "./player-stats";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { ReactElement } from "react"; import { ReactElement } from "react";
import PlayerTrackedStatus from "@/components/player/player-tracked-status"; import PlayerTrackedStatus from "@/components/player/player-tracked-status";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import Link from "next/link"; import Link from "next/link";
import { capitalizeFirstLetter } from "@/common/string-utils"; import { capitalizeFirstLetter } from "@/common/string-utils";
import AddFriend from "@/components/friend/add-friend"; import AddFriend from "@/components/friend/add-friend";

@ -12,14 +12,16 @@ import { Input } from "@/components/ui/input";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useDebounce } from "@uidotdev/usehooks"; import { useDebounce } from "@uidotdev/usehooks";
import { scoreAnimation } from "@/components/score/score-animation"; import { scoreAnimation } from "@/components/score/score-animation";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; import { ScoreSort } from "@ssr/common/score/score-sort";
import { ScoreSort } from "@ssr/common/types/score/score-sort";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { setCookieValue } from "@ssr/common/utils/cookie-utils"; import { setCookieValue } from "@ssr/common/utils/cookie-utils";
import ScoreSaberScore from "@ssr/common/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";
type Props = { type Props = {
initialScoreData?: ScoreSaberPlayerScoresPageToken; initialScoreData?: PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
initialSearch?: string; initialSearch?: string;
player: ScoreSaberPlayer; player: ScoreSaberPlayer;
sort: ScoreSort; sort: ScoreSort;
@ -50,27 +52,25 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
const [pageState, setPageState] = useState<PageState>({ page, sort }); const [pageState, setPageState] = useState<PageState>({ page, sort });
const [previousPage, setPreviousPage] = useState(page); const [previousPage, setPreviousPage] = useState(page);
const [currentScores, setCurrentScores] = useState<ScoreSaberPlayerScoresPageToken | undefined>(initialScoreData); const [scores, setScores] = useState<PlayerScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined>(
initialScoreData
);
const [searchTerm, setSearchTerm] = useState(initialSearch || ""); const [searchTerm, setSearchTerm] = useState(initialSearch || "");
const debouncedSearchTerm = useDebounce(searchTerm, 250); const debouncedSearchTerm = useDebounce(searchTerm, 250);
const [shouldFetch, setShouldFetch] = useState(false); const [shouldFetch, setShouldFetch] = useState(false);
const topOfScoresRef = useRef<HTMLDivElement>(null); const topOfScoresRef = useRef<HTMLDivElement>(null);
const isSearchActive = debouncedSearchTerm.length >= 3; const isSearchActive = debouncedSearchTerm.length >= 3;
const { const { data, isError, isLoading } = useQuery({
data: scores,
isError,
isLoading,
} = useQuery({
queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm], queryKey: ["playerScores", player.id, pageState, debouncedSearchTerm],
queryFn: () => { queryFn: () =>
return scoresaberService.lookupPlayerScores({ fetchPlayerScores<ScoreSaberScore, ScoreSaberLeaderboard>(
playerId: player.id, "scoresaber",
page: pageState.page, player.id,
sort: pageState.sort, pageState.page,
...(isSearchActive && { search: debouncedSearchTerm }), pageState.sort,
}); debouncedSearchTerm
}, ),
staleTime: 30 * 1000, // 30 seconds staleTime: 30 * 1000, // 30 seconds
enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0), enabled: shouldFetch && (debouncedSearchTerm.length >= 3 || debouncedSearchTerm.length === 0),
}); });
@ -80,9 +80,9 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
*/ */
const handleScoreAnimation = useCallback(async () => { const handleScoreAnimation = useCallback(async () => {
await controls.start(previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft"); await controls.start(previousPage >= pageState.page ? "hiddenRight" : "hiddenLeft");
setCurrentScores(scores); setScores(data);
await controls.start("visible"); await controls.start("visible");
}, [scores, controls, previousPage, pageState.page]); }, [controls, previousPage, pageState.page, data]);
/** /**
* Change the score sort. * Change the score sort.
@ -122,8 +122,10 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
* Handle score animation. * Handle score animation.
*/ */
useEffect(() => { useEffect(() => {
if (scores) handleScoreAnimation(); if (data) {
}, [scores, handleScoreAnimation]); handleScoreAnimation();
}
}, [data, handleScoreAnimation]);
/** /**
* Gets the URL to the page. * Gets the URL to the page.
@ -203,10 +205,10 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
</div> </div>
</div> </div>
{currentScores && ( {scores !== undefined && (
<> <>
<div className="text-center"> <div className="text-center">
{isError || (currentScores.playerScores.length === 0 && <p>No scores found. Invalid Page or Search?</p>)} {isError || (scores.scores.length === 0 && <p>No scores found. Invalid Page or Search?</p>)}
</div> </div>
<motion.div <motion.div
@ -215,9 +217,14 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
variants={scoreAnimation} variants={scoreAnimation}
className="grid min-w-full grid-cols-1 divide-y divide-border" className="grid min-w-full grid-cols-1 divide-y divide-border"
> >
{currentScores.playerScores.map((playerScore, index) => ( {scores.scores.map((score, index) => (
<motion.div key={index} variants={scoreAnimation}> <motion.div key={score.score.id} variants={scoreAnimation}>
<Score player={player} playerScore={playerScore} /> <Score
player={player}
score={score.score}
leaderboard={score.leaderboard}
beatSaverMap={score.beatSaver}
/>
</motion.div> </motion.div>
))} ))}
</motion.div> </motion.div>
@ -225,7 +232,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
<Pagination <Pagination
mobilePagination={width < 768} mobilePagination={width < 768}
page={pageState.page} page={pageState.page}
totalPages={Math.ceil(currentScores.metadata.total / currentScores.metadata.itemsPerPage)} totalPages={scores.metadata.totalPages}
loadingPage={isLoading ? pageState.page : undefined} loadingPage={isLoading ? pageState.page : undefined}
generatePageUrl={page => { generatePageUrl={page => {
return getUrl(page); return getUrl(page);

@ -1,6 +1,6 @@
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils"; import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import StatValue from "@/components/stat-value"; import StatValue from "@/components/stat-value";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { formatDate } from "@ssr/common/utils/time-utils"; import { formatDate } from "@ssr/common/utils/time-utils";
import { ReactNode } from "react"; import { ReactNode } from "react";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";

@ -6,7 +6,7 @@ import Tooltip from "@/components/tooltip";
import { InformationCircleIcon } from "@heroicons/react/16/solid"; import { InformationCircleIcon } from "@heroicons/react/16/solid";
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils"; import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since"; import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
type Props = { type Props = {

@ -7,7 +7,7 @@ import Card from "../card";
import CountryFlag from "../country-flag"; import CountryFlag from "../country-flag";
import { Avatar, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarImage } from "../ui/avatar";
import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton"; import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token"; import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";

@ -1,17 +1,14 @@
import StatValue from "@/components/stat-value"; import StatValue from "@/components/stat-value";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
/** /**
* A badge to display in the score stats. * A badge to display in the score stats.
*/ */
export type ScoreBadge = { export type ScoreBadge = {
name: string; name: string;
color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined; color?: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | undefined;
create: ( create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => string | React.ReactNode | undefined;
score: ScoreSaberScoreToken,
leaderboard: ScoreSaberLeaderboardToken
) => string | React.ReactNode | undefined;
}; };
/** /**
@ -19,8 +16,8 @@ export type ScoreBadge = {
*/ */
type ScoreBadgeProps = { type ScoreBadgeProps = {
badges: ScoreBadge[]; badges: ScoreBadge[];
score: ScoreSaberScoreToken; score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
}; };
export function ScoreBadges({ badges, score, leaderboard }: ScoreBadgeProps) { export function ScoreBadges({ badges, score, leaderboard }: ScoreBadgeProps) {

@ -1,6 +1,5 @@
"use client"; "use client";
import BeatSaverMap from "@/common/database/types/beatsaver-map";
import { songNameToYouTubeLink } from "@/common/youtube-utils"; import { songNameToYouTubeLink } from "@/common/youtube-utils";
import BeatSaverLogo from "@/components/logos/beatsaver-logo"; import BeatSaverLogo from "@/components/logos/beatsaver-logo";
import YouTubeLogo from "@/components/logos/youtube-logo"; import YouTubeLogo from "@/components/logos/youtube-logo";
@ -8,19 +7,20 @@ import { useToast } from "@/hooks/use-toast";
import { useState } from "react"; import { useState } from "react";
import ScoreButton from "./score-button"; import ScoreButton from "./score-button";
import { copyToClipboard } from "@/common/browser-utils"; import { copyToClipboard } from "@/common/browser-utils";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { ArrowDownIcon } from "@heroicons/react/24/solid"; import { ArrowDownIcon } from "@heroicons/react/24/solid";
import clsx from "clsx"; import clsx from "clsx";
import ScoreEditorButton from "@/components/score/score-editor-button"; import ScoreEditorButton from "@/components/score/score-editor-button";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
type Props = { type Props = {
score?: ScoreSaberScoreToken; score?: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
beatSaverMap?: BeatSaverMap; beatSaverMap?: BeatSaverMap;
alwaysSingleLine?: boolean; alwaysSingleLine?: boolean;
setIsLeaderboardExpanded?: (isExpanded: boolean) => void; setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
updateScore?: (score: ScoreSaberScoreToken) => void; updateScore?: (score: ScoreSaberScore) => void;
}; };
export default function ScoreButtons({ export default function ScoreButtons({
@ -35,7 +35,7 @@ export default function ScoreButtons({
const { toast } = useToast(); const { toast } = useToast();
return ( return (
<div className="flex justify-end gap-2 h-[64px]"> <div className={`flex justify-end gap-2 h-[${alwaysSingleLine ? "32" : "64"}px]`}>
<div <div
className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`} className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`}
> >
@ -90,7 +90,7 @@ export default function ScoreButtons({
{/* View Leaderboard button */} {/* View Leaderboard button */}
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && ( {leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
<div className="pr-2 flex items-center justify-center cursor-default"> <div className="flex items-center justify-center cursor-default">
<ArrowDownIcon <ArrowDownIcon
className={clsx( className={clsx(
"w-6 h-6 transition-all transform-gpu cursor-pointer", "w-6 h-6 transition-all transform-gpu cursor-pointer",

@ -1,44 +1,44 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CogIcon } from "@heroicons/react/24/solid"; import { CogIcon } from "@heroicons/react/24/solid";
import clsx from "clsx"; import clsx from "clsx";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
import { useState } from "react"; import { useState } from "react";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ResetIcon } from "@radix-ui/react-icons"; import { ResetIcon } from "@radix-ui/react-icons";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
type ScoreEditorButtonProps = { type ScoreEditorButtonProps = {
score: ScoreSaberScoreToken; score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
updateScore: (score: ScoreSaberScoreToken) => void; updateScore: (score: ScoreSaberScore) => void;
}; };
export default function ScoreEditorButton({ score, leaderboard, updateScore }: ScoreEditorButtonProps) { export default function ScoreEditorButton({ score, leaderboard, updateScore }: ScoreEditorButtonProps) {
const [isScoreEditMode, setIsScoreEditMode] = useState(false); const [isScoreEditMode, setIsScoreEditMode] = useState(false);
const maxScore = leaderboard.maxScore || 1; // Use 1 to prevent division by zero const maxScore = leaderboard.maxScore || 1; // Use 1 to prevent division by zero
const accuracy = (score.baseScore / maxScore) * 100; const accuracy = (score.score / maxScore) * 100;
const handleSliderChange = (value: number[]) => { const handleSliderChange = (value: number[]) => {
const newAccuracy = Math.max(0, Math.min(value[0], 100)); // Ensure the accuracy stays within 0-100 const newAccuracy = Math.max(0, Math.min(value[0], 100)); // Ensure the accuracy stays within 0-100
const newBaseScore = (newAccuracy / 100) * maxScore; const newBaseScore = (newAccuracy / 100) * maxScore;
updateScore({ updateScore({
...score, ...score,
baseScore: newBaseScore, score: newBaseScore,
}); });
}; };
const handleSliderReset = () => { const handleSliderReset = () => {
updateScore({ updateScore({
...score, ...score,
baseScore: (accuracy / 100) * maxScore, score: (accuracy / 100) * maxScore,
}); });
}; };
return ( return (
<div className="pr-2 flex items-center justify-center cursor-default relative"> <div className="flex items-center justify-center cursor-default relative">
<Popover <Popover
onOpenChange={open => { onOpenChange={open => {
setIsScoreEditMode(open); setIsScoreEditMode(open);

@ -49,7 +49,7 @@ export default function ScoreFeed() {
</Link> </Link>
</p> </p>
<Score <Score
playerScore={score} score={score}
settings={{ settings={{
noScoreButtons: true, noScoreButtons: true,
}} }}

@ -1,31 +1,30 @@
import BeatSaverMap from "@/common/database/types/beatsaver-map";
import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils";
import FallbackLink from "@/components/fallback-link"; import FallbackLink from "@/components/fallback-link";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { StarIcon } from "@heroicons/react/24/solid"; import { StarIcon } from "@heroicons/react/24/solid";
import Image from "next/image"; import Image from "next/image";
import { songDifficultyToColor } from "@/common/song-utils"; import { songDifficultyToColor } from "@/common/song-utils";
import Link from "next/link"; import Link from "next/link";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver-map";
type Props = { type Props = {
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
beatSaverMap?: BeatSaverMap; beatSaverMap?: BeatSaverMap;
}; };
export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) { export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
const diff = getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty);
const mappersProfile = const mappersProfile =
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined; beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.author.authorId}` : undefined;
const starCount = leaderboard.stars; const starCount = leaderboard.stars;
const difficulty = leaderboard.difficulty;
return ( return (
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="relative flex justify-center h-[64px]"> <div className="relative flex justify-center h-[64px]">
<Tooltip <Tooltip
display={ display={
<> <>
<p>Difficulty: {diff}</p> <p>Difficulty: {difficulty.difficulty}</p>
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>} {starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
</> </>
} }
@ -33,7 +32,7 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
<div <div
className="absolute w-full h-[18px] bottom-0 right-0 rounded-sm flex justify-center items-center text-[0.70rem] cursor-default" className="absolute w-full h-[18px] bottom-0 right-0 rounded-sm flex justify-center items-center text-[0.70rem] cursor-default"
style={{ style={{
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255) backgroundColor: songDifficultyToColor(difficulty.difficultyRaw) + "f0", // Transparency value (in hex 0-255)
}} }}
> >
{starCount > 0 ? ( {starCount > 0 ? (
@ -42,13 +41,13 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
<StarIcon className="w-[14px] h-[14px]" /> <StarIcon className="w-[14px] h-[14px]" />
</div> </div>
) : ( ) : (
<p>{diff}</p> <p>{difficulty.difficulty}</p>
)} )}
</div> </div>
</Tooltip> </Tooltip>
<Image <Image
unoptimized unoptimized
src={`https://img.fascinated.cc/upload/w_64,h_64/${leaderboard.coverImage}`} src={`https://img.fascinated.cc/upload/w_64,h_64/${leaderboard.songArt}`}
width={64} width={64}
height={64} height={64}
alt="Song Artwork" alt="Song Artwork"

@ -2,15 +2,15 @@ import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import { format } from "@formkit/tempo"; import { format } from "@formkit/tempo";
import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
import Tooltip from "../tooltip"; import Tooltip from "../tooltip";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
import { timeAgo } from "@ssr/common/utils/time-utils"; import { timeAgo } from "@ssr/common/utils/time-utils";
import Link from "next/link"; import Link from "next/link";
import { getPageFromRank } from "@ssr/common/utils/utils"; import { getPageFromRank } from "@ssr/common/utils/utils";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
type Props = { type Props = {
score: ScoreSaberScoreToken; score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
}; };
export default function ScoreRankInfo({ score, leaderboard }: Props) { export default function ScoreRankInfo({ score, leaderboard }: Props) {
@ -29,13 +29,13 @@ export default function ScoreRankInfo({ score, leaderboard }: Props) {
display={ display={
<p> <p>
{format({ {format({
date: new Date(score.timeSet), date: new Date(score.timestamp),
format: "DD MMMM YYYY HH:mm a", format: "DD MMMM YYYY HH:mm a",
})} })}
</p> </p>
} }
> >
<p className="text-sm cursor-default select-none">{timeAgo(new Date(score.timeSet))}</p> <p className="text-sm cursor-default select-none">{timeAgo(new Date(score.timestamp))}</p>
</Tooltip> </Tooltip>
</div> </div>
); );

@ -4,8 +4,8 @@ import { XMarkIcon } from "@heroicons/react/24/solid";
import clsx from "clsx"; import clsx from "clsx";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge"; import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
const badges: ScoreBadge[] = [ const badges: ScoreBadge[] = [
{ {
@ -13,12 +13,13 @@ const badges: ScoreBadge[] = [
color: () => { color: () => {
return "bg-pp"; return "bg-pp";
}, },
create: (score: ScoreSaberScoreToken) => { create: (score: ScoreSaberScore) => {
const pp = score.pp; const pp = score.pp;
if (pp === 0) { const weight = score.weight;
if (pp === 0 || pp === undefined || weight === undefined) {
return undefined; return undefined;
} }
const weightedPp = pp * score.weight; const weightedPp = pp * weight;
return ( return (
<> <>
@ -26,7 +27,7 @@ const badges: ScoreBadge[] = [
display={ display={
<div> <div>
<p> <p>
Weighted: {formatPp(weightedPp)}pp ({(100 * score.weight).toFixed(2)}%) Weighted: {formatPp(weightedPp)}pp ({(100 * weight).toFixed(2)}%)
</p> </p>
</div> </div>
} }
@ -39,12 +40,12 @@ const badges: ScoreBadge[] = [
}, },
{ {
name: "Accuracy", name: "Accuracy",
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.baseScore / leaderboard.maxScore) * 100; const acc = (score.score / leaderboard.maxScore) * 100;
return getScoreBadgeFromAccuracy(acc).color; return getScoreBadgeFromAccuracy(acc).color;
}, },
create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => { create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.baseScore / leaderboard.maxScore) * 100; const acc = (score.score / leaderboard.maxScore) * 100;
const scoreBadge = getScoreBadgeFromAccuracy(acc); const scoreBadge = getScoreBadgeFromAccuracy(acc);
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`; let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
if (scoreBadge.max == null) { if (scoreBadge.max == null) {
@ -72,8 +73,8 @@ const badges: ScoreBadge[] = [
}, },
{ {
name: "Score", name: "Score",
create: (score: ScoreSaberScoreToken) => { create: (score: ScoreSaberScore) => {
return `${formatNumberWithCommas(Number(score.baseScore.toFixed(0)))}`; return `${formatNumberWithCommas(Number(score.score.toFixed(0)))}`;
}, },
}, },
{ {
@ -86,14 +87,14 @@ const badges: ScoreBadge[] = [
}, },
{ {
name: "Full Combo", name: "Full Combo",
create: (score: ScoreSaberScoreToken) => { create: (score: ScoreSaberScore) => {
return ( return (
<Tooltip <Tooltip
display={ display={
<div className="flex flex-col justify-center items-center"> <div className="flex flex-col justify-center items-center">
{!score.fullCombo ? ( {!score.fullCombo ? (
<> <>
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p> <p>Missed Notes: {formatNumberWithCommas(score.misses)}</p>
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p> <p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p>
</> </>
) : ( ) : (
@ -107,7 +108,7 @@ const badges: ScoreBadge[] = [
{score.fullCombo ? ( {score.fullCombo ? (
<span className="text-green-400">FC</span> <span className="text-green-400">FC</span>
) : ( ) : (
formatNumberWithCommas(score.missedNotes + score.badCuts) formatNumberWithCommas(score.misses + score.badCuts)
)} )}
</p> </p>
<XMarkIcon className={clsx("w-5 h-5", score.fullCombo ? "hidden" : "text-red-400")} /> <XMarkIcon className={clsx("w-5 h-5", score.fullCombo ? "hidden" : "text-red-400")} />
@ -119,8 +120,8 @@ const badges: ScoreBadge[] = [
]; ];
type Props = { type Props = {
score: ScoreSaberScoreToken; score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboardToken; leaderboard: ScoreSaberLeaderboard;
}; };
export default function ScoreStats({ score, leaderboard }: Props) { export default function ScoreStats({ score, leaderboard }: Props) {

@ -1,18 +1,18 @@
"use client"; "use client";
import BeatSaverMap from "@/common/database/types/beatsaver-map";
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ScoreButtons from "./score-buttons"; import ScoreButtons from "./score-buttons";
import ScoreSongInfo from "./score-info"; import ScoreSongInfo from "./score-info";
import ScoreRankInfo from "./score-rank-info"; import ScoreRankInfo from "./score-rank-info";
import ScoreStats from "./score-stats"; import ScoreStats from "./score-stats";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
import { lookupBeatSaverMap } from "@/common/beatsaver-utils";
import { getPageFromRank } from "@ssr/common/utils/utils"; import { getPageFromRank } from "@ssr/common/utils/utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
type Props = { type Props = {
/** /**
@ -20,10 +20,20 @@ type Props = {
*/ */
player?: ScoreSaberPlayer; player?: ScoreSaberPlayer;
/**
* The leaderboard.
*/
leaderboard: ScoreSaberLeaderboard;
/**
* The beat saver map for this song.
*/
beatSaverMap?: BeatSaverMap;
/** /**
* The score to display. * The score to display.
*/ */
playerScore: ScoreSaberPlayerScoreToken; score: ScoreSaberScore;
/** /**
* Score settings * Score settings
@ -33,36 +43,18 @@ type Props = {
}; };
}; };
export default function Score({ player, playerScore, settings }: Props) { export default function Score({ player, leaderboard, beatSaverMap, score, settings }: Props) {
const { score, leaderboard } = playerScore; const [baseScore, setBaseScore] = useState<number>(score.score);
const [baseScore, setBaseScore] = useState<number>(score.baseScore);
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
const fetchBeatSaverData = useCallback(async () => {
// No need to fetch if no buttons
if (settings?.noScoreButtons == true) {
return;
}
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
setBeatSaverMap(beatSaverMapData);
}, [leaderboard.songHash, settings?.noScoreButtons]);
/** /**
* Set the base score * Set the base score
*/ */
useEffect(() => { useEffect(() => {
if (playerScore?.score?.baseScore) { if (score?.score) {
setBaseScore(playerScore.score.baseScore); setBaseScore(score.score);
} }
}, [playerScore]); }, [score]);
/**
* Fetch the beatSaver data on page load
*/
useEffect(() => {
fetchBeatSaverData();
}, [fetchBeatSaverData]);
/** /**
* Close the leaderboard when the score changes * Close the leaderboard when the score changes
@ -72,7 +64,7 @@ export default function Score({ player, playerScore, settings }: Props) {
}, [score]); }, [score]);
const accuracy = (baseScore / leaderboard.maxScore) * 100; const accuracy = (baseScore / leaderboard.maxScore) * 100;
const pp = baseScore === score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy); const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
// Dynamic grid column classes // Dynamic grid column classes
const gridColsClass = settings?.noScoreButtons const gridColsClass = settings?.noScoreButtons
@ -92,14 +84,14 @@ export default function Score({ player, playerScore, settings }: Props) {
score={score} score={score}
setIsLeaderboardExpanded={setIsLeaderboardExpanded} setIsLeaderboardExpanded={setIsLeaderboardExpanded}
updateScore={score => { updateScore={score => {
setBaseScore(score.baseScore); setBaseScore(score.score);
}} }}
/> />
)} )}
<ScoreStats <ScoreStats
score={{ score={{
...score, ...score,
baseScore, score: baseScore,
pp: pp ? pp : score.pp, pp: pp ? pp : score.pp,
}} }}
leaderboard={leaderboard} leaderboard={leaderboard}