track total score, total ranked score, replay watched count and add a score chart
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 31s
Deploy Website / docker (ubuntu-latest) (push) Failing after 30s

This commit is contained in:
Lee 2024-10-22 13:54:54 +01:00
parent 696da236d5
commit 854f88c43a
16 changed files with 287 additions and 188 deletions

@ -148,6 +148,7 @@ export class PlayerService {
history.countryRank = player.countryRank; history.countryRank = player.countryRank;
history.rank = player.rank; history.rank = player.rank;
history.accuracy = { history.accuracy = {
...history.accuracy,
averageRankedAccuracy: scoreStats.averageRankedAccuracy, averageRankedAccuracy: scoreStats.averageRankedAccuracy,
}; };
history.scores = { history.scores = {
@ -155,6 +156,11 @@ export class PlayerService {
totalScores: scoreStats.totalPlayCount, totalScores: scoreStats.totalPlayCount,
totalRankedScores: scoreStats.rankedPlayCount, totalRankedScores: scoreStats.rankedPlayCount,
}; };
history.score = {
...history.score,
totalScore: scoreStats.totalScore,
totalRankedScore: scoreStats.totalRankedScore,
};
foundPlayer.setStatisticHistory(dateToday, history); foundPlayer.setStatisticHistory(dateToday, history);
foundPlayer.sortStatisticHistory(); foundPlayer.sortStatisticHistory();
@ -181,12 +187,11 @@ export class PlayerService {
} }
const today = new Date(); const today = new Date();
let history = player.getHistoryByDate(today); const history = player.getHistoryByDate(today);
if (history == undefined || Object.keys(history).length === 0) { const scores = history.scores || {
history = { scores: { rankedScores: 0, unrankedScores: 0 } }; // Ensure initialization rankedScores: 0,
} unrankedScores: 0,
};
const scores = history.scores || {};
if (leaderboard.stars > 0) { if (leaderboard.stars > 0) {
scores.rankedScores!++; scores.rankedScores!++;
} else { } else {

@ -110,18 +110,11 @@ export async function getScoreSaberPlayerFromToken(
if (history) { if (history) {
// Use the latest data for today // Use the latest data for today
history[todayDate] = { history[todayDate] = {
...{
scores: {
rankedScores: 0,
unrankedScores: 0,
totalScores: 0,
totalRankedScores: 0,
},
},
...history[todayDate], ...history[todayDate],
rank: token.rank, rank: token.rank,
countryRank: token.countryRank, countryRank: token.countryRank,
pp: token.pp, pp: token.pp,
replaysWatched: token.scoreStats.replaysWatched,
accuracy: { accuracy: {
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy, averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
}, },
@ -129,6 +122,10 @@ export async function getScoreSaberPlayerFromToken(
totalScores: token.scoreStats.totalPlayCount, totalScores: token.scoreStats.totalPlayCount,
totalRankedScores: token.scoreStats.rankedPlayCount, totalRankedScores: token.scoreStats.rankedPlayCount,
}, },
score: {
totalScore: token.scoreStats.totalScore,
totalRankedScore: token.scoreStats.totalRankedScore,
},
}; };
isBeingTracked = true; isBeingTracked = true;
@ -158,10 +155,6 @@ export async function getScoreSaberPlayerFromToken(
statisticHistory[dateKey] = { statisticHistory[dateKey] = {
...statisticHistory[dateKey], ...statisticHistory[dateKey],
rank: rank, rank: rank,
scores: {
totalScores: token.scoreStats.totalPlayCount,
totalRankedScores: token.scoreStats.rankedPlayCount,
},
}; };
} }
} }
@ -247,9 +240,15 @@ export async function getScoreSaberPlayerFromToken(
rank: getStatisticChange("rank", true, daysAgo), rank: getStatisticChange("rank", true, daysAgo),
countryRank: getStatisticChange("countryRank", true, daysAgo), countryRank: getStatisticChange("countryRank", true, daysAgo),
pp: getStatisticChange("pp", false, daysAgo), pp: getStatisticChange("pp", false, daysAgo),
replaysWatched: getStatisticChange("replaysWatched", false, daysAgo),
accuracy: {
averageRankedAccuracy: getStatisticChange("accuracy.averageRankedAccuracy", false, daysAgo),
},
scores: { scores: {
totalScores: getStatisticChange("scores.totalScores", false, daysAgo), totalScores: getStatisticChange("scores.totalScores", false, daysAgo),
totalRankedScores: getStatisticChange("scores.totalRankedScores", false, daysAgo), totalRankedScores: getStatisticChange("scores.totalRankedScores", false, daysAgo),
rankedScores: getStatisticChange("scores.rankedScores", false, daysAgo),
unrankedScores: getStatisticChange("scores.unrankedScores", false, daysAgo),
}, },
}; };
}; };
@ -268,7 +267,6 @@ export async function getScoreSaberPlayerFromToken(
daily: getStatisticChanges(1), daily: getStatisticChanges(1),
weekly: getStatisticChanges(7), weekly: getStatisticChanges(7),
monthly: getStatisticChanges(30), monthly: getStatisticChanges(30),
yearly: getStatisticChanges(365),
}, },
role: token.role == null ? undefined : token.role, role: token.role == null ? undefined : token.role,
badges: badges, badges: badges,

