add score history viewing
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 34s
Deploy Website / docker (ubuntu-latest) (push) Failing after 33s

This commit is contained in:
Lee 2024-10-25 21:29:57 +01:00
parent 9fb5317bc8
commit 97fba47fd8
10 changed files with 417 additions and 112 deletions

@ -54,6 +54,7 @@ export async function initDiscordBot() {
* @param message the message to log
*/
export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) {
try {
const channel = await client.channels.fetch(channelId);
if (channel == undefined) {
throw new Error(`Channel "${channelId}" not found`);
@ -63,4 +64,7 @@ export async function logToChannel(channelId: DiscordChannels, message: EmbedBui
}
channel.send({ embeds: [message] });
} catch (error) {
console.error(error);
}
}

@ -52,4 +52,25 @@ export default class ScoresController {
}): Promise<unknown> {
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
}
@Get("/history/:playerId/:leaderboardId/:page", {
config: {},
params: t.Object({
playerId: t.String({ required: true }),
leaderboardId: t.String({ required: true }),
page: t.Number({ required: true }),
}),
})
public async getScoreHistory({
params: { playerId, leaderboardId, page },
}: {
params: {
playerId: string;
leaderboardId: string;
page: number;
};
query: { search?: string };
}): Promise<unknown> {
return (await ScoreService.getPreviousScores(playerId, leaderboardId, page)).toJSON();
}
}

@ -27,12 +27,13 @@ import {
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement";
import { ScoreType } from "@ssr/common/model/score/score";
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
import { ScoreSaberScore, ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
import { Page, Pagination } from "@ssr/common/pagination";
const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute
@ -487,4 +488,59 @@ export class ScoreService {
}
);
}
/**
* Gets the previous scores for a player.
*
* @param playerId the player's id to get the previous scores for
* @param leaderboardId the leaderboard to get the previous scores on
* @param page the page to get
*/
public static async getPreviousScores(
playerId: string,
leaderboardId: string,
page: number
): Promise<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>> {
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId })
.sort({ timestamp: -1 })
.skip(1);
if (scores == null || scores.length == 0) {
throw new NotFoundError(`No previous scores found for ${playerId} in ${leaderboardId}`);
}
return new Pagination<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>()
.setItemsPerPage(8)
.setTotalItems(scores.length)
.getPage(page, async () => {
const toReturn: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = [];
for (const score of scores) {
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
"scoresaber",
leaderboardId
);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardId}" not found`);
}
const { leaderboard, beatsaver } = leaderboardResponse;
const additionalData = await this.getAdditionalScoreData(
playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
);
if (additionalData !== undefined) {
score.additionalData = additionalData;
}
toReturn.push({
score: score as unknown as ScoreSaberScore,
leaderboard: leaderboard,
beatSaver: beatsaver,
});
}
return toReturn;
});
}
}

