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

View File

@ -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" />

View File

@ -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);
}}
/>
</>
);
}

View File

@ -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
/>
</>
);
}

View File

@ -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>
)}