@ -14,6 +14,26 @@ export interface PlayerHistory {
*/ */
pp?: number; pp?: number;
/**
* How many times replays of the player scores have been watched
*/
replaysWatched?: number;
/**
* The player's score stats.
*/
score?: {
/**
* The total amount of unranked and ranked score.
*/
totalScore?: number;
/**
* The total amount of ranked score.
*/
totalRankedScore?: number;
};
/** /**
* The amount of scores set for this day. * The amount of scores set for this day.
*/ */

@ -0,0 +1,64 @@
import ScoreSaberPlayer from "@ssr/player/impl/scoresaber-player";
import { ChangeRange } from "@ssr/player/player";
export type PlayerStatValue = {
/**
* The type of the stat.
*/
type: string;
/**
* The value of the stat.
*/
value: (player: ScoreSaberPlayer, range: ChangeRange) => number | undefined;
};
export type PlayerStatChangeType =
| "Rank"
| "CountryRank"
| "PerformancePoints"
| "TotalPlayCount"
| "RankedPlayCount"
| "TotalScore"
| "TotalRankedScore"
| "AverageRankedAccuracy"
| "TotalReplaysWatched";
export const PlayerStatChange: Record<PlayerStatChangeType, PlayerStatValue> = {
Rank: {
type: "Rank",
value: (player, range) => player.statisticChange?.[range].rank,
},
CountryRank: {
type: "Country Rank",
value: (player, range) => player.statisticChange?.[range].countryRank,
},
PerformancePoints: {
type: "Performance Points",
value: (player, range) => player.statisticChange?.[range].pp,
},
TotalPlayCount: {
type: "Total Play Count",
value: (player, range) => player.statisticChange?.[range].scores?.totalScores,
},
RankedPlayCount: {
type: "Ranked Play Count",
value: (player, range) => player.statisticChange?.[range].scores?.totalRankedScores,
},
TotalScore: {
type: "Total Score",
value: (player, range) => player.statisticChange?.[range].score?.totalScore,
},
TotalRankedScore: {
type: "Total Ranked Score",
value: (player, range) => player.statisticChange?.[range].scores?.totalRankedScores,
},
AverageRankedAccuracy: {
type: "Average Ranked Accuracy",
value: (player, range) => player.statisticChange?.[range].accuracy?.averageRankedAccuracy,
},
TotalReplaysWatched: {
type: "Total Replays Watched",
value: (player, range) => player.statisticChange?.[range].replaysWatched,
},
};

@ -1,32 +0,0 @@
export type PlayerStatValue = {
/**
* The display name of the stat.
*/
displayName: string;
/**
* The value of the stat.
*/
value?: "rank" | "countryRank" | "pp";
};
export const PlayerStat: Record<string, PlayerStatValue> = {
Rank: {
displayName: "Rank",
value: "rank",
},
CountryRank: {
displayName: "Country Rank",
value: "countryRank",
},
PerformancePoints: {
displayName: "Performance Points",
value: "pp",
},
TotalPlayCount: {
displayName: "Total Play Count",
},
RankedPlayCount: {
displayName: "Ranked Play Count",
},
};

@ -55,9 +55,7 @@ export default class Player {
} }
} }
export type ChangeRange = "daily" | "weekly" | "monthly";
export type StatisticChange = { export type StatisticChange = {
daily: PlayerHistory; [key in ChangeRange]: PlayerHistory;
weekly: PlayerHistory;
monthly: PlayerHistory;
yearly: PlayerHistory;
}; };

