wish me luck
Some checks failed
Deploy / deploy (push) Failing after 1m22s

This commit is contained in:
Lee 2024-09-28 05:57:35 +01:00
parent 39526dde41
commit 2f18f0f096
25 changed files with 1812 additions and 85 deletions

6
.env-example Normal file

@ -0,0 +1,6 @@
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY=
TRIGGER_API_KEY=
TRIGGER_API_URL=https://trigger.fascinated.cc
MONGO_URI=mongodb://127.0.0.1:27017

@ -26,3 +26,29 @@ spec:
limits: limits:
cpu: 1000m # 1 vCPU cpu: 1000m # 1 vCPU
memory: 256Mi memory: 256Mi
env:
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: ssr-secret
key: MONGO_URI
- name: NEXT_PUBLIC_SITE_URL
valueFrom:
secretKeyRef:
name: ssr-secret
key: NEXT_PUBLIC_SITE_URL
- name: NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY
valueFrom:
secretKeyRef:
name: ssr-secret
key: NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY
- name: TRIGGER_API_KEY
valueFrom:
secretKeyRef:
name: ssr-secret
key: TRIGGER_API_KEY
- name: TRIGGER_API_URL
valueFrom:
secretKeyRef:
name: ssr-secret
key: TRIGGER_API_URL