@ -12,7 +12,7 @@ export default class Score {
* The internal score id.
*/
@prop()
private _id?: number;
public _id?: number;
/**
* The id of the player who set the score.

@ -0,0 +1,103 @@
import { NotFoundError } from "backend/src/error/not-found-error";
import { Metadata } from "./types/metadata";
type FetchItemsFunction<T> = (fetchItems: FetchItems) => Promise<T[]>;
export class Pagination<T> {
private itemsPerPage: number = 0;
private totalItems: number = 0;
private items: T[] | null = null; // Optional array to hold set items
/**
* Sets the number of items per page.
* @param itemsPerPage - The number of items per page.
* @returns the pagination
*/
setItemsPerPage(itemsPerPage: number): Pagination<T> {
this.itemsPerPage = itemsPerPage;
return this;
}
/**
* Sets the items to paginate.
* @param items - The items to paginate.
* @returns the pagination
*/
setItems(items: T[]): Pagination<T> {
this.items = items;
this.totalItems = items.length;
return this;
}
/**
* Sets the total number of items.
* @param totalItems - Total number of items.
* @returns the pagination
*/
setTotalItems(totalItems: number): Pagination<T> {
this.totalItems = totalItems;
return this;
}
/**
* Gets a page of items, using either static items or a dynamic fetchItems callback.
* @param page - The page number to retrieve.
* @param fetchItems - The async function to fetch items if setItems was not used.
* @returns A promise resolving to the page of items.
* @throws throws an error if the page number is invalid.
*/
async getPage(page: number, fetchItems?: FetchItemsFunction<T>): Promise<Page<T>> {
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
if (page < 1 || page > totalPages) {
throw new NotFoundError("Invalid page number");
}
// Calculate the range of items to fetch for the current page
const start = (page - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
let pageItems: T[];
// Use set items if they are present, otherwise use fetchItems callback
if (this.items) {
pageItems = this.items.slice(start, end);
} else if (fetchItems) {
pageItems = await fetchItems(new FetchItems(start, end));
} else {
throw new Error("Items function is not set and no fetchItems callback provided");
}
return new Page<T>(pageItems, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
}
}
class FetchItems {
readonly start: number;
readonly end: number;
constructor(start: number, end: number) {
this.start = start;
this.end = end;
}
}
export class Page<T> {
readonly items: T[];
readonly metadata: Metadata;
constructor(items: T[], metadata: Metadata) {
this.items = items;
this.metadata = metadata;
}
/**
* Converts the page to a JSON object.
*/
toJSON() {
return {
items: this.items,
metadata: this.metadata,
};
}
}

@ -4,6 +4,23 @@ import PlayerScoresResponse from "../response/player-scores-response";
import { Config } from "../config";
import { ScoreSort } from "../score/score-sort";
import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
import { Page } from "../pagination";
import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score";
import { PlayerScore } from "../score/player-score";
import ScoreSaberLeaderboard from "../leaderboard/impl/scoresaber-leaderboard";
/**
* Fetches the player's scores
*
* @param playerId the id of the player
* @param leaderboardId the id of the leaderboard
* @param page the page
*/
export async function fetchPlayerScoresHistory(playerId: string, leaderboardId: string, page: number) {
return kyFetch<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>>(
`${Config.apiUrl}/scores/history/${playerId}/${leaderboardId}/${page}`
);
}
/**
* Fetches the player's scores

@ -21,6 +21,8 @@ type Props = {
leaderboard: ScoreSaberLeaderboard;
beatSaverMap?: BeatSaverMap;
alwaysSingleLine?: boolean;
hideLeaderboardDropdown?: boolean;
hideAccuracyChanger?: boolean;
isLeaderboardLoading?: boolean;
setIsLeaderboardExpanded?: (isExpanded: boolean) => void;
updateScore?: (score: ScoreSaberScore) => void;
@ -31,6 +33,8 @@ export default function ScoreButtons({
leaderboard,
beatSaverMap,
alwaysSingleLine,
hideLeaderboardDropdown,
hideAccuracyChanger,
isLeaderboardLoading,
setIsLeaderboardExpanded,
updateScore,
@ -103,12 +107,12 @@ export default function ScoreButtons({
className={`flex gap-2 ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}
>
{/* Edit score button */}
{score && leaderboard && updateScore && (
{score && leaderboard && updateScore && !hideAccuracyChanger && (
<ScoreEditorButton score={score} leaderboard={leaderboard} updateScore={updateScore} />
)}
{/* View Leaderboard button */}
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && (
{leaderboardExpanded != undefined && setIsLeaderboardExpanded != undefined && !hideLeaderboardDropdown && (
<div className="flex items-center justify-center cursor-default">
{isLeaderboardLoading ? (
<ArrowPathIcon className="w-5 h-5 animate-spin" />

@ -0,0 +1,63 @@
"use client";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import Score from "@/components/score/score";
import { fetchPlayerScoresHistory } from "@ssr/common/utils/score-utils";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import Pagination from "@/components/input/pagination";
import { useIsMobile } from "@/hooks/use-is-mobile";
type ScoreHistoryProps = {
/**
* The player who set this score.
*/
playerId: string;
/**
* The leaderboard the score was set on.
*/
leaderboard: ScoreSaberLeaderboard;
};
export function ScoreHistory({ playerId, leaderboard }: ScoreHistoryProps) {
const isMobile = useIsMobile();
const [page, setPage] = useState(1);
const { data, isError, isLoading } = useQuery({
queryKey: [`scoresHistory:${leaderboard.id}`, leaderboard.id, page],
queryFn: async () => fetchPlayerScoresHistory(playerId, leaderboard.id + "", page),
staleTime: 30 * 1000,
});
if (!data || isError) {
return <p className="text-center">No score history found.</p>;
}
return (
<>
{data.items.map(({ score, leaderboard, beatSaver }) => (
<Score
key={score.scoreId}
score={score}
leaderboard={leaderboard}
beatSaverMap={beatSaver}
settings={{
hideLeaderboardDropdown: true,
hideAccuracyChanger: true,
}}
/>
))}
<Pagination
mobilePagination={isMobile}
page={page}
totalPages={data.metadata.totalPages}
loadingPage={isLoading ? page : undefined}
onPageChange={newPage => {
setPage(newPage);
}}
/>
</>
);
}

@ -0,0 +1,47 @@
import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart";
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
type ScoreOverviewProps = {
/**
* The initial page to show
*/
initialPage: number;
/**
* The score stats for this score.
*/
scoreStats?: ScoreStatsToken;
/**
* The leaderboard the score was set on.
*/
leaderboard: ScoreSaberLeaderboard;
/**
* The scores so show.
*/
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
};
export function ScoreOverview({ scoreStats, initialPage, leaderboard, scores }: ScoreOverviewProps) {
return (
<>
{scoreStats && (
<div className="flex gap-2">
<PlayerScoreAccuracyChart scoreStats={scoreStats} />
</div>
)}
<LeaderboardScores
initialPage={initialPage}
initialScores={scores}
leaderboard={leaderboard}
disableUrlChanging
/>
</>
);
}

@ -1,104 +1,104 @@
"use client";
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { useQuery } from "@tanstack/react-query";
import { CubeIcon } from "@heroicons/react/24/solid";
import { GitGraph } from "lucide-react";
import ScoreButtons from "./score-buttons";
import ScoreSongInfo from "./score-song-info";
import ScoreRankInfo from "./score-rank-info";
import ScoreStats from "./score-stats";
import { motion } from "framer-motion";
import Card from "@/components/card";
import { MapStats } from "@/components/score/map-stats";
import { Button } from "@/components/ui/button";
import { ScoreOverview } from "@/components/score/score-views/score-overview";
import { ScoreHistory } from "@/components/score/score-views/score-history";
import { getPageFromRank } from "@ssr/common/utils/utils";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { useIsMobile } from "@/hooks/use-is-mobile";
import Card from "@/components/card";
import { MapStats } from "@/components/score/map-stats";
import PlayerScoreAccuracyChart from "@/components/leaderboard/chart/player-score-accuracy-chart";
import { useQuery } from "@tanstack/react-query";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import { ScoreStatsToken } from "@ssr/common/types/token/beatleader/score-stats/score-stats";
import { beatLeaderService } from "@ssr/common/service/impl/beatleader";
type Props = {
/**
* The score to display.
*/
score: ScoreSaberScore;
/**
* The leaderboard.
*/
leaderboard: ScoreSaberLeaderboard;
/**
* The beat saver map for this song.
*/
beatSaverMap?: BeatSaverMap;
/**
* Score settings
*/
settings?: {
noScoreButtons: boolean;
noScoreButtons?: boolean;
hideLeaderboardDropdown?: boolean;
hideAccuracyChanger?: boolean;
};
};
type LeaderboardDropdownData = {
/**
* The initial scores.
*/
scores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
/**
* The score stats for this score,
*/
scoreStats?: ScoreStatsToken;
};
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
const scoresPage = getPageFromRank(score.rank, 12);
type Mode = {
name: string;
icon: React.ReactElement;
};
const isMobile = useIsMobile();
const [baseScore, setBaseScore] = useState<number>(score.score);
const modes: Mode[] = [
{ name: "Overview", icon: <CubeIcon className="w-4 h-4" /> },
{ name: "Score History", icon: <GitGraph className="w-4 h-4" /> },
];
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
const [baseScore, setBaseScore] = useState(score.score);
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [leaderboardDropdownData, setLeaderboardDropdownData] = useState<LeaderboardDropdownData | undefined>();
const [selectedMode, setSelectedMode] = useState<Mode>(modes[0]);
const { data, isError, isLoading } = useQuery<LeaderboardDropdownData>({
queryKey: ["leaderboardDropdownData", leaderboard.id, score.scoreId, isLeaderboardExpanded],
const scoresPage = getPageFromRank(score.rank, 12);
const isMobile = useIsMobile();
const { data, isLoading } = useQuery<LeaderboardDropdownData>({
queryKey: [`leaderboardDropdownData:${leaderboard.id}`, leaderboard.id, score.scoreId, isLeaderboardExpanded],
queryFn: async () => {
const scores = await fetchLeaderboardScores<ScoreSaberScore, ScoreSaberLeaderboard>(
"scoresaber",
leaderboard.id + "",
leaderboard.id.toString(),
scoresPage
);
const scoreStats = score.additionalData
? await beatLeaderService.lookupScoreStats(score.additionalData.scoreId)
: undefined;
return {
scores: scores,
scoreStats: scoreStats,
};
return { scores, scoreStats };
},
staleTime: 30 * 1000,
staleTime: 30000,
enabled: loading,
});
useEffect(() => {
if (data) {
setLeaderboardDropdownData({
...data,
scores: data.scores,
scoreStats: data.scoreStats,
});
setLeaderboardDropdownData(data);
setLoading(false);
}
}, [data]);
useEffect(() => {
setIsLeaderboardExpanded(false);
setLeaderboardDropdownData(undefined);
}, [score.scoreId]);
useEffect(() => {
setBaseScore(score.score);
}, [score]);
const accuracy = (baseScore / leaderboard.maxScore) * 100;
const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
const handleLeaderboardOpen = (isExpanded: boolean) => {
if (!isExpanded) {
setLeaderboardDropdownData(undefined);
@ -108,63 +108,35 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
setIsLeaderboardExpanded(isExpanded);
};
/**
* Set the base score
*/
useEffect(() => {
if (score?.score) {
setBaseScore(score.score);
}
}, [score]);
const handleModeChange = (mode: Mode) => {
setSelectedMode(mode);
};
/**
* Close the leaderboard when the score changes
*/
useEffect(() => {
setIsLeaderboardExpanded(false);
setLeaderboardDropdownData(undefined);
}, [score.scoreId]);
const accuracy = (baseScore / leaderboard.maxScore) * 100;
const pp = baseScore === score.score ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
// Dynamic grid column classes
const gridColsClass = settings?.noScoreButtons
? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" // Fewer columns if no buttons
: "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; // Original with buttons
? "grid-cols-[20px_1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]"
: "grid-cols-[20px_1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]";
return (
<div className="pb-2 pt-2">
{/* Score Info */}
<div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
<ScoreRankInfo score={score} leaderboard={leaderboard} />
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
{settings?.noScoreButtons !== true && (
{!settings?.noScoreButtons && (
<ScoreButtons
leaderboard={leaderboard}
beatSaverMap={beatSaverMap}
score={score}
alwaysSingleLine={isMobile}
setIsLeaderboardExpanded={(isExpanded: boolean) => {
handleLeaderboardOpen(isExpanded);
}}
hideLeaderboardDropdown={settings?.hideLeaderboardDropdown}
hideAccuracyChanger={settings?.hideAccuracyChanger}
setIsLeaderboardExpanded={handleLeaderboardOpen}
isLeaderboardLoading={isLoading}
updateScore={score => {
setBaseScore(score.score);
}}
updateScore={updatedScore => setBaseScore(updatedScore.score)}
/>
)}
<ScoreStats
score={{
...score,
accuracy: accuracy ? accuracy : score.accuracy,
pp: pp ? pp : score.pp,
}}
leaderboard={leaderboard}
/>
<ScoreStats score={{ ...score, accuracy, pp }} leaderboard={leaderboard} />
</div>
{/* Leaderboard */}
{isLeaderboardExpanded && leaderboardDropdownData && !loading && (
<motion.div
initial={{ opacity: 0, y: -50 }}
@ -173,20 +145,38 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
className="w-full mt-2"
>
<Card className="flex gap-4 w-full relative border border-input">
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
{leaderboardDropdownData.scoreStats && (
<div className="flex gap-2">
<PlayerScoreAccuracyChart scoreStats={leaderboardDropdownData.scoreStats} />
<div className="flex flex-col lg:flex-row w-full gap-2 justify-center">
<div className="flex clex-col justify-center lg:justify-start gap-2">
{modes.map((mode, i) => (
<Button
key={i}
variant={mode.name === selectedMode.name ? "default" : "outline"}
onClick={() => handleModeChange(mode)}
className="flex gap-2"
>
{mode.icon}
<p>{mode.name}</p>
</Button>
))}
</div>
<div>
<MapStats leaderboard={leaderboard} beatSaver={beatSaverMap} />
</div>
</div>
{selectedMode.name === "Overview" && (
<ScoreOverview
scores={leaderboardDropdownData.scores}
leaderboard={leaderboard}
initialPage={scoresPage}
scoreStats={leaderboardDropdownData.scoreStats}
/>
)}
<LeaderboardScores
initialPage={scoresPage}
initialScores={leaderboardDropdownData.scores}
leaderboard={leaderboard}
disableUrlChanging
/>
{selectedMode.name === "Score History" && (
<ScoreHistory playerId={score.playerId} leaderboard={leaderboard} />
)}
</Card>
</motion.div>
)}