@ -1 +0,0 @@
export type StatTimeframe = "daily" | "weekly" | "monthly";

@ -4,8 +4,9 @@ import Link from "next/link";
import React from "react"; import React from "react";
import NavbarButton from "./navbar-button"; import NavbarButton from "./navbar-button";
import ProfileButton from "./profile-button"; import ProfileButton from "./profile-button";
import { SwordIcon, TrendingUpIcon } from "lucide-react"; import { TrendingUpIcon } from "lucide-react";
import FriendsButton from "@/components/navbar/friends-button"; import FriendsButton from "@/components/navbar/friends-button";
import { PiSwordFill } from "react-icons/pi";
type NavbarItem = { type NavbarItem = {
name: string; name: string;
@ -25,7 +26,7 @@ const items: NavbarItem[] = [
name: "Score Feed", name: "Score Feed",
link: "/scores", link: "/scores",
align: "left", align: "left",
icon: <SwordIcon className="h-5 w-5" />, icon: <PiSwordFill className="h-5 w-5" />,
}, },
{ {
name: "Search", name: "Search",

@ -0,0 +1,67 @@
"use client";
import React from "react";
import { DatasetConfig } from "@/components/chart/generic-chart";
import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
import { scoreBarsDataset } from "@/components/player/chart/charts/player-scores-chart";
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { formatNumberWithCommas, isWholeNumber } from "@ssr/common/utils/number-utils";
type Props = {
player: ScoreSaberPlayer;
};
// Dataset configuration for the chart
const datasetConfig: DatasetConfig[] = [
{
title: "Rank",
field: "rank",
color: "#3EC1D3",
axisId: "y",
axisConfig: {
reverse: true,
display: true,
displayName: "Rank",
position: "left",
},
labelFormatter: (value: number) => `Rank: #${formatNumberWithCommas(value)}`,
},
{
title: "Country Rank",
field: "countryRank",
color: "#FFEA00",
axisId: "y1",
axisConfig: {
reverse: true,
display: false,
displayName: "Country Rank",
position: "left",
},
labelFormatter: (value: number) => `Country Rank: #${formatNumberWithCommas(value)}`,
},
{
title: "PP",
field: "pp",
color: "#4858ff",
axisId: "y2",
axisConfig: {
reverse: false,
display: true,
hideOnMobile: true,
displayName: "PP",
position: "right",
valueFormatter: value => {
if (isWholeNumber(value)) {
return value.toString();
}
return value.toFixed(1);
},
},
labelFormatter: (value: number) => `PP: ${formatNumberWithCommas(value)}pp`,
},
...scoreBarsDataset,
];
export default function PlayerRankingChart({ player }: Props) {
return <GenericPlayerChart player={player} datasetConfig={datasetConfig} />;
}

@ -1,96 +1,21 @@
"use client"; "use client";
import { formatNumberWithCommas, isWholeNumber } from "@ssr/common/utils/number-utils";
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/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
type Props = { type Props = {
player: ScoreSaberPlayer; player: ScoreSaberPlayer;
}; };
// Dataset configuration for the chart export const scoreBarsDataset: DatasetConfig[] = [
const datasetConfig: DatasetConfig[] = [
{
title: "Rank",
field: "rank",
color: "#3EC1D3",
axisId: "y",
axisConfig: {
reverse: true,
display: true,
displayName: "Rank",
position: "left",
},
labelFormatter: (value: number) => `Rank: #${formatNumberWithCommas(value)}`,
},
{
title: "Country Rank",
field: "countryRank",
color: "#FFEA00",
axisId: "y1",
axisConfig: {
reverse: true,
display: false,
displayName: "Country Rank",
position: "left",
},
labelFormatter: (value: number) => `Country Rank: #${formatNumberWithCommas(value)}`,
},
{
title: "PP",
field: "pp",
color: "#4858ff",
axisId: "y2",
axisConfig: {
reverse: false,
display: true,
hideOnMobile: true,
displayName: "PP",
position: "right",
valueFormatter: value => {
if (isWholeNumber(value)) {
return value.toString();
}
return value.toFixed(1);
},
},
labelFormatter: (value: number) => `PP: ${formatNumberWithCommas(value)}pp`,
},
{
title: "Total Scores",
field: "scores.totalScores",
color: "#616161",
axisId: "y3",
showLegend: false,
axisConfig: {
reverse: false,
display: false,
displayName: "Total Scores",
position: "left",
},
labelFormatter: (value: number) => `Total Scores: ${formatNumberWithCommas(value)}`,
},
{
title: "Total Ranked Scores",
field: "scores.totalRankedScores",
color: "#6773ff",
axisId: "y4",
showLegend: false,
axisConfig: {
reverse: false,
display: false,
displayName: "Total Ranked Scores",
position: "left",
},
labelFormatter: (value: number) => `Total Ranked Scores: ${formatNumberWithCommas(value)}`,
},
{ {
title: "Ranked Scores", title: "Ranked Scores",
field: "scores.rankedScores", field: "scores.rankedScores",
color: "#ffae4d", color: "#ffae4d",
axisId: "y5", axisId: "y100",
axisConfig: { axisConfig: {
reverse: false, reverse: false,
display: false, display: false,
@ -104,7 +29,7 @@ const datasetConfig: DatasetConfig[] = [
title: "Unranked Scores", title: "Unranked Scores",
field: "scores.unrankedScores", field: "scores.unrankedScores",
color: "#616161", color: "#616161",
axisId: "y5", axisId: "y100",
axisConfig: { axisConfig: {
reverse: false, reverse: false,
display: false, display: false,
@ -116,6 +41,37 @@ const datasetConfig: DatasetConfig[] = [
}, },
]; ];
export default function PlayerRankingChart({ player }: Props) { // Dataset configuration for the chart
const datasetConfig: DatasetConfig[] = [
...scoreBarsDataset,
{
title: "Total Scores",
field: "scores.totalScores",
color: "#616161",
axisId: "y1",
axisConfig: {
reverse: false,
display: true,
displayName: "Total Scores",
position: "left",
},
labelFormatter: (value: number) => `Total Scores: ${formatNumberWithCommas(value)}`,
},
{
title: "Total Ranked Scores",
field: "scores.totalRankedScores",
color: "#6773ff",
axisId: "y2",
axisConfig: {
reverse: false,
display: true,
displayName: "Total Ranked Scores",
position: "right",
},
labelFormatter: (value: number) => `Total Ranked Scores: ${formatNumberWithCommas(value)}`,
},
];
export default function PlayerScoresChart({ player }: Props) {
return <GenericPlayerChart player={player} datasetConfig={datasetConfig} />; return <GenericPlayerChart player={player} datasetConfig={datasetConfig} />;
} }

@ -1,12 +1,14 @@
"use client"; "use client";
import PlayerRankingChart from "@/components/player/chart/player-ranking-chart"; import PlayerRankingChart from "@/components/player/chart/charts/player-ranking-chart";
import { FC, useState } from "react"; import { FC, useState } from "react";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart"; import PlayerAccuracyChart from "@/components/player/chart/charts/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/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import PlayerScoresChart from "@/components/player/chart/charts/player-scores-chart";
import { PiSwordFill } from "react-icons/pi";
type PlayerChartsProps = { type PlayerChartsProps = {
/** /**
@ -53,6 +55,12 @@ export default function PlayerCharts({ player }: PlayerChartsProps) {
icon: <TrendingUpIcon className="w-[18px] h-[18px]" />, icon: <TrendingUpIcon className="w-[18px] h-[18px]" />,
chart: PlayerAccuracyChart, chart: PlayerAccuracyChart,
}); });
charts.push({
index: 2,
label: "Scores",
icon: <PiSwordFill className="w-[18px] h-[18px]" />,
chart: PlayerScoresChart,
});
} }
const [selectedChart, setSelectedChart] = useState<SelectedChart>(charts[0]); const [selectedChart, setSelectedChart] = useState<SelectedChart>(charts[0]);

@ -13,7 +13,7 @@ import PlayerSteamProfile from "@/components/player/player-steam-profile";
import { getScoreSaberRole } from "@ssr/common/utils/scoresaber.util"; import { getScoreSaberRole } from "@ssr/common/utils/scoresaber.util";
import { DailyChange } from "@/components/statistic/daily-change"; import { DailyChange } from "@/components/statistic/daily-change";
import { ChangeOverTime } from "@/components/statistic/change-over-time"; import { ChangeOverTime } from "@/components/statistic/change-over-time";
import { PlayerStat } from "@ssr/common/player/player-stat"; import { PlayerStatChange } from "@ssr/common/player/player-stat-change";
const playerData = [ const playerData = [
{ {
@ -27,14 +27,14 @@ const playerData = [
return ( return (
<div className="text-gray-300 flex gap-1 items-center"> <div className="text-gray-300 flex gap-1 items-center">
<ChangeOverTime player={player} type={PlayerStat.Rank}> <ChangeOverTime player={player} type={PlayerStatChange.Rank}>
<Link href={`/ranking/${player.rankPages.global}`}> <Link href={`/ranking/${player.rankPages.global}`}>
<p className="hover:brightness-[66%] transition-all transform-gpu"> <p className="hover:brightness-[66%] transition-all transform-gpu">
#{formatNumberWithCommas(player.rank)} #{formatNumberWithCommas(player.rank)}
</p> </p>
</Link> </Link>
</ChangeOverTime> </ChangeOverTime>
<DailyChange type={PlayerStat.Rank} change={rankChange} /> <DailyChange type={PlayerStatChange.Rank} change={rankChange} />
</div> </div>
); );
}, },
@ -50,14 +50,14 @@ const playerData = [
return ( return (
<div className="text-gray-300 flex gap-1 items-center"> <div className="text-gray-300 flex gap-1 items-center">
<ChangeOverTime player={player} type={PlayerStat.CountryRank}> <ChangeOverTime player={player} type={PlayerStatChange.CountryRank}>
<Link href={`/ranking/${player.country}/${player.rankPages.country}`}> <Link href={`/ranking/${player.country}/${player.rankPages.country}`}>
<p className="hover:brightness-[66%] transition-all transform-gpu"> <p className="hover:brightness-[66%] transition-all transform-gpu">
#{formatNumberWithCommas(player.countryRank)} #{formatNumberWithCommas(player.countryRank)}
</p> </p>
</Link> </Link>
</ChangeOverTime> </ChangeOverTime>
<DailyChange type={PlayerStat.CountryRank} change={rankChange} /> <DailyChange type={PlayerStatChange.CountryRank} change={rankChange} />
</div> </div>
); );
}, },
@ -70,10 +70,10 @@ const playerData = [
return ( return (
<div className="text-gray-300 flex gap-1 items-center"> <div className="text-gray-300 flex gap-1 items-center">
<ChangeOverTime player={player} type={PlayerStat.PerformancePoints}> <ChangeOverTime player={player} type={PlayerStatChange.PerformancePoints}>
<p className="hover:brightness-[66%] transition-all transform-gpu text-pp">{formatPp(player.pp)}pp</p> <p className="hover:brightness-[66%] transition-all transform-gpu text-pp">{formatPp(player.pp)}pp</p>
</ChangeOverTime> </ChangeOverTime>
<DailyChange type={PlayerStat.PerformancePoints} change={ppChange} /> <DailyChange type={PlayerStatChange.PerformancePoints} change={ppChange} />
</div> </div>
); );
}, },

@ -5,8 +5,8 @@ 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";
import { DailyChange } from "@/components/statistic/daily-change"; import { DailyChange } from "@/components/statistic/daily-change";
import { PlayerStat } from "@ssr/common/player/player-stat";
import { getScoreSaberRole } from "@ssr/common/utils/scoresaber.util"; import { getScoreSaberRole } from "@ssr/common/utils/scoresaber.util";
import { PlayerStatChange } from "@ssr/common/player/player-stat-change";
type Stat = { type Stat = {
name: string; name: string;
@ -22,13 +22,11 @@ const playerStats: Stat[] = [
name: "Ranked Play Count", name: "Ranked Play Count",
color: "bg-pp", color: "bg-pp",
create: (player: ScoreSaberPlayer) => { create: (player: ScoreSaberPlayer) => {
const rankedScoresChange = player.statisticChange?.daily.scores?.totalRankedScores;
return { return {
value: ( value: (
<> <>
{formatNumberWithCommas(player.statistics.rankedPlayCount)}{" "} {formatNumberWithCommas(player.statistics.rankedPlayCount)}{" "}
<DailyChange type={PlayerStat.RankedPlayCount} change={rankedScoresChange} /> <DailyChange type={PlayerStatChange.RankedPlayCount} player={player} />
</> </>
), ),
}; };
@ -39,7 +37,12 @@ const playerStats: Stat[] = [
color: "bg-pp", color: "bg-pp",
create: (player: ScoreSaberPlayer) => { create: (player: ScoreSaberPlayer) => {
return { return {
value: formatNumberWithCommas(player.statistics.totalRankedScore), value: (
<>
{formatNumberWithCommas(player.statistics.totalRankedScore)}{" "}
<DailyChange type={PlayerStatChange.TotalRankedScore} player={player} />
</>
),
}; };
}, },
}, },
@ -48,20 +51,23 @@ const playerStats: Stat[] = [
color: "bg-pp", color: "bg-pp",
create: (player: ScoreSaberPlayer) => { create: (player: ScoreSaberPlayer) => {
return { return {
value: player.statistics.averageRankedAccuracy.toFixed(2) + "%", value: (
<>
{player.statistics.averageRankedAccuracy.toFixed(2) + "%"}{" "}
<DailyChange type={PlayerStatChange.AverageRankedAccuracy} player={player} />
</>
),
}; };
}, },
}, },
{ {
name: "Total Play Count", name: "Total Play Count",
create: (player: ScoreSaberPlayer) => { create: (player: ScoreSaberPlayer) => {
const scoresChange = player.statisticChange?.daily.scores;
return { return {
value: ( value: (
<> <>
{formatNumberWithCommas(player.statistics.totalPlayCount)}{" "} {formatNumberWithCommas(player.statistics.totalPlayCount)}{" "}
<DailyChange type={PlayerStat.TotalPlayCount} change={scoresChange?.totalScores} /> <DailyChange type={PlayerStatChange.TotalPlayCount} player={player} />
</> </>
), ),
}; };
@ -71,7 +77,12 @@ const playerStats: Stat[] = [
name: "Total Score", name: "Total Score",
create: (player: ScoreSaberPlayer) => { create: (player: ScoreSaberPlayer) => {
return { return {
value: formatNumberWithCommas(player.statistics.totalScore), value: (
<>
{formatNumberWithCommas(player.statistics.totalScore)}{" "}
<DailyChange type={PlayerStatChange.TotalScore} player={player} />
</>
),
}; };
}, },
}, },
@ -79,7 +90,12 @@ const playerStats: Stat[] = [
name: "Total Replays Watched", name: "Total Replays Watched",
create: (player: ScoreSaberPlayer) => { create: (player: ScoreSaberPlayer) => {
return { return {
value: formatNumberWithCommas(player.statistics.replaysWatched), value: (
<>
{formatNumberWithCommas(player.statistics.replaysWatched)}{" "}
<DailyChange type={PlayerStatChange.TotalReplaysWatched} player={player} />
</>
),
}; };
}, },
}, },

@ -2,8 +2,9 @@ import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils"; import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
import { capitalizeFirstLetter } from "@/common/string-utils"; import { capitalizeFirstLetter } from "@/common/string-utils";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { PlayerStatValue } from "@ssr/common/player/player-stat";
import { ReactElement } from "react"; import { ReactElement } from "react";
import { ChangeRange } from "@ssr/common/player/player";
import { PlayerStatValue } from "@ssr/common/player/player-stat-change";
type ChangeOverTimeProps = { type ChangeOverTimeProps = {
/** /**
@ -23,13 +24,9 @@ type ChangeOverTimeProps = {
}; };
export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps) { export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps) {
const todayStats = player.statisticChange?.daily; const daily = type.value(player, "daily");
const weeklyStats = player.statisticChange?.weekly; const weekly = type.value(player, "weekly");
const monthlyStats = player.statisticChange?.monthly; const monthly = type.value(player, "monthly");
const todayStat = todayStats?.[type.value!];
const weeklyStat = weeklyStats?.[type.value!];
const monthlyStat = monthlyStats?.[type.value!];
// Format values based on stat type // Format values based on stat type
const formatChangeValue = (value: number | undefined): string | number => { const formatChangeValue = (value: number | undefined): string | number => {
@ -39,13 +36,13 @@ export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps)
if (value === undefined) { if (value === undefined) {
return "No Data"; return "No Data";
} }
return type.value === "pp" ? formatPp(value) + "pp" : formatNumberWithCommas(value); return type.type === "Performance Points" ? formatPp(value) + "pp" : formatNumberWithCommas(value);
}; };
// Renders the change for a given time frame // Renders the change for a given time frame
const renderChange = (value: number | undefined, timeFrame: "daily" | "weekly" | "monthly") => ( const renderChange = (value: number | undefined, range: ChangeRange) => (
<p> <p>
{capitalizeFirstLetter(timeFrame)} Change:{" "} {capitalizeFirstLetter(range)} Change:{" "}
<span className={value === undefined ? "" : value >= 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}> <span className={value === undefined ? "" : value >= 0 ? (value === 0 ? "" : "text-green-500") : "text-red-500"}>
{formatChangeValue(value)} {formatChangeValue(value)}
</span> </span>
@ -62,9 +59,9 @@ export function ChangeOverTime({ player, type, children }: ChangeOverTimeProps)
side="bottom" side="bottom"
display={ display={
<div> <div>
{renderChange(todayStat, "daily")} {renderChange(daily, "daily")}
{renderChange(weeklyStat, "weekly")} {renderChange(weekly, "weekly")}
{renderChange(monthlyStat, "monthly")} {renderChange(monthly, "monthly")}
</div> </div>
} }
> >

@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import Tooltip from "@/components/tooltip"; import Tooltip from "@/components/tooltip";
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils"; import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
import { PlayerStatValue } from "@ssr/common/player/player-stat"; import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { PlayerStatValue } from "@ssr/common/player/player-stat-change";
interface DailyChangeProps { interface DailyChangeProps {
/** /**
@ -10,38 +11,39 @@ interface DailyChangeProps {
type: PlayerStatValue; type: PlayerStatValue;
/** /**
* The value of the change * The player to get the change for
*/ */
change: number | undefined; player?: ScoreSaberPlayer | undefined;
/**
* The change (if not using player)
*/
change?: number;
/** /**
* The tooltip to display * The tooltip to display
*/ */
tooltip?: React.ReactElement | string; tooltip?: React.ReactElement | string;
/**
* The formater for the change
*
* @param value
*/
format?: (value: number) => string;
} }
export function DailyChange({ type, change, tooltip, format }: DailyChangeProps) { export function DailyChange({ type, player, change, tooltip }: DailyChangeProps) {
const formatValue = format ?? formatNumberWithCommas; const formatValue = type.type == "Performance Points" ? formatPp : formatNumberWithCommas;
if (change === 0 || change === undefined) { if (!change && player !== undefined) {
change = type.value?.(player, "daily");
}
if (change === 0 || (change && change < 0.01) || change === undefined) {
return null; return null;
} }
const value = ( const value = (
<p className={`text-sm ${change > 0 ? "text-green-400" : "text-red-400"}`}> <p className={`text-sm ${change > 0 ? "text-green-400" : "text-red-400"}`}>
{change > 0 ? "+" : ""} {change > 0 ? "+" : ""}
{`${formatValue(change)}${type.value == "pp" ? "pp" : ""}`} {`${formatValue(change)}${type.type == "Performance Points" ? "pp" : ""}`}
</p> </p>
); );
if (!tooltip) { if (!tooltip) {
tooltip = `${type.displayName} change compared to yesterday`; tooltip = `${type.type} change compared to yesterday`;
} }
return ( return (