@ -0,0 +1,20 @@
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
creationTimestamp: null
name: ssr-secret
namespace: public-services
spec:
encryptedData:
MONGO_URI: AgBSW/TeDuqMDd4JslZ0MjBkX/vYxjicHN/4F/CRLOurmeWKQymrXqYEAvpdWv9pn3OcbzsBU152+NNRMM87lmMbsv4PS1v4X3Apc/5isL81CD3HXvs5o8ls9XNUvR3vriZI1ZzfQOKmLW81gqefCtAMkEOWI/RW3mj7Ai8C3kHve+RX26Ncevi3/af6VrHU6bFVwOshRFlZlPIqTbo0Y6V1S7bWtgeAkiahPNsru4jFozbBa19T131OHAud1v6UL8eyXz4OU7NMWtkztarNdOaAL/RQFxzP3ievVGW4YXLkWiXac1YQnTynxODUlm+piY7mN4KCkX4cIPDQ/jTBU54NtWuy/zrmscw5LK4WheuwkdqWWOZRxoDg+5Q/RwtZeuT7GkDQQPKmH6ti1vrXt4tF1BbW35uFbwC2fKeovm1VVtmPlY2pPy10HANKOpHhpMMazvhtj+pcuKICTXCC4D0ccJUgrYx5zuqXKKZOVqw1RxwwXQDJ1ooldmuKrWLxYu1kRe7CkA3yzT9lWTbRThsxl91cXGcT+G6ZfiSS8I/0Y+1XUyYCKmszd55KnBMZvRe3TohdUugap7TIYiKUAYQ272zdN2jxzeWb5IWqLp2RETl8a3DpQKaUCRnQQtUoipEIStJDhYQwITF6OsTg0W49eHA5NtC8TkXdOy0vvZnim6jXT6Rv5R9aXg3yFFjb2dAgPwcoYLv6snnjedA35quY7WpCwJegoF9NbZOQcvrjxVaFASPg53B/g7V3wHl4t+g2pbiZ2j4/6VbVVnhP1GlhcMbbynGEgr8yGK8V0xbxRfJFqS39YwgxL+M=
NEXT_PUBLIC_SITE_URL: AgAN582ssfWgQ58zWHBGTCG4v5okX1tMsc0ssnXBfO8uIk3XV6B/Wct/FO2HupaBBqiRf5ngWK45Zl4i4RKd2suiWXPRniwe8SpRreNECrqXiFH103hJHYxDIy3oR+wMjB2Wzj9HmyiqpdMtnqw61j9qNqPyu0yyXgqLIIBeL21jkXuHhJtjD9In8JvxI4qh+9LrKJGAJhgR7H48+PCvrN1YLJGthVu1A8M0TkfZGYv3yKSsp+18WbdDT4LH9pJLuymgUaAC28xJXIo69lP43mdxgK1Oc4icRLnO9CTA5otZ94n5bSadZHsM+SEFDkAk2sTpRbehw0/qxM/DFaDC4CgWbSi9fJF4O5bKGHWRvQ/l4sQkj6xdvcGMUgB1f2s4sGZijlFTGdCxaMb83TL8PZjjhcglc2/E7YMlLXP5NkuVYxO4Mb8ZPMbZ4DQBASSW0YK9XF3UeZnTMXtcNioUDyACvIcaJudRSHJQZFdaV1eWfdz+Nv0uGoin3PpxRCV8Qx9O3GJ5IHdJGTGZrh0P+kX7vlv3Y/AVc7BttuiIDkxKQBBW3MXatccTJu+ML5beI88VLKCoSTQiYc+0WrvLchGhWAu/56IgGxwtteQP4sTPJnr3L/5VmUN6YeyFHcHvXplivjv+ofnMTnytWH1FEdU+hMurilDYi0sq6TV03g/kJRVQYdat7jgSpUgPHgODfe244GTCE2mHyGnM2k3e0xIvZz+TwX+Bx6eH
NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY: AgBqHybb2WMXrYm55drkxeNF2Ir1fJnC6cQyk7VsQ6a5MvJSRHxuu4tLMWXvhAvsNmy7f/zEPdKHWtPlMaVmBFma+HydFCdOIif2Zlklso+6Hc6vwup0OohPi50O4cKszJI9NOYoJHygc+VbrawRUFwxxhplkxKTVlxTVC7aaitqS4rkaBT/u12NvNQpABMmP64TFrc6pMNYR71FyDz32sK0P0DkWq/0H5O0ej4TQl8e1cWMJiLJephN7h7Ur4ZsW1BREsmHXToUkVL/ECbo+7KTUrdDpppaI/ttXV6W1Wcn4eZxsAtbARCjIfjTPR4SE8csLsrf9a9ZzruT/SastL1eK0QRS2vcTMeeHrmADSRyfJi5XwCUowJqhpRCuwxeQnALpnqwjcgB4USelaH+5mA+bW28WvlZq9FSgkpWRMjejkRVSKiuSFcF7zFhRW38+kHyLRg/Ge8fH1/yILTabymwrM2+cuf5onrWzbCIXSBTM1nJPtPIOrikQX1u2gdDXQkKxTD2dE5hbhvAPCE+EZMupwU5D91tyq7WdlARuKgKr6ixeBDuReFEC4SX70HMmCnORHD3rqZIhHPfKjwnGCKpskU+DoyMmv2YX0C82NCq+Azf1MM1Ufd8rC0HGOvo3lxon+2az/7haGZj+kcL4uBMsuuhqfvjY5lDReIX8gE9AhiC0PoUXjilW+ZfGmGJjry6A+YsSrDIwQ6fV99kviSocGX90AoQ7nhVcaY=
TRIGGER_API_KEY: AgCuH57uP3NcaOMgiEsR6mU+pN3/VOPoFbDGQsCNW0p5joT3UVP1Z8/upd+wKaUKaSvTwk8K18JQRyQXBqIPJi9r3W0Lk3WeBVkpBUYLzk6ssJocaJdhudcBVwgQatIjsoTQgqSMNyzHdkEkjTCEdAyjN3aXOSNbaVRm9o8udSqOl8uzKC3ue40SBbNlMNG6p4zRE4nH0SHcXeN4La0JqbMzs5yiikm9yvvz8r+CHxhjIAkpC4y9yEIyvHIfUJNqFQKJgQ0geVkTs/FXn3yVQeXn0Akoi8P+oonVHP4loQNVPwuCodzRKGtBXF0vWKe4Tctdk6nVjTR3awUa6v4XwWqf+CHmsBb4m7ZanEZAVT/Vk36tbJjoz3Q0e/RFIol65t1bqzvtGkPVRBQRUDDPdJaW9WWmxeIRyesPWJk5daC8kbCrncZHqkFEaNVSmFcF4crYFZbjvACAx2FLm7q5y/Vdqst+rFor2d7SB5w32hSuuRUjx1uOv2WzsBT2A+nRNlRGHiLmpyeUnuL12+2k8hIR2Au73+Q5JkzE1k5P2jtZ5QS2JOzoBBWF9r2OLG/UXzTHgybptLuOQd4ee96jErh+ue2SrAqILaPlvzGdcTX4cpBnksDvScPh/d2DnIbzPo00jBwsco7v8R1KTdhnb1xP5wlUEgGzWsMfk22cD9ZBYMgPhREx/kn4mLwTwoGnDlg4zH5ejP/OieDcRZwNy8XrMhGigu4ocN5H2hE=
TRIGGER_API_URL: AgCIpCu4Zpo9NeFSTluABhYTvx0TJsXafXneAahiepwFAWKXsmccHc0gxc2qB68V62vLEcq0m9I6bSHuIM8QTq8+dfhQvR58g6hWaHqufvw3f96EiL2sY90keerGqGLsVdw/0vSwpdUstJtKR2+Slt755gUpvGOxb9LmGCHqFN+z1yaT5U6p3EJUhfAUgajU7SCkfEjj6ClL8ASToKCKMeIN9mH00EWs10iGKbYJgINA6IsaVy82q9ti5vvCN5nN6AYdSsfIyOVxNMBubR60yIL19Adt+jPoyF9VP1Nx7kz7HwIaHrwzLylAsRO/F2Tacqtm9wu69XpJmwkKL9GUyNDjC+CbCK/dKuC3kDLM3acPVi9G5aTdAyBz0+69eGBtXjBKaS7OhlL0buKMDpYkSk2pZ7+IeXCc6LuxGp6gplI7e1DpAZ5/mB+ypFuTpzHCgcvcgot+BHkr9nRcgCpQr8FLLLCXqGc8p0jXsNaMy56mO7EFGH6YJ1W7GZRFSdadWBvKP/b/lTBImf8SNGgWEzFuG80/DLlXph2g0ROU/T6qa/BOcKrnKmHYwMHxk2ZC2Trs31NzTb3xNqRBEdNKsFhC5WCAMR3InPkXjeY32hscP5YuA0j2JQRA9c46+FvfKba/69ZPIIuzKBR8/af3FYXq3fzhyI7NqwrCnXL37cyPaehL5VEzxdzzzR+Me4W2oPHBBz64RSPgFjs45Ah26slcOhfJC+zQblJToO26vw==
template:
metadata:
creationTimestamp: null
name: ssr-secret
namespace: public-services
type: Opaque

