This commit is contained in:
parent
39526dde41
commit
2f18f0f096
6
.env-example
Normal file
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
|
||||||
|
20
.gitea/kubernetes/sealed-secrets.yaml
Normal file
20
.gitea/kubernetes/sealed-secrets.yaml
Normal file
@ -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",
|
||||||
};
|
};
|
||||||
|
11
package.json
11
package.json
@ -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
1104
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
45
src/app/(pages)/api/player/history/route.ts
Normal file
45
src/app/(pages)/api/player/history/route.ts
Normal file
@ -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);
|
||||||
|
}
|
10
src/app/(pages)/api/trigger/route.ts
Normal file
10
src/app/(pages)/api/trigger/route.ts
Normal file
@ -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
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
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}`));
|
||||||
|
}
|
16
src/common/player/player-history.ts
Normal file
16
src/common/player/player-history.ts
Normal file
@ -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;
|
||||||
|
}
|
123
src/common/schema/player-schema.ts
Normal file
123
src/common/schema/player-schema.ts
Normal file
@ -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">
|
||||||
|
<p>Unable to load player rank chart, missing data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (i === 2) {
|
|
||||||
label = "yesterday";
|
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`);
|
||||||
}
|
}
|
||||||
labels.push(label);
|
|
||||||
|
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
3
src/jobs/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// export all your job files here
|
||||||
|
|
||||||
|
export * from "./seed-player-statistics";
|
33
src/jobs/seed-player-statistics.ts
Normal file
33
src/jobs/seed-player-statistics.ts
Normal file
@ -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
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,
|
||||||
|
});
|
Reference in New Issue
Block a user