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:
|
||||
cpu: 1000m # 1 vCPU
|
||||
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
|
||||
namespace: public-services
|
||||
manifests: |
|
||||
.gitea/kubernetes/sealed-secrets.yaml
|
||||
.gitea/kubernetes/deployment.yaml
|
||||
.gitea/kubernetes/service.yaml
|
||||
.gitea/kubernetes/ingress.yaml
|
||||
|
@ -1,3 +1,3 @@
|
||||
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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
@ -20,6 +20,9 @@
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@ -27,8 +30,10 @@
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"framer-motion": "^11.5.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"ky": "^1.7.2",
|
||||
"lucide-react": "^0.446.0",
|
||||
"mongoose": "^8.7.0",
|
||||
"next": "15.0.0-rc.0",
|
||||
"next-build-id": "^3.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@ -41,6 +46,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
@ -49,5 +55,8 @@
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"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";
|
||||
|
||||
import Card from "@/components/card";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <main>hi</main>;
|
||||
}
|
||||
|
@ -15,8 +15,8 @@ type Props = {
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const id = slug[0]; // The players id
|
||||
const player = await scoresaberService.lookupPlayer(id, false);
|
||||
if (player === undefined) {
|
||||
const response = await scoresaberService.lookupPlayer(id, false);
|
||||
if (response === undefined) {
|
||||
return {
|
||||
title: `Unknown Player`,
|
||||
openGraph: {
|
||||
@ -24,6 +24,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
},
|
||||
};
|
||||
}
|
||||
const { player } = response;
|
||||
|
||||
return {
|
||||
title: `${player.name}`,
|
||||
@ -44,18 +45,18 @@ export default async function Search({ params }: Props) {
|
||||
const id = slug[0]; // The players id
|
||||
const sort: ScoreSort = (slug[1] as ScoreSort) || "recent"; // The sorting method
|
||||
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({
|
||||
playerId: id,
|
||||
sort,
|
||||
page,
|
||||
});
|
||||
|
||||
if (player == undefined) {
|
||||
// Invalid player id
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const { player } = response;
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<PlayerData
|
||||
|
@ -1,5 +1,9 @@
|
||||
import Player from "../player";
|
||||
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.
|
||||
@ -28,7 +32,7 @@ export default interface ScoreSaberPlayer extends Player {
|
||||
/**
|
||||
* The rank history for this player.
|
||||
*/
|
||||
rankHistory: number[];
|
||||
statisticHistory: { [date: string]: PlayerHistory };
|
||||
|
||||
/**
|
||||
* The statistics for this player.
|
||||
@ -51,9 +55,9 @@ export default interface ScoreSaberPlayer extends Player {
|
||||
inactive: boolean;
|
||||
}
|
||||
|
||||
export function getScoreSaberPlayerFromToken(
|
||||
export async function getScoreSaberPlayerFromToken(
|
||||
token: ScoreSaberPlayerToken,
|
||||
): ScoreSaberPlayer {
|
||||
): Promise<ScoreSaberPlayer> {
|
||||
const bio: ScoreSaberBio = {
|
||||
lines: token.bio?.split("\n") || [],
|
||||
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [],
|
||||
@ -66,7 +70,50 @@ export function getScoreSaberPlayerFromToken(
|
||||
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 {
|
||||
id: token.id,
|
||||
@ -80,7 +127,7 @@ export function getScoreSaberPlayerFromToken(
|
||||
pp: token.pp,
|
||||
role: role,
|
||||
badges: badges,
|
||||
rankHistory: rankHistory,
|
||||
statisticHistory: statisticHistory,
|
||||
statistics: token.scoreStats,
|
||||
permissions: token.permissions,
|
||||
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(
|
||||
playerId: string,
|
||||
useProxy = true,
|
||||
): Promise<ScoreSaberPlayer | undefined> {
|
||||
): Promise<
|
||||
| {
|
||||
player: ScoreSaberPlayer;
|
||||
rawPlayer: ScoreSaberPlayerToken;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const before = performance.now();
|
||||
this.log(`Looking up player "${playerId}"...`);
|
||||
const token = await this.fetch<ScoreSaberPlayerToken>(
|
||||
@ -75,7 +81,10 @@ class ScoreSaberService extends Service {
|
||||
this.log(
|
||||
`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
|
||||
*
|
||||
* @param playerId the player id to set
|
||||
*/
|
||||
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)) {
|
||||
player = data;
|
||||
player = data.player;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -55,7 +55,7 @@ export default function PlayerData({
|
||||
page={page}
|
||||
/>
|
||||
</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="Country" player={player} />
|
||||
</aside>
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
} from "chart.js";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||
import { getDaysAgo, parseDate } from "@/common/time-utils";
|
||||
|
||||
Chart.register(
|
||||
LinearScale,
|
||||
@ -25,6 +26,104 @@ Chart.register(
|
||||
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 = {
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 1,
|
||||
@ -32,29 +131,7 @@ export const options: any = {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: createAxes(), // Use createAxes to configure axes
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
@ -67,9 +144,6 @@ export const options: any = {
|
||||
color: "white",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context: any) {
|
||||
@ -77,6 +151,12 @@ export const options: any = {
|
||||
case "Rank": {
|
||||
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) {
|
||||
const labels = [];
|
||||
for (let i = player.rankHistory.length; i > 0; i--) {
|
||||
let label = `${i} days ago`;
|
||||
if (i === 1) {
|
||||
label = "now";
|
||||
if (
|
||||
player.statisticHistory === undefined ||
|
||||
Object.keys(player.statisticHistory).length === 0
|
||||
) {
|
||||
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 = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
lineTension: 0.5,
|
||||
data: player.rankHistory,
|
||||
label: "Rank",
|
||||
borderColor: "#606fff",
|
||||
fill: false,
|
||||
color: "#fff",
|
||||
},
|
||||
],
|
||||
datasets,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -12,7 +12,7 @@ import { Avatar, AvatarImage } from "../ui/avatar";
|
||||
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const PLAYER_NAME_MAX_LENGTH = 14;
|
||||
const PLAYER_NAME_MAX_LENGTH = 18;
|
||||
|
||||
type MiniProps = {
|
||||
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