@ -48,6 +48,7 @@ jobs:
action: deploy action: deploy
namespace: public-services namespace: public-services
manifests: | manifests: |
.gitea/kubernetes/sealed-secrets.yaml
.gitea/kubernetes/deployment.yaml .gitea/kubernetes/deployment.yaml
.gitea/kubernetes/service.yaml .gitea/kubernetes/service.yaml
.gitea/kubernetes/ingress.yaml .gitea/kubernetes/ingress.yaml

@ -1,3 +1,3 @@
export const config = { export const config = {
siteUrl: "https://ssr.fascinated.cc", siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://ssr.fascinated.cc",
}; };

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
@ -20,6 +20,9 @@
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.55.4", "@tanstack/react-query": "^5.55.4",
"@trigger.dev/nextjs": "^3.0.8",
"@trigger.dev/react": "^3.0.8",
"@trigger.dev/sdk": "^3.0.8",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -27,8 +30,10 @@
"dexie": "^4.0.8", "dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"js-cookie": "^3.0.5",
"ky": "^1.7.2", "ky": "^1.7.2",
"lucide-react": "^0.446.0", "lucide-react": "^0.446.0",
"mongoose": "^8.7.0",
"next": "15.0.0-rc.0", "next": "15.0.0-rc.0",
"next-build-id": "^3.0.0", "next-build-id": "^3.0.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@ -41,6 +46,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
@ -49,5 +55,8 @@
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
},
"trigger.dev": {
"endpointId": "scoresaber-reloaded-KB0Z"
} }
} }

1104
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { connectMongo } from "@/common/mongo";
import { IPlayer, PlayerModel } from "@/common/schema/player-schema";
import { PlayerHistory } from "@/common/player/player-history";
import { seedPlayerHistory, sortPlayerHistory } from "@/common/player-utils";
import { scoresaberService } from "@/common/service/impl/scoresaber";
export async function GET(request: NextRequest) {
const playerIdCookie = request.cookies.get("playerId");
const id = request.nextUrl.searchParams.get("id");
if (id == null) {
return NextResponse.json(
{ error: "Unknown player. Missing: ?id=" },
{ status: 400 },
);
}
const shouldCreatePlayer = playerIdCookie?.value === id;
await connectMongo(); // Connect to Mongo
// Fetch the player and return their statistic history
let foundPlayer: IPlayer | null = await PlayerModel.findById(id);
if (shouldCreatePlayer && foundPlayer == null) {
foundPlayer = await PlayerModel.create({
_id: id,
});
const response = await scoresaberService.lookupPlayer(id, true);
if (response != undefined) {
const { player, rawPlayer } = response;
await seedPlayerHistory(foundPlayer!, player, rawPlayer);
}
}
if (foundPlayer == null) {
return NextResponse.json({ error: "Player not found" }, { status: 404 });
}
let history: Map<string, PlayerHistory> = foundPlayer.getStatisticHistory();
let fetchedHistory = sortPlayerHistory(history);
fetchedHistory = fetchedHistory.slice(-50); // Get the last 50 entries
const resultHistory: { [key: string]: PlayerHistory } = {};
for (const [date, history] of fetchedHistory) {
resultHistory[date] = history;
}
return NextResponse.json(resultHistory);
}

@ -0,0 +1,10 @@
import { createAppRoute } from "@trigger.dev/nextjs";
import { client } from "@/trigger";
import "@/jobs";
//this route is used to send and receive data with Trigger.dev
export const { POST, dynamic } = createAppRoute(client);
//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration
//export const maxDuration = 60;

@ -1,18 +1,5 @@
"use client"; "use client";
import Card from "@/components/card";
export default function Home() { export default function Home() {
return ( return <main>hi</main>;
<main className="w-[1600px] h-full px-4">
<div className="flex w-full gap-2">
<Card className="w-[45%]">
<p>hello</p>
</Card>
<Card className="w-[55%]">
<p>hello</p>
</Card>
</div>
</main>
);
} }

@ -15,8 +15,8 @@ type Props = {
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const id = slug[0]; // The players id const id = slug[0]; // The players id
const player = await scoresaberService.lookupPlayer(id, false); const response = await scoresaberService.lookupPlayer(id, false);
if (player === undefined) { if (response === undefined) {
return { return {
title: `Unknown Player`, title: `Unknown Player`,
openGraph: { openGraph: {
@ -24,6 +24,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}, },
}; };
} }
const { player } = response;
return { return {
title: `${player.name}`, title: `${player.name}`,
@ -44,18 +45,18 @@ export default async function Search({ params }: Props) {
const id = slug[0]; // The players id const id = slug[0]; // The players id
const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
const page = parseInt(slug[2]) || 1; // The page number const page = parseInt(slug[2]) || 1; // The page number
const player = await scoresaberService.lookupPlayer(id, false); const response = await scoresaberService.lookupPlayer(id, false);
if (response == undefined) {
// Invalid player id
return redirect("/");
}
const scores = await scoresaberService.lookupPlayerScores({ const scores = await scoresaberService.lookupPlayerScores({
playerId: id, playerId: id,
sort, sort,
page, page,
}); });
const { player } = response;
if (player == undefined) {
// Invalid player id
return redirect("/");
}
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
<PlayerData <PlayerData

@ -1,5 +1,9 @@
import Player from "../player"; import Player from "../player";
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token"; import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
import { PlayerHistory } from "@/common/player/player-history";
import { config } from "../../../../../config";
import ky from "ky";
import { getDaysAgoDate, getMidnightAlignedDate } from "@/common/time-utils";
/** /**
* A ScoreSaber player. * A ScoreSaber player.
@ -28,7 +32,7 @@ export default interface ScoreSaberPlayer extends Player {
/** /**
* The rank history for this player. * The rank history for this player.
*/ */
rankHistory: number[]; statisticHistory: { [date: string]: PlayerHistory };
/** /**
* The statistics for this player. * The statistics for this player.
@ -51,9 +55,9 @@ export default interface ScoreSaberPlayer extends Player {
inactive: boolean; inactive: boolean;
} }
export function getScoreSaberPlayerFromToken( export async function getScoreSaberPlayerFromToken(
token: ScoreSaberPlayerToken, token: ScoreSaberPlayerToken,
): ScoreSaberPlayer { ): Promise<ScoreSaberPlayer> {
const bio: ScoreSaberBio = { const bio: ScoreSaberBio = {
lines: token.bio?.split("\n") || [], lines: token.bio?.split("\n") || [],
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [], linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [],
@ -66,7 +70,50 @@ export function getScoreSaberPlayerFromToken(
description: badge.description, description: badge.description,
}; };
}) || []; }) || [];
const rankHistory = token.histories.split(",").map((rank) => Number(rank));
let statisticHistory: { [key: string]: PlayerHistory } = {};
try {
const history = await ky
.get<{
[key: string]: PlayerHistory;
}>(`${config.siteUrl}/api/player/history?id=${token.id}`)
.json();
if (history === undefined || Object.entries(history).length === 0) {
console.log("Player has no history, using fallback");
throw new Error();
}
if (history) {
// Use the latest data for today
history[getMidnightAlignedDate(new Date()).toString()] = {
rank: token.rank,
countryRank: token.countryRank,
pp: token.pp,
};
}
statisticHistory = history;
} catch (error) {
// Fallback to ScoreSaber History if the player has no history
const playerRankHistory = token.histories.split(",").map((value) => {
return parseInt(value);
});
playerRankHistory.push(token.rank);
let daysAgo = 0; // Start from current day
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
daysAgo += 1; // Increment daysAgo for each earlier rank
statisticHistory[date.toString()] = {
rank: rank,
};
}
// Sort the fallback history
statisticHistory = Object.entries(statisticHistory)
.sort()
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
}
return { return {
id: token.id, id: token.id,
@ -80,7 +127,7 @@ export function getScoreSaberPlayerFromToken(
pp: token.pp, pp: token.pp,
role: role, role: role,
badges: badges, badges: badges,
rankHistory: rankHistory, statisticHistory: statisticHistory,
statistics: token.scoreStats, statistics: token.scoreStats,
permissions: token.permissions, permissions: token.permissions,
banned: token.banned, banned: token.banned,

12
src/common/mongo.ts Normal file

@ -0,0 +1,12 @@
import * as mongoose from "mongoose";
/**
* Connects to the mongo database
*/
export async function connectMongo() {
const connectionUri = process.env.MONGO_URI;
if (!connectionUri) {
throw new Error("Missing MONGO_URI");
}
await mongoose.connect(connectionUri);
}

128
src/common/player-utils.ts Normal file

@ -0,0 +1,128 @@
import { PlayerHistory } from "@/common/player/player-history";
import { IPlayer } from "@/common/schema/player-schema";
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { getDaysAgoDate, getMidnightAlignedDate } from "@/common/time-utils";
import { scoresaberService } from "@/common/service/impl/scoresaber";
import { IO } from "@trigger.dev/sdk";
const INACTIVE_CHECK_AGAIN_TIME = 3 * 24 * 60 * 60 * 1000; // 3 days
/**
* Sorts the player history based on date,
* so the most recent date is first
*
* @param history the player history
*/
export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
return Array.from(history.entries()).sort(
(a, b) => Date.parse(b[0]) - Date.parse(a[0]), // Sort in descending order
);
}
/**
* Sorts the player history based on date,
* so the most recent date is first
*
* @param foundPlayer the player
* @param player the scoresaber player
* @param rawPlayer the raw scoresaber player
*/
export async function seedPlayerHistory(
foundPlayer: IPlayer,
player: ScoreSaberPlayer,
rawPlayer: ScoreSaberPlayerToken,
): Promise<Map<string, PlayerHistory>> {
// Loop through rankHistory in reverse, from current day backwards
const playerRankHistory = rawPlayer.histories.split(",").map((value) => {
return parseInt(value);
});
playerRankHistory.push(player.rank);
let daysAgo = 0; // Start from current day
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
foundPlayer.setStatisticHistory(date, {
rank: rank,
});
daysAgo += 1; // Increment daysAgo for each earlier rank
}
foundPlayer.sortStatisticHistory();
await foundPlayer.save();
return foundPlayer.getStatisticHistory();
}
/**
* Tracks a players statistics
*
* This is ONLY to be used in Trigger.
*
* @param io the io from Trigger
* @param dateToday the date to use
* @param foundPlayer the player to track
*/
export async function trackScoreSaberPlayer(
dateToday: Date,
foundPlayer: IPlayer,
io?: IO,
) {
io && (await io.logger.info(`Updating statistics for ${foundPlayer.id}...`));
// Check if the player is inactive and if we check their inactive status again
if (
foundPlayer.rawPlayer &&
foundPlayer.rawPlayer.inactive &&
Date.now() - foundPlayer.getLastTracked().getTime() >
INACTIVE_CHECK_AGAIN_TIME
) {
io &&
(await io.logger.warn(
`Player ${foundPlayer.id} is inactive, skipping...`,
));
return;
}
// Lookup player data from the ScoreSaber service
const response = await scoresaberService.lookupPlayer(foundPlayer.id, true);
if (response == undefined) {
io &&
(await io.logger.warn(
`Player ${foundPlayer.id} not found on ScoreSaber`,
));
return;
}
const { player, rawPlayer } = response;
foundPlayer.rawPlayer = player; // Update the raw player data
if (player.inactive) {
io && (await io.logger.warn(`Player ${foundPlayer.id} is inactive`));
await foundPlayer.save(); // Save the player
return;
}
const statisticHistory = foundPlayer.getStatisticHistory();
// Seed the history with ScoreSaber data if no history exists
if (statisticHistory.size === 0) {
io && (await io.logger.info(`Seeding history for ${foundPlayer.id}...`));
await seedPlayerHistory(foundPlayer, player, rawPlayer);
io && (await io.logger.info(`Seeded history for ${foundPlayer.id}`));
}
// Update current day's statistics
let history = foundPlayer.getHistory(dateToday);
if (history == undefined) {
history = {}; // Initialize if history is not found
}
// Set the history data
history.pp = player.pp;
history.countryRank = player.countryRank;
history.rank = player.rank;
foundPlayer.setStatisticHistory(dateToday, history);
foundPlayer.sortStatisticHistory();
await foundPlayer.save();
io && (await io.logger.info(`Updated statistics for ${foundPlayer.id}`));
}

@ -0,0 +1,16 @@
export interface PlayerHistory {
/**
* The player's rank.
*/
rank?: number;
/**
* The player's country rank.
*/
countryRank?: number;
/**
* The pp of the player.
*/
pp?: number;
}

@ -0,0 +1,123 @@
import mongoose, { Document, Schema } from "mongoose";
import { PlayerHistory } from "@/common/player/player-history";
import { getMidnightAlignedDate } from "@/common/time-utils";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
// Interface for Player Document
export interface IPlayer extends Document {
/**
* The player's id
*/
id: string;
/**
* The player's statistic history
*/
statisticHistory: Map<string, PlayerHistory>;
/**
* The last time the player was tracked
*/
lastTracked: Date;
/**
* The raw player data.
*/
rawPlayer: ScoreSaberPlayer;
/**
* Gets when this player was last tracked.
*/
getLastTracked(): Date;
/**
* Gets the history for the given date
*
* @param date
*/
getHistory(date: Date): PlayerHistory;
/**
* Gets all the statistic history
*/
getStatisticHistory(): Map<string, PlayerHistory>;
/**
* Sets the statistic history for the given date
*
* @param date the date to set it on
* @param data the data to set
*/
setStatisticHistory(date: Date, data: PlayerHistory): void;
/**
* Sorts the statistic history
*/
sortStatisticHistory(): Map<string, PlayerHistory>;
}
// Mongoose Schema definition for Player
const PlayerSchema = new Schema<IPlayer>({
_id: { type: String, required: true },
lastTracked: { type: Date, default: new Date(), required: false },
rawPlayer: { type: Object, required: false },
statisticHistory: { type: Map, default: () => new Map(), required: false },
});
PlayerSchema.methods.getLastTracked = function (): Date {
return this.lastTracked || new Date();
};
PlayerSchema.methods.getHistory = function (date: Date): PlayerHistory {
return (
this.statisticHistory.get(getMidnightAlignedDate(date).toString()) || {}
);
};
PlayerSchema.methods.getStatisticHistory = function (): Map<
Date,
PlayerHistory
> {
if (!this.statisticHistory) {
this.statisticHistory = new Map();
}
return this.statisticHistory;
};
PlayerSchema.methods.setStatisticHistory = function (
date: Date,
data: PlayerHistory,
): void {
if (!this.statisticHistory) {
this.statisticHistory = new Map();
}
const alignedDate = getMidnightAlignedDate(date).toString();
return this.statisticHistory.set(alignedDate, data);
};
PlayerSchema.methods.sortStatisticHistory = function (): Map<
Date,
PlayerHistory
> {
if (!this.statisticHistory) {
this.statisticHistory = new Map();
}
// Sort the player's history
this.statisticHistory = new Map(
Array.from(this.statisticHistory.entries() as [string, PlayerHistory][])
.sort(
(a: [string, PlayerHistory], b: [string, PlayerHistory]) =>
Date.parse(b[0]) - Date.parse(a[0]),
)
// Convert the date strings back to Date objects for the resulting Map
.map(([date, history]) => [new Date(date).toString(), history]),
);
return this.statisticHistory;
};
// Mongoose Model for Player
const PlayerModel =
mongoose.models.Player || mongoose.model<IPlayer>("Player", PlayerSchema);
export { PlayerModel };

@ -62,7 +62,13 @@ class ScoreSaberService extends Service {
async lookupPlayer( async lookupPlayer(
playerId: string, playerId: string,
useProxy = true, useProxy = true,
): Promise<ScoreSaberPlayer | undefined> { ): Promise<
| {
player: ScoreSaberPlayer;
rawPlayer: ScoreSaberPlayerToken;
}
| undefined
> {
const before = performance.now(); const before = performance.now();
this.log(`Looking up player "${playerId}"...`); this.log(`Looking up player "${playerId}"...`);
const token = await this.fetch<ScoreSaberPlayerToken>( const token = await this.fetch<ScoreSaberPlayerToken>(
@ -75,7 +81,10 @@ class ScoreSaberService extends Service {
this.log( this.log(
`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`, `Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`,
); );
return getScoreSaberPlayerFromToken(token); return {
player: await getScoreSaberPlayerFromToken(token),
rawPlayer: token,
};
} }
/** /**

@ -27,3 +27,45 @@ export function timeAgo(input: Date | number) {
} }
} }
} }
/**
* Gets the midnight aligned date
*
* @param date the date
*/
export function getMidnightAlignedDate(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/**
* Gets the date X days ago
*
* @param days the number of days to go back
* @returns {Date} A Date object representing the date X days ago
*/
export function getDaysAgoDate(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
/**
* Gets the amount of days ago a date was
*
* @param date the date
* @returns the amount of days
*/
export function getDaysAgo(date: Date): number {
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) - 1;
}
/**
* Parses a date from a string
*
* @param date the date
*/
export function parseDate(date: string): Date {
return new Date(date);
}

@ -1,10 +1,12 @@
import Cookies from "js-cookie";
/** /**
* Sets the player id cookie * Sets the player id cookie
* *
* @param playerId the player id to set * @param playerId the player id to set
*/ */
export function setPlayerIdCookie(playerId: string) { export function setPlayerIdCookie(playerId: string) {
document.cookie = `playerId=${playerId}`; Cookies.set("playerId", playerId, { path: "/" });
} }
/** /**

@ -35,7 +35,7 @@ export default function PlayerData({
}); });
if (data && (!isLoading || !isError)) { if (data && (!isLoading || !isError)) {
player = data; player = data.player;
} }
return ( return (
@ -55,7 +55,7 @@ export default function PlayerData({
page={page} page={page}
/> />
</article> </article>
<aside className="w-[550px] hidden xl:flex flex-col gap-2"> <aside className="w-[550px] hidden 2xl:flex flex-col gap-2">
<Mini type="Global" player={player} /> <Mini type="Global" player={player} />
<Mini type="Country" player={player} /> <Mini type="Country" player={player} />
</aside> </aside>

@ -14,6 +14,7 @@ import {
} from "chart.js"; } from "chart.js";
import { Line } from "react-chartjs-2"; import { Line } from "react-chartjs-2";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import { getDaysAgo, parseDate } from "@/common/time-utils";
Chart.register( Chart.register(
LinearScale, LinearScale,
@ -25,6 +26,104 @@ Chart.register(
Legend, Legend,
); );
/**
* A ChartJS axis
*/
type Axis = {
id: string;
position: "left" | "right";
display: boolean;
grid?: { color?: string; drawOnChartArea?: boolean };
title?: { display: boolean; text: string; color?: string };
ticks?: {
stepSize: number;
};
reverse: boolean;
};
/**
* A ChartJS dataset
*/
type Dataset = {
label: string;
data: number[];
borderColor: string;
fill: boolean;
lineTension: number;
spanGaps: boolean;
yAxisID: string;
};
/**
* Generate an axis
*
* @param id the id of the axis
* @param display if the axis should be displayed
* @param position the position of the axis
* @param displayName the optional name to display for the axis
*/
const generateAxis = (
id: string,
display: boolean,
position: "right" | "left",
displayName: string,
): Axis => ({
id,
position,
display,
grid: {
drawOnChartArea: id === "y",
color: id === "y" ? "#252525" : "",
},
title: {
display: true,
text: displayName,
color: "#ffffff",
},
ticks: {
stepSize: 10,
},
reverse: true,
});
/**
* Create the axes
*/
const createAxes = () => ({
x: {
grid: {
color: "#252525", // gray grid lines
},
reverse: true,
},
y: generateAxis("y", true, "left", "Global Rank"), // Rank axis with display name
y1: generateAxis("y1", false, "left", "Country Rank"), // Country Rank axis with display name
y2: generateAxis("y2", true, "right", "PP"), // PP axis with display name
});
/**
* Generate a dataset
*
* @param label the label of the dataset
* @param data the data of the dataset
* @param borderColor the border color of the dataset
* @param yAxisID the ID of the y-axis
*/
const generateDataset = (
label: string,
data: number[],
borderColor: string,
yAxisID: string,
): Dataset => ({
label,
data,
borderColor,
fill: false,
lineTension: 0.5,
spanGaps: true,
yAxisID,
});
export const options: any = { export const options: any = {
maintainAspectRatio: false, maintainAspectRatio: false,
aspectRatio: 1, aspectRatio: 1,
@ -32,29 +131,7 @@ export const options: any = {
mode: "index", mode: "index",
intersect: false, intersect: false,
}, },
scales: { scales: createAxes(), // Use createAxes to configure axes
y: {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
stepSize: 1,
},
grid: {
// gray grid lines
color: "#252525",
},
reverse: true,
},
x: {
ticks: {
autoSkip: true,
},
grid: {
// gray grid lines
color: "#252525",
},
},
},
elements: { elements: {
point: { point: {
radius: 0, radius: 0,
@ -67,9 +144,6 @@ export const options: any = {
color: "white", color: "white",
}, },
}, },
title: {
display: false,
},
tooltip: { tooltip: {
callbacks: { callbacks: {
label(context: any) { label(context: any) {
@ -77,6 +151,12 @@ export const options: any = {
case "Rank": { case "Rank": {
return `Rank #${formatNumberWithCommas(Number(context.parsed.y))}`; return `Rank #${formatNumberWithCommas(Number(context.parsed.y))}`;
} }
case "Country Rank": {
return `Country Rank #${formatNumberWithCommas(Number(context.parsed.y))}`;
}
case "PP": {
return `PP ${formatNumberWithCommas(Number(context.parsed.y))}pp`;
}
} }
}, },
}, },
@ -89,30 +169,50 @@ type Props = {
}; };
export default function PlayerRankChart({ player }: Props) { export default function PlayerRankChart({ player }: Props) {
const labels = []; if (
for (let i = player.rankHistory.length; i > 0; i--) { player.statisticHistory === undefined ||
let label = `${i} days ago`; Object.keys(player.statisticHistory).length === 0
if (i === 1) { ) {
label = "now"; return (
} <div className="flex justify-center">
if (i === 2) { <p>Unable to load player rank chart, missing data...</p>
label = "yesterday"; </div>
} );
labels.push(label);
} }
const labels: string[] = [];
const histories: Record<"rank" | "countryRank" | "pp", number[]> = {
rank: [],
countryRank: [],
pp: [],
};
// Create labels and history data
for (const [dateString, history] of Object.entries(player.statisticHistory)) {
const daysAgo = getDaysAgo(parseDate(dateString));
// Create labels based on days ago
if (daysAgo === 0) {
labels.push("Today");
} else if (daysAgo === 1) {
labels.push("Yesterday");
} else {
labels.push(`${daysAgo} days ago`);
}
history.rank && histories.rank.push(history.rank);
history.countryRank && histories.countryRank.push(history.countryRank);
history.pp && histories.pp.push(history.pp);
}
const datasets: Dataset[] = [
generateDataset("Rank", histories["rank"], "#3EC1D3", "y"),
generateDataset("Country Rank", histories["countryRank"], "#FFEA00", "y1"),
generateDataset("PP", histories["pp"], "#606fff", "y2"),
];
const data = { const data = {
labels, labels,
datasets: [ datasets,
{
lineTension: 0.5,
data: player.rankHistory,
label: "Rank",
borderColor: "#606fff",
fill: false,
color: "#fff",
},
],
}; };
return ( return (

@ -12,7 +12,7 @@ import { Avatar, AvatarImage } from "../ui/avatar";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
const PLAYER_NAME_MAX_LENGTH = 14; const PLAYER_NAME_MAX_LENGTH = 18;
type MiniProps = { type MiniProps = {
type: "Global" | "Country"; type: "Global" | "Country";

3
src/jobs/index.ts Normal file

@ -0,0 +1,3 @@
// export all your job files here
export * from "./seed-player-statistics";

@ -0,0 +1,33 @@
import { cronTrigger } from "@trigger.dev/sdk";
import { client } from "@/trigger";
import { connectMongo } from "@/common/mongo";
import { getMidnightAlignedDate } from "@/common/time-utils";
import { IPlayer, PlayerModel } from "@/common/schema/player-schema";
import { trackScoreSaberPlayer } from "@/common/player-utils";
client.defineJob({
id: "track-player-statistics",
name: "Tracks player statistics",
version: "0.0.1",
trigger: cronTrigger({
// Run at 00:01 every day (midnight)
cron: "0 1 * * *",
}),
run: async (payload, io, ctx) => {
await io.logger.info("Connecting to Mongo");
await connectMongo();
await io.logger.info("Finding players...");
const players: IPlayer[] = await PlayerModel.find({});
await io.logger.info(
`Found ${players.length} player${players.length > 1 ? "s" : ""}.`,
);
const dateToday = getMidnightAlignedDate(new Date());
for (const foundPlayer of players) {
await io.runTask(`track-player-${foundPlayer.id}`, async () => {
await trackScoreSaberPlayer(dateToday, foundPlayer, io);
});
}
},
});

7
src/trigger.ts Normal file

@ -0,0 +1,7 @@
import { TriggerClient } from "@trigger.dev/sdk";
export const client = new TriggerClient({
id: "scoresaber-reloaded-KB0Z",
apiKey: process.env.TRIGGER_API_KEY,
apiUrl: process.env.TRIGGER_API_URL,
});