mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2026-01-07 16:05:12 +08:00
feat: leaderboards remake, weekly xp leaderboards (@miodec) (#6250)
This commit is contained in:
parent
e7685c5861
commit
01dee3fe15
32 changed files with 2223 additions and 1436 deletions
|
|
@ -7,11 +7,7 @@ import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards";
|
|||
import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import { mockAuthenticateWithApeKey } from "../../__testData__/auth";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
XpLeaderboardEntry,
|
||||
XpLeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { XpLeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
|
@ -41,32 +37,39 @@ describe("Loaderboard Controller", () => {
|
|||
it("should get for english time 60", async () => {
|
||||
//GIVEN
|
||||
|
||||
const resultData = [
|
||||
{
|
||||
wpm: 20,
|
||||
acc: 90,
|
||||
timestamp: 1000,
|
||||
raw: 92,
|
||||
consistency: 80,
|
||||
uid: "user1",
|
||||
name: "user1",
|
||||
discordId: "discordId",
|
||||
discordAvatar: "discordAvatar",
|
||||
rank: 1,
|
||||
badgeId: 1,
|
||||
isPremium: true,
|
||||
},
|
||||
{
|
||||
wpm: 10,
|
||||
acc: 80,
|
||||
timestamp: 1200,
|
||||
raw: 82,
|
||||
uid: "user2",
|
||||
name: "user2",
|
||||
rank: 2,
|
||||
},
|
||||
];
|
||||
const mockData = resultData.map((it) => ({ ...it, _id: new ObjectId() }));
|
||||
const resultData = {
|
||||
count: 0,
|
||||
pageSize: 50,
|
||||
entries: [
|
||||
{
|
||||
wpm: 20,
|
||||
acc: 90,
|
||||
timestamp: 1000,
|
||||
raw: 92,
|
||||
consistency: 80,
|
||||
uid: "user1",
|
||||
name: "user1",
|
||||
discordId: "discordId",
|
||||
discordAvatar: "discordAvatar",
|
||||
rank: 1,
|
||||
badgeId: 1,
|
||||
isPremium: true,
|
||||
},
|
||||
{
|
||||
wpm: 10,
|
||||
acc: 80,
|
||||
timestamp: 1200,
|
||||
raw: 82,
|
||||
uid: "user2",
|
||||
name: "user2",
|
||||
rank: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockData = resultData.entries.map((it) => ({
|
||||
...it,
|
||||
_id: new ObjectId(),
|
||||
}));
|
||||
getLeaderboardMock.mockResolvedValue(mockData);
|
||||
|
||||
//WHEN
|
||||
|
|
@ -91,11 +94,11 @@ describe("Loaderboard Controller", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("should get for english time 60 with skip and limit", async () => {
|
||||
it("should get for english time 60 with page", async () => {
|
||||
//GIVEN
|
||||
getLeaderboardMock.mockResolvedValue([]);
|
||||
const skip = 23;
|
||||
const limit = 42;
|
||||
const page = 0;
|
||||
const pageSize = 25;
|
||||
|
||||
//WHEN
|
||||
|
||||
|
|
@ -105,23 +108,27 @@ describe("Loaderboard Controller", () => {
|
|||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
skip,
|
||||
limit,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Leaderboard retrieved",
|
||||
data: [],
|
||||
data: {
|
||||
count: 0,
|
||||
pageSize: 25,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getLeaderboardMock).toHaveBeenCalledWith(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
skip,
|
||||
limit
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -166,8 +173,8 @@ describe("Loaderboard Controller", () => {
|
|||
language: "en?gli.sh",
|
||||
mode: "unknownMode",
|
||||
mode2: "unknownMode2",
|
||||
skip: -1,
|
||||
limit: 100,
|
||||
page: -1,
|
||||
pageSize: 500,
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
|
|
@ -177,8 +184,8 @@ describe("Loaderboard Controller", () => {
|
|||
'"language" Can only contain letters [a-zA-Z0-9_+]',
|
||||
`"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`,
|
||||
'"mode2" Needs to be a number or a number represented as a string e.g. "10".',
|
||||
'"skip" Number must be greater than or equal to 0',
|
||||
'"limit" Number must be less than or equal to 50',
|
||||
'"page" Number must be greater than or equal to 0',
|
||||
'"pageSize" Number must be less than or equal to 200',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -246,11 +253,7 @@ describe("Loaderboard Controller", () => {
|
|||
name: "user2",
|
||||
rank: 2,
|
||||
};
|
||||
getLeaderboardRankMock.mockResolvedValue({
|
||||
count: 1000,
|
||||
rank: 50,
|
||||
entry: resultEntry,
|
||||
});
|
||||
getLeaderboardRankMock.mockResolvedValue(resultEntry);
|
||||
|
||||
//WHEN
|
||||
|
||||
|
|
@ -263,11 +266,7 @@ describe("Loaderboard Controller", () => {
|
|||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rank retrieved",
|
||||
data: {
|
||||
count: 1000,
|
||||
rank: 50,
|
||||
entry: resultEntry,
|
||||
},
|
||||
data: resultEntry,
|
||||
});
|
||||
|
||||
expect(getLeaderboardRankMock).toHaveBeenCalledWith(
|
||||
|
|
@ -396,6 +395,8 @@ describe("Loaderboard Controller", () => {
|
|||
|
||||
getDailyLeaderboardMock.mockReturnValue({
|
||||
getResults: () => Promise.resolve([]),
|
||||
getCount: () => Promise.resolve(0),
|
||||
getMinWpm: () => Promise.resolve(0),
|
||||
} as any);
|
||||
});
|
||||
|
||||
|
|
@ -408,35 +409,47 @@ describe("Loaderboard Controller", () => {
|
|||
const lbConf = (await configuration).dailyLeaderboards;
|
||||
const premiumEnabled = (await configuration).users.premium.enabled;
|
||||
|
||||
const resultData: LeaderboardEntry[] = [
|
||||
{
|
||||
name: "user1",
|
||||
rank: 1,
|
||||
wpm: 20,
|
||||
acc: 90,
|
||||
timestamp: 1000,
|
||||
raw: 92,
|
||||
consistency: 80,
|
||||
uid: "user1",
|
||||
discordId: "discordId",
|
||||
discordAvatar: "discordAvatar",
|
||||
},
|
||||
{
|
||||
wpm: 10,
|
||||
rank: 2,
|
||||
acc: 80,
|
||||
timestamp: 1200,
|
||||
raw: 82,
|
||||
consistency: 72,
|
||||
uid: "user2",
|
||||
name: "user2",
|
||||
},
|
||||
];
|
||||
const resultData = {
|
||||
minWpm: 10,
|
||||
entries: [
|
||||
{
|
||||
name: "user1",
|
||||
rank: 1,
|
||||
wpm: 20,
|
||||
acc: 90,
|
||||
timestamp: 1000,
|
||||
raw: 92,
|
||||
consistency: 80,
|
||||
uid: "user1",
|
||||
discordId: "discordId",
|
||||
discordAvatar: "discordAvatar",
|
||||
},
|
||||
{
|
||||
wpm: 10,
|
||||
rank: 2,
|
||||
acc: 80,
|
||||
timestamp: 1200,
|
||||
raw: 82,
|
||||
consistency: 72,
|
||||
uid: "user2",
|
||||
name: "user2",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const getResultMock = vi.fn();
|
||||
getResultMock.mockResolvedValue(resultData);
|
||||
|
||||
const getCountMock = vi.fn();
|
||||
getCountMock.mockResolvedValue(2);
|
||||
|
||||
const getMinWpmMock = vi.fn();
|
||||
getMinWpmMock.mockResolvedValue(10);
|
||||
|
||||
getDailyLeaderboardMock.mockReturnValue({
|
||||
getResults: getResultMock,
|
||||
getCount: getCountMock,
|
||||
getMinWpm: getMinWpmMock,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
|
|
@ -448,7 +461,12 @@ describe("Loaderboard Controller", () => {
|
|||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Daily leaderboard retrieved",
|
||||
data: resultData,
|
||||
data: {
|
||||
count: 2,
|
||||
pageSize: 50,
|
||||
minWpm: 10,
|
||||
entries: resultData,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getDailyLeaderboardMock).toHaveBeenCalledWith(
|
||||
|
|
@ -459,7 +477,7 @@ describe("Loaderboard Controller", () => {
|
|||
-1
|
||||
);
|
||||
|
||||
expect(getResultMock).toHaveBeenCalledWith(0, 49, lbConf, premiumEnabled);
|
||||
expect(getResultMock).toHaveBeenCalledWith(0, 50, lbConf, premiumEnabled);
|
||||
});
|
||||
|
||||
it("should get for english time 60 for yesterday", async () => {
|
||||
|
|
@ -480,7 +498,12 @@ describe("Loaderboard Controller", () => {
|
|||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Daily leaderboard retrieved",
|
||||
data: [],
|
||||
data: {
|
||||
entries: [],
|
||||
count: 0,
|
||||
pageSize: 50,
|
||||
minWpm: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getDailyLeaderboardMock).toHaveBeenCalledWith(
|
||||
|
|
@ -491,17 +514,26 @@ describe("Loaderboard Controller", () => {
|
|||
1722470400000
|
||||
);
|
||||
});
|
||||
it("should get for english time 60 with skip and limit", async () => {
|
||||
it("should get for english time 60 with page and pageSize", async () => {
|
||||
//GIVEN
|
||||
const lbConf = (await configuration).dailyLeaderboards;
|
||||
const premiumEnabled = (await configuration).users.premium.enabled;
|
||||
const limit = 23;
|
||||
const skip = 42;
|
||||
const page = 2;
|
||||
const pageSize = 25;
|
||||
|
||||
const getResultMock = vi.fn();
|
||||
getResultMock.mockResolvedValue([]);
|
||||
|
||||
const getCountMock = vi.fn();
|
||||
getCountMock.mockResolvedValue(0);
|
||||
|
||||
const getMinWpmMock = vi.fn();
|
||||
getMinWpmMock.mockResolvedValue(0);
|
||||
|
||||
getDailyLeaderboardMock.mockReturnValue({
|
||||
getResults: getResultMock,
|
||||
getCount: getCountMock,
|
||||
getMinWpm: getMinWpmMock,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
|
|
@ -511,15 +543,20 @@ describe("Loaderboard Controller", () => {
|
|||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
skip,
|
||||
limit,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Daily leaderboard retrieved",
|
||||
data: [],
|
||||
data: {
|
||||
entries: [],
|
||||
count: 0,
|
||||
pageSize,
|
||||
minWpm: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getDailyLeaderboardMock).toHaveBeenCalledWith(
|
||||
|
|
@ -531,8 +568,8 @@ describe("Loaderboard Controller", () => {
|
|||
);
|
||||
|
||||
expect(getResultMock).toHaveBeenCalledWith(
|
||||
skip,
|
||||
skip + limit - 1,
|
||||
page,
|
||||
pageSize,
|
||||
lbConf,
|
||||
premiumEnabled
|
||||
);
|
||||
|
|
@ -841,6 +878,7 @@ describe("Loaderboard Controller", () => {
|
|||
|
||||
getXpWeeklyLeaderboardMock.mockReturnValue({
|
||||
getResults: () => Promise.resolve([]),
|
||||
getCount: () => Promise.resolve(0),
|
||||
} as any);
|
||||
});
|
||||
|
||||
|
|
@ -877,8 +915,13 @@ describe("Loaderboard Controller", () => {
|
|||
|
||||
const getResultMock = vi.fn();
|
||||
getResultMock.mockResolvedValue(resultData);
|
||||
|
||||
const getCountMock = vi.fn();
|
||||
getCountMock.mockResolvedValue(2);
|
||||
|
||||
getXpWeeklyLeaderboardMock.mockReturnValue({
|
||||
getResults: getResultMock,
|
||||
getCount: getCountMock,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
|
|
@ -890,12 +933,16 @@ describe("Loaderboard Controller", () => {
|
|||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Weekly xp leaderboard retrieved",
|
||||
data: resultData,
|
||||
data: {
|
||||
entries: resultData,
|
||||
count: 2,
|
||||
pageSize: 50,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1);
|
||||
|
||||
expect(getResultMock).toHaveBeenCalledWith(0, 49, lbConf);
|
||||
expect(getResultMock).toHaveBeenCalledWith(0, 50, lbConf, false);
|
||||
});
|
||||
|
||||
it("should get for last week", async () => {
|
||||
|
|
@ -913,7 +960,11 @@ describe("Loaderboard Controller", () => {
|
|||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Weekly xp leaderboard retrieved",
|
||||
data: [],
|
||||
data: {
|
||||
count: 0,
|
||||
entries: [],
|
||||
pageSize: 50,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(
|
||||
|
|
@ -925,37 +976,42 @@ describe("Loaderboard Controller", () => {
|
|||
it("should get with skip and limit", async () => {
|
||||
//GIVEN
|
||||
const lbConf = (await configuration).leaderboards.weeklyXp;
|
||||
const limit = 23;
|
||||
const skip = 42;
|
||||
const page = 2;
|
||||
const pageSize = 25;
|
||||
|
||||
const getResultMock = vi.fn();
|
||||
getResultMock.mockResolvedValue([]);
|
||||
|
||||
const getCountMock = vi.fn();
|
||||
getCountMock.mockResolvedValue(0);
|
||||
|
||||
getXpWeeklyLeaderboardMock.mockReturnValue({
|
||||
getResults: getResultMock,
|
||||
getCount: getCountMock,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/leaderboards/xp/weekly")
|
||||
.query({
|
||||
skip,
|
||||
limit,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Weekly xp leaderboard retrieved",
|
||||
data: [],
|
||||
data: {
|
||||
entries: [],
|
||||
count: 0,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1);
|
||||
|
||||
expect(getResultMock).toHaveBeenCalledWith(
|
||||
skip,
|
||||
skip + limit - 1,
|
||||
lbConf
|
||||
);
|
||||
expect(getResultMock).toHaveBeenCalledWith(page, pageSize, lbConf, false);
|
||||
});
|
||||
|
||||
it("fails if daily leaderboards are disabled", async () => {
|
||||
|
|
@ -1021,10 +1077,9 @@ describe("Loaderboard Controller", () => {
|
|||
//GIVEN
|
||||
const lbConf = (await configuration).leaderboards.weeklyXp;
|
||||
|
||||
const resultData: XpLeaderboardRank = {
|
||||
const resultData: XpLeaderboardEntry = {
|
||||
totalXp: 100,
|
||||
rank: 1,
|
||||
count: 100,
|
||||
timeTypedSeconds: 100,
|
||||
uid: "user1",
|
||||
name: "user1",
|
||||
|
|
|
|||
|
|
@ -24,11 +24,11 @@ import { ObjectId } from "mongodb";
|
|||
import { PersonalBest } from "@monkeytype/contracts/schemas/shared";
|
||||
import { pb } from "../../dal/leaderboards.spec";
|
||||
import { mockAuthenticateWithApeKey } from "../../__testData__/auth";
|
||||
import { LeaderboardRank } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import _ from "lodash";
|
||||
import { MonkeyMail, UserStreak } from "@monkeytype/contracts/schemas/users";
|
||||
import { isFirebaseError } from "../../../src/utils/error";
|
||||
import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
|
@ -2696,6 +2696,7 @@ describe("user controller test", () => {
|
|||
const getUserByNameMock = vi.spyOn(UserDal, "getUserByName");
|
||||
const checkIfUserIsPremiumMock = vi.spyOn(UserDal, "checkIfUserIsPremium");
|
||||
const leaderboardGetRankMock = vi.spyOn(LeaderboardDal, "getRank");
|
||||
const leaderboardGetCountMock = vi.spyOn(LeaderboardDal, "getCount");
|
||||
|
||||
const foundUser: Partial<UserDal.DBUser> = {
|
||||
_id: new ObjectId(),
|
||||
|
|
@ -2747,6 +2748,7 @@ describe("user controller test", () => {
|
|||
getUserByNameMock.mockReset();
|
||||
checkIfUserIsPremiumMock.mockReset().mockResolvedValue(true);
|
||||
leaderboardGetRankMock.mockReset();
|
||||
leaderboardGetCountMock.mockReset();
|
||||
await enableProfiles(true);
|
||||
});
|
||||
|
||||
|
|
@ -2754,8 +2756,9 @@ describe("user controller test", () => {
|
|||
//GIVEN
|
||||
getUserByNameMock.mockResolvedValue(foundUser as any);
|
||||
|
||||
const rank: LeaderboardRank = { count: 100, rank: 24 };
|
||||
const rank = { rank: 24 } as LeaderboardEntry;
|
||||
leaderboardGetRankMock.mockResolvedValue(rank);
|
||||
leaderboardGetCountMock.mockResolvedValue(100);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/users/bob/profile").expect(200);
|
||||
|
|
@ -2815,8 +2818,9 @@ describe("user controller test", () => {
|
|||
banned: true,
|
||||
} as any);
|
||||
|
||||
const rank: LeaderboardRank = { count: 100, rank: 24 };
|
||||
const rank = { rank: 24 } as LeaderboardEntry;
|
||||
leaderboardGetRankMock.mockResolvedValue(rank);
|
||||
leaderboardGetCountMock.mockResolvedValue(100);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/users/bob/profile").expect(200);
|
||||
|
|
@ -2865,8 +2869,9 @@ describe("user controller test", () => {
|
|||
const uid = foundUser.uid;
|
||||
getUserMock.mockResolvedValue(foundUser as any);
|
||||
|
||||
const rank: LeaderboardRank = { count: 100, rank: 24 };
|
||||
const rank = { rank: 24 } as LeaderboardEntry;
|
||||
leaderboardGetRankMock.mockResolvedValue(rank);
|
||||
leaderboardGetCountMock.mockResolvedValue(100);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe("LeaderboardsDal", () => {
|
|||
|
||||
//WHEN
|
||||
await LeaderboardsDal.update("time", "15", "english");
|
||||
const result = await LeaderboardsDal.get("time", "15", "english", 0);
|
||||
const result = await LeaderboardsDal.get("time", "15", "english", 0, 50);
|
||||
|
||||
//THEN
|
||||
expect(result).toHaveLength(1);
|
||||
|
|
@ -50,7 +50,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -76,7 +77,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"60",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -102,7 +104,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"60",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -125,7 +128,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -187,7 +191,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -223,7 +228,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -255,7 +261,8 @@ describe("LeaderboardsDal", () => {
|
|||
"time",
|
||||
"15",
|
||||
"english",
|
||||
0
|
||||
0,
|
||||
50
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
|
|||
|
|
@ -21,4 +21,4 @@ for _, user_id in ipairs(scores_in_range) do
|
|||
end
|
||||
end
|
||||
|
||||
return {results, scores}
|
||||
return {results, scores}
|
||||
|
|
@ -7,14 +7,15 @@ import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
|||
import {
|
||||
GetDailyLeaderboardQuery,
|
||||
GetDailyLeaderboardRankQuery,
|
||||
GetDailyLeaderboardResponse,
|
||||
GetLeaderboardDailyRankResponse,
|
||||
GetLeaderboardQuery,
|
||||
GetLeaderboardRankQuery,
|
||||
GetLeaderboardRankResponse,
|
||||
GetLeaderboardResponse as GetLeaderboardResponse,
|
||||
GetWeeklyXpLeaderboardQuery,
|
||||
GetWeeklyXpLeaderboardRankResponse,
|
||||
GetWeeklyXpLeaderboardResponse,
|
||||
LanguageAndModeQuery,
|
||||
} from "@monkeytype/contracts/leaderboards";
|
||||
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
|
||||
import {
|
||||
|
|
@ -27,14 +28,14 @@ import { MonkeyRequest } from "../types";
|
|||
export async function getLeaderboard(
|
||||
req: MonkeyRequest<GetLeaderboardQuery>
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { language, mode, mode2, skip = 0, limit = 50 } = req.query;
|
||||
const { language, mode, mode2, page, pageSize } = req.query;
|
||||
|
||||
const leaderboard = await LeaderboardsDAL.get(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
skip,
|
||||
limit
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
|
||||
if (leaderboard === false) {
|
||||
|
|
@ -44,13 +45,18 @@ export async function getLeaderboard(
|
|||
);
|
||||
}
|
||||
|
||||
const count = await LeaderboardsDAL.getCount(mode, mode2, language);
|
||||
const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"]));
|
||||
|
||||
return new MonkeyResponse("Leaderboard retrieved", normalizedLeaderboard);
|
||||
return new MonkeyResponse("Leaderboard retrieved", {
|
||||
count,
|
||||
entries: normalizedLeaderboard,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRankFromLeaderboard(
|
||||
req: MonkeyRequest<LanguageAndModeQuery>
|
||||
req: MonkeyRequest<GetLeaderboardRankQuery>
|
||||
): Promise<GetLeaderboardRankResponse> {
|
||||
const { language, mode, mode2 } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
|
@ -91,25 +97,33 @@ function getDailyLeaderboardWithError(
|
|||
|
||||
export async function getDailyLeaderboard(
|
||||
req: MonkeyRequest<GetDailyLeaderboardQuery>
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { skip = 0, limit = 50 } = req.query;
|
||||
): Promise<GetDailyLeaderboardResponse> {
|
||||
const { page, pageSize } = req.query;
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
const minRank = skip;
|
||||
const maxRank = minRank + limit - 1;
|
||||
|
||||
const topResults = await dailyLeaderboard.getResults(
|
||||
minRank,
|
||||
maxRank,
|
||||
const results = await dailyLeaderboard.getResults(
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
req.ctx.configuration.users.premium.enabled
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard retrieved", topResults);
|
||||
const minWpm = await dailyLeaderboard.getMinWpm(
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
const count = await dailyLeaderboard.getCount();
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard retrieved", {
|
||||
entries: results,
|
||||
minWpm,
|
||||
count,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboardRank(
|
||||
|
|
@ -131,8 +145,8 @@ export async function getDailyLeaderboardRank(
|
|||
}
|
||||
|
||||
function getWeeklyXpLeaderboardWithError(
|
||||
{ weeksBefore }: GetWeeklyXpLeaderboardQuery,
|
||||
config: Configuration["leaderboards"]["weeklyXp"]
|
||||
config: Configuration["leaderboards"]["weeklyXp"],
|
||||
weeksBefore?: number
|
||||
): WeeklyXpLeaderboard.WeeklyXpLeaderboard {
|
||||
const customTimestamp =
|
||||
weeksBefore === undefined
|
||||
|
|
@ -150,22 +164,26 @@ function getWeeklyXpLeaderboardWithError(
|
|||
export async function getWeeklyXpLeaderboardResults(
|
||||
req: MonkeyRequest<GetWeeklyXpLeaderboardQuery>
|
||||
): Promise<GetWeeklyXpLeaderboardResponse> {
|
||||
const { skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const minRank = skip;
|
||||
const maxRank = minRank + limit - 1;
|
||||
const { page, pageSize, weeksBefore } = req.query;
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
weeksBefore
|
||||
);
|
||||
const results = await weeklyXpLeaderboard.getResults(
|
||||
minRank,
|
||||
maxRank,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
req.ctx.configuration.users.premium.enabled
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard retrieved", results);
|
||||
const count = await weeklyXpLeaderboard.getCount();
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard retrieved", {
|
||||
entries: results,
|
||||
count,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardRank(
|
||||
|
|
@ -174,7 +192,6 @@ export async function getWeeklyXpLeaderboardRank(
|
|||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
{},
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
const rankEntry = await weeklyXpLeaderboard.getRank(
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ export async function addResult(
|
|||
discordId: user.discordId,
|
||||
badgeId: selectedBadgeId,
|
||||
lastActivityTimestamp: Date.now(),
|
||||
isPremium,
|
||||
},
|
||||
xpGained: xpGained.xp,
|
||||
timeTypedSeconds: totalDurationTypedSeconds,
|
||||
|
|
|
|||
|
|
@ -1071,6 +1071,12 @@ async function getAllTimeLbs(uid: string): Promise<AllTimeLbs> {
|
|||
uid
|
||||
);
|
||||
|
||||
const allTime15EnglishCount = await LeaderboardsDAL.getCount(
|
||||
"time",
|
||||
"15",
|
||||
"english"
|
||||
);
|
||||
|
||||
const allTime60English = await LeaderboardsDAL.getRank(
|
||||
"time",
|
||||
"60",
|
||||
|
|
@ -1078,20 +1084,26 @@ async function getAllTimeLbs(uid: string): Promise<AllTimeLbs> {
|
|||
uid
|
||||
);
|
||||
|
||||
const allTime60EnglishCount = await LeaderboardsDAL.getCount(
|
||||
"time",
|
||||
"60",
|
||||
"english"
|
||||
);
|
||||
|
||||
const english15 =
|
||||
allTime15English === false
|
||||
allTime15English === false || allTime15English === null
|
||||
? undefined
|
||||
: {
|
||||
rank: allTime15English.rank,
|
||||
count: allTime15English.count,
|
||||
count: allTime15EnglishCount,
|
||||
};
|
||||
|
||||
const english60 =
|
||||
allTime60English === false
|
||||
allTime60English === false || allTime60English === null
|
||||
? undefined
|
||||
: {
|
||||
rank: allTime60English.rank,
|
||||
count: allTime60English.count,
|
||||
count: allTime60EnglishCount,
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,10 @@ import { getCachedConfiguration } from "../init/configuration";
|
|||
|
||||
import { addLog } from "./logs";
|
||||
import { Collection, ObjectId } from "mongodb";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
LeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { omit } from "lodash";
|
||||
import { DBUser } from "./user";
|
||||
import MonkeyError from "../utils/error";
|
||||
|
||||
export type DBLeaderboardEntry = LeaderboardEntry & {
|
||||
_id: ObjectId;
|
||||
|
|
@ -31,13 +29,16 @@ export async function get(
|
|||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
skip: number,
|
||||
limit = 50
|
||||
page: number,
|
||||
pageSize: number
|
||||
): Promise<DBLeaderboardEntry[] | false> {
|
||||
//if (leaderboardUpdating[`${language}_${mode}_${mode2}`]) return false;
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
const skip = page * pageSize;
|
||||
const limit = pageSize;
|
||||
|
||||
if (limit > 50 || limit <= 0) limit = 50;
|
||||
if (skip < 0) skip = 0;
|
||||
try {
|
||||
const preset = await getCollection({ language, mode, mode2 })
|
||||
.find()
|
||||
|
|
@ -64,27 +65,26 @@ export async function get(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getCount(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string
|
||||
): Promise<number> {
|
||||
return getCollection({ language, mode, mode2 }).estimatedDocumentCount();
|
||||
}
|
||||
|
||||
export async function getRank(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
uid: string
|
||||
): Promise<LeaderboardRank | false> {
|
||||
): Promise<LeaderboardEntry | null | false> {
|
||||
try {
|
||||
const entry = await getCollection({ language, mode, mode2 }).findOne({
|
||||
uid,
|
||||
});
|
||||
const count = await getCollection({
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
}).estimatedDocumentCount();
|
||||
|
||||
return {
|
||||
count,
|
||||
rank: entry?.rank,
|
||||
entry: entry !== null ? entry : undefined,
|
||||
};
|
||||
return entry;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (e.error === 175) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
|
||||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import {
|
||||
XpLeaderboardEntry,
|
||||
XpLeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { XpLeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { getCurrentWeekTimestamp } from "@monkeytype/util/date-and-time";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { omit } from "lodash";
|
||||
|
||||
type AddResultOpts = {
|
||||
entry: Pick<
|
||||
|
|
@ -16,6 +15,7 @@ type AddResultOpts = {
|
|||
| "discordAvatar"
|
||||
| "badgeId"
|
||||
| "lastActivityTimestamp"
|
||||
| "isPremium"
|
||||
>;
|
||||
xpGained: number;
|
||||
timeTypedSeconds: number;
|
||||
|
|
@ -118,15 +118,23 @@ export class WeeklyXpLeaderboard {
|
|||
}
|
||||
|
||||
public async getResults(
|
||||
minRank: number,
|
||||
maxRank: number,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
|
||||
page: number,
|
||||
pageSize: number,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"],
|
||||
premiumFeaturesEnabled: boolean
|
||||
): Promise<XpLeaderboardEntry[]> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
const minRank = page * pageSize;
|
||||
const maxRank = minRank + pageSize - 1;
|
||||
|
||||
const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } =
|
||||
this.getThisWeeksXpLeaderboardKeys();
|
||||
|
||||
|
|
@ -166,16 +174,20 @@ export class WeeklyXpLeaderboard {
|
|||
}
|
||||
);
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
return resultsWithRanks.map((it) => omit(it, "isPremium"));
|
||||
}
|
||||
|
||||
return resultsWithRanks;
|
||||
}
|
||||
|
||||
public async getRank(
|
||||
uid: string,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
|
||||
): Promise<XpLeaderboardRank | null> {
|
||||
): Promise<XpLeaderboardEntry | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return null;
|
||||
throw new MonkeyError(500, "Redis connnection is unavailable");
|
||||
}
|
||||
|
||||
const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } =
|
||||
|
|
@ -186,7 +198,7 @@ export class WeeklyXpLeaderboard {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
|
||||
// @ts-ignore
|
||||
const [[, rank], [, totalXp], [, count], [, result]] = (await connection
|
||||
const [[, rank], [, totalXp], [, _count], [, result]] = (await connection
|
||||
.multi()
|
||||
.zrevrank(weeklyXpLeaderboardScoresKey, uid)
|
||||
.zscore(weeklyXpLeaderboardScoresKey, uid)
|
||||
|
|
@ -209,12 +221,23 @@ export class WeeklyXpLeaderboard {
|
|||
>;
|
||||
|
||||
return {
|
||||
rank: rank + 1,
|
||||
count: count ?? 0,
|
||||
totalXp: parseInt(totalXp as string, 10),
|
||||
...parsed,
|
||||
rank: rank + 1,
|
||||
totalXp: parseInt(totalXp as string, 10),
|
||||
};
|
||||
}
|
||||
|
||||
public async getCount(): Promise<number> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection) {
|
||||
throw new Error("Redis connection is unavailable");
|
||||
}
|
||||
|
||||
const { weeklyXpLeaderboardScoresKey } =
|
||||
this.getThisWeeksXpLeaderboardKeys();
|
||||
|
||||
return connection.zcard(weeklyXpLeaderboardScoresKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function get(
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import {
|
|||
Configuration,
|
||||
ValidModeRule,
|
||||
} from "@monkeytype/contracts/schemas/configuration";
|
||||
import {
|
||||
DailyLeaderboardRank,
|
||||
LeaderboardEntry,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import MonkeyError from "./error";
|
||||
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
|
||||
import { getCurrentDayTimestamp } from "@monkeytype/util/date-and-time";
|
||||
|
|
@ -109,8 +106,8 @@ export class DailyLeaderboard {
|
|||
}
|
||||
|
||||
public async getResults(
|
||||
minRank: number,
|
||||
maxRank: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
premiumFeaturesEnabled: boolean
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
|
|
@ -119,19 +116,26 @@ export class DailyLeaderboard {
|
|||
return [];
|
||||
}
|
||||
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
const minRank = page * pageSize;
|
||||
const maxRank = minRank + pageSize - 1;
|
||||
|
||||
const { leaderboardScoresKey, leaderboardResultsKey } =
|
||||
this.getTodaysLeaderboardKeys();
|
||||
|
||||
// @ts-expect-error we are doing some weird file to function mapping, thats why its any
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
const [results] = (await connection.getResults(
|
||||
const [results, _] = (await connection.getResults(
|
||||
2,
|
||||
leaderboardScoresKey,
|
||||
leaderboardResultsKey,
|
||||
minRank,
|
||||
maxRank,
|
||||
"false"
|
||||
)) as string[][];
|
||||
)) as [string[], string[]];
|
||||
|
||||
if (results === undefined) {
|
||||
throw new Error(
|
||||
|
|
@ -158,10 +162,33 @@ export class DailyLeaderboard {
|
|||
return resultsWithRanks;
|
||||
}
|
||||
|
||||
public async getMinWpm(
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
|
||||
): Promise<number> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { leaderboardScoresKey } = this.getTodaysLeaderboardKeys();
|
||||
|
||||
const [_uid, minScore] = (await connection.zrange(
|
||||
leaderboardScoresKey,
|
||||
0,
|
||||
0,
|
||||
"WITHSCORES"
|
||||
)) as [string, string];
|
||||
|
||||
const minWpm =
|
||||
minScore !== undefined ? parseInt(minScore?.slice(1, 6)) / 100 : 0;
|
||||
|
||||
return minWpm;
|
||||
}
|
||||
|
||||
public async getRank(
|
||||
uid: string,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
|
||||
): Promise<DailyLeaderboardRank> {
|
||||
): Promise<LeaderboardEntry | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
throw new MonkeyError(500, "Redis connnection is unavailable");
|
||||
|
|
@ -175,36 +202,34 @@ export class DailyLeaderboard {
|
|||
.zrevrank(leaderboardScoresKey, uid)
|
||||
.zcard(leaderboardScoresKey)
|
||||
.hget(leaderboardResultsKey, uid)
|
||||
.zrange(leaderboardScoresKey, 0, 0, "WITHSCORES")
|
||||
.exec()) as [
|
||||
[null, number | null],
|
||||
[null, number | null],
|
||||
[null, string | null],
|
||||
[null, [string, string] | null]
|
||||
[null, string | null]
|
||||
];
|
||||
|
||||
const [[, rank], [, count], [, result], [, minScore]] = redisExecResult;
|
||||
const [[, rank], [, _count], [, result]] = redisExecResult;
|
||||
|
||||
const minWpm =
|
||||
minScore !== null && minScore.length > 0
|
||||
? parseInt(minScore[1]?.slice(1, 6)) / 100
|
||||
: 0;
|
||||
if (rank === null) {
|
||||
return {
|
||||
minWpm,
|
||||
count: count ?? 0,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
minWpm,
|
||||
count: count ?? 0,
|
||||
...(JSON.parse(result ?? "null") as LeaderboardEntry),
|
||||
rank: rank + 1,
|
||||
entry: {
|
||||
...(JSON.parse(result ?? "null") as LeaderboardEntry),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async getCount(): Promise<number> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection) {
|
||||
throw new MonkeyError(500, "Redis connnection is unavailable");
|
||||
}
|
||||
|
||||
const { leaderboardScoresKey } = this.getTodaysLeaderboardKeys();
|
||||
|
||||
return connection.zcard(leaderboardScoresKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgeUserFromDailyLeaderboards(
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@ async function handleDailyLeaderboardResults(
|
|||
|
||||
const dailyLeaderboard = new DailyLeaderboard(modeRule, yesterdayTimestamp);
|
||||
|
||||
const allResults = await dailyLeaderboard.getResults(
|
||||
const results = await dailyLeaderboard.getResults(
|
||||
0,
|
||||
-1,
|
||||
dailyLeaderboardsConfig,
|
||||
false
|
||||
);
|
||||
|
||||
if (allResults.length === 0) {
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ async function handleDailyLeaderboardResults(
|
|||
mail: MonkeyMail[];
|
||||
}[] = [];
|
||||
|
||||
allResults.forEach((entry) => {
|
||||
results.forEach((entry) => {
|
||||
const rank = entry.rank ?? maxResults;
|
||||
const wpm = Math.round(entry.wpm);
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ async function handleDailyLeaderboardResults(
|
|||
await addToInboxBulk(mailEntries, inboxConfig);
|
||||
}
|
||||
|
||||
const topResults = allResults.slice(
|
||||
const topResults = results.slice(
|
||||
0,
|
||||
dailyLeaderboardsConfig.topResultsToAnnounce
|
||||
);
|
||||
|
|
@ -126,7 +126,8 @@ async function handleWeeklyXpLeaderboardResults(
|
|||
const allResults = await weeklyXpLeaderboard.getResults(
|
||||
0,
|
||||
maxRankToGet,
|
||||
weeklyXpConfig
|
||||
weeklyXpConfig,
|
||||
false
|
||||
);
|
||||
|
||||
if (allResults.length === 0) {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
<i class="fas fa-fw fa-keyboard"></i>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
<!-- <button
|
||||
class="textButton leaderboards view-leaderboards"
|
||||
onclick="this.blur();"
|
||||
title="leaderboard"
|
||||
|
|
@ -64,7 +64,18 @@
|
|||
<div class="icon">
|
||||
<i class="fas fa-fw fa-crown"></i>
|
||||
</div>
|
||||
</button>
|
||||
</button> -->
|
||||
<a
|
||||
class="textButton view-leaderboards"
|
||||
href="/leaderboards"
|
||||
onclick="this.blur();"
|
||||
router-link
|
||||
title="leaderboards"
|
||||
>
|
||||
<div class="icon">
|
||||
<i class="fas fa-fw fa-crown"></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
class="textButton view-about"
|
||||
href="/about"
|
||||
|
|
|
|||
218
frontend/src/html/pages/leaderboards.html
Normal file
218
frontend/src/html/pages/leaderboards.html
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<div class="page pageLeaderboards hidden" id="pageLeaderboards">
|
||||
<div class="content">
|
||||
<div class="tableAndUser">
|
||||
<div class="bigtitle">
|
||||
<div class="text">-</div>
|
||||
<div class="subtext">
|
||||
<div class="text"></div>
|
||||
<div class="divider hidden"></div>
|
||||
<button class="textButton hidden" data-action="toggleYesterday">
|
||||
<i class="fas fa-backward"></i>
|
||||
show yesterday
|
||||
</button>
|
||||
<button class="textButton hidden" data-action="toggleLastWeek">
|
||||
<i class="fas fa-backward"></i>
|
||||
show last week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<!-- <div class="title needAuth">You</div> -->
|
||||
<div class="bigUser you needAuth"></div>
|
||||
<div class="titleAndButtons">
|
||||
<div class="title timer">Updates in: -</div>
|
||||
|
||||
<div class="jumpButtons">
|
||||
<div class="updating hidden">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<button data-action="firstPage"><i class="fas fa-crown"></i></button>
|
||||
<button data-action="userPage" class="needAuth">
|
||||
<i class="fas fa-user"></i>
|
||||
</button>
|
||||
<button data-action="previousPage">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button data-action="goToPage"><i class="fas fa-hashtag"></i></button>
|
||||
<button data-action="nextPage">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading hidden">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<div class="error hidden">
|
||||
<i class="fas fa-times"></i>
|
||||
<p>Something went wrong</p>
|
||||
</div>
|
||||
<table>
|
||||
<thead class="allTimeAndDaily">
|
||||
<tr>
|
||||
<td>#</td>
|
||||
<td>name</td>
|
||||
<td class="stat narrow">
|
||||
wpm
|
||||
<div class="sub">accuracy</div>
|
||||
</td>
|
||||
<td class="stat narrow">
|
||||
raw
|
||||
<div class="sub">consistency</div>
|
||||
</td>
|
||||
<td class="stat wide">wpm</td>
|
||||
<td class="stat wide">accuracy</td>
|
||||
<td class="stat wide">raw</td>
|
||||
<td class="stat wide">consistency</td>
|
||||
<td class="date">date</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead class="weekly">
|
||||
<tr>
|
||||
<td>#</td>
|
||||
<td>name</td>
|
||||
<td class="stat wide">xp gained</td>
|
||||
<td class="stat wide">time typed</td>
|
||||
<td class="stat narrow">
|
||||
xp gained
|
||||
<div class="sub">time typed</div>
|
||||
</td>
|
||||
<td class="date">last activity</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>
|
||||
<div class="avatarNameBadge">
|
||||
<div class="lbav">
|
||||
<div class="avatarPlaceholder">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="name">Username</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>100.00</td>
|
||||
<td>100.00%</td>
|
||||
<td>100.00</td>
|
||||
<td>100.00%</td>
|
||||
<td class="small">23 Aug 2024 12:10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>
|
||||
<div class="avatarNameBadge">
|
||||
<div class="lbav">
|
||||
<div class="avatarPlaceholder">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="name">Username</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>100.00</td>
|
||||
<td>100.00%</td>
|
||||
<td>100.00</td>
|
||||
<td>100.00%</td>
|
||||
<td class="small">23 Aug 2024 12:10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>
|
||||
<div class="avatarNameBadge">
|
||||
<div class="lbav">
|
||||
<div class="avatarPlaceholder">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="name">Username</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>100.00</td>
|
||||
<td>100.00%</td>
|
||||
<td>100.00</td>
|
||||
<td>100.00%</td>
|
||||
<td class="small">23 Aug 2024 12:10</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="titleAndButtons" style="margin-top: 1em">
|
||||
<div></div>
|
||||
<div class="jumpButtons">
|
||||
<div class="updating hidden">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<button data-action="firstPage"><i class="fas fa-crown"></i></button>
|
||||
<button data-action="userPage" class="needAuth">
|
||||
<i class="fas fa-user"></i>
|
||||
</button>
|
||||
<button data-action="previousPage">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button data-action="goToPage"><i class="fas fa-hashtag"></i></button>
|
||||
<button data-action="nextPage">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<div class="buttonGroup typeButtons">
|
||||
<button data-type="allTime">
|
||||
<i class="fas fa-globe-americas"></i>
|
||||
all-time english
|
||||
</button>
|
||||
<button data-type="weekly">
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
weekly xp
|
||||
</button>
|
||||
<button data-type="daily">
|
||||
<i class="fas fa-sun"></i>
|
||||
daily
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider hidden"></div>
|
||||
<div class="buttonGroup hidden secondary modeButtons">
|
||||
<button data-mode="15">
|
||||
<i class="fas fa-clock"></i>
|
||||
time 15
|
||||
</button>
|
||||
<button data-mode="60">
|
||||
<i class="fas fa-clock"></i>
|
||||
time 60
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider divider2 hidden"></div>
|
||||
<div class="buttonGroup hidden secondary languageButtons">
|
||||
<button data-language="english">
|
||||
<i class="fas fa-globe"></i>
|
||||
english
|
||||
</button>
|
||||
<button data-language="spanish">
|
||||
<i class="fas fa-globe"></i>
|
||||
spanish
|
||||
</button>
|
||||
<button data-language="german">
|
||||
<i class="fas fa-globe"></i>
|
||||
german
|
||||
</button>
|
||||
<button data-language="french">
|
||||
<i class="fas fa-globe"></i>
|
||||
french
|
||||
</button>
|
||||
<button data-language="portuguese">
|
||||
<i class="fas fa-globe"></i>
|
||||
portuguese
|
||||
</button>
|
||||
<button data-language="indonesian">
|
||||
<i class="fas fa-globe"></i>
|
||||
indonesian
|
||||
</button>
|
||||
<button data-language="italian">
|
||||
<i class="fas fa-globe"></i>
|
||||
italian
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
<load src="html/pages/test.html" />
|
||||
<load src="html/pages/404.html" />
|
||||
<load src="html/pages/account-settings.html" />
|
||||
<load src="html/pages/leaderboards.html" />
|
||||
</main>
|
||||
<load src="html/footer.html" />
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import "buttons", "fonts", "404", "ads", "about", "account", "animations",
|
||||
"banners", "caret", "commandline", "core", "footer", "inputs", "keymap",
|
||||
"leaderboards", "login", "monkey", "nav", "notifications", "popups", "profile",
|
||||
"scroll", "settings", "account-settings", "test", "media-queries";
|
||||
"login", "monkey", "nav", "notifications", "popups", "profile", "scroll",
|
||||
"settings", "account-settings", "leaderboards", "test", "media-queries";
|
||||
|
|
|
|||
|
|
@ -1,152 +1,212 @@
|
|||
#leaderboardsWrapper {
|
||||
#leaderboards {
|
||||
// max-width: 300px;
|
||||
max-width: 1536px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// height: calc(95vh - 5rem);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--roundness);
|
||||
padding: 1rem;
|
||||
.pageLeaderboards {
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 1rem 0;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"title buttons"
|
||||
"tables tables";
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
grid-template-areas: "buttons table";
|
||||
grid-template-columns: 20rem 1fr;
|
||||
|
||||
.leaderboardsTop {
|
||||
width: 200%;
|
||||
min-width: 100%;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 1fr 20rem;
|
||||
grid-template-areas:
|
||||
"title title buttons"
|
||||
"subtitle yesterday buttons";
|
||||
// grid-template-areas: "table buttons";
|
||||
// grid-template-columns: 1fr 15rem;
|
||||
height: 100%;
|
||||
|
||||
.buttons {
|
||||
grid-area: buttons;
|
||||
.timeRange {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
.languageSelect {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.bigtitle {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
.text {
|
||||
grid-area: text;
|
||||
}
|
||||
.subtext {
|
||||
grid-area: subtext;
|
||||
color: var(--sub-color);
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
.divider {
|
||||
height: 1.75em;
|
||||
width: 0.25em;
|
||||
background: var(--sub-alt-color);
|
||||
border-radius: calc(var(--roundness) / 2);
|
||||
}
|
||||
button {
|
||||
// font-size: 0.75em;
|
||||
margin-left: -0.5em;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainTitle {
|
||||
font-size: 2.5rem;
|
||||
grid-area: title;
|
||||
}
|
||||
.tableAndUser {
|
||||
grid-area: table;
|
||||
font-size: 1rem;
|
||||
|
||||
.showYesterdayButton {
|
||||
grid-area: "yesterday";
|
||||
margin-left: 1rem;
|
||||
.fas {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
color: var(--sub-color);
|
||||
grid-area: subtitle;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tables {
|
||||
grid-area: tables;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
|
||||
.sub {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.alignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.titleAndTable {
|
||||
display: grid;
|
||||
& > .divider {
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
background: var(--sub-alt-color);
|
||||
border-radius: calc(var(--roundness) / 2);
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.titleAndButtons {
|
||||
.titleAndButtons {
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
justify-content: space-between;
|
||||
button {
|
||||
width: 3rem;
|
||||
}
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
color: var(--sub-color);
|
||||
}
|
||||
.jumpButtons {
|
||||
justify-self: end;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
// margin-top: .1rem;
|
||||
gap: 0.5rem;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
.updating {
|
||||
font-size: 1.5em;
|
||||
color: var(--sub-color);
|
||||
.button {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .title {
|
||||
font-size: 1.25rem;
|
||||
color: var(--sub-color);
|
||||
}
|
||||
|
||||
.narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bigUser {
|
||||
// color: var(--main-color);
|
||||
font-size: 1em;
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 2rem;
|
||||
background: var(--sub-alt-color);
|
||||
padding: 1em 2em;
|
||||
border-radius: var(--roundness);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2em;
|
||||
// .rank {
|
||||
// width: 7ch;
|
||||
// text-align: left;
|
||||
// }
|
||||
|
||||
.warning {
|
||||
padding: 0.5em 0;
|
||||
text-align: center;
|
||||
grid-column: 1 / -1;
|
||||
color: var(--sub-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: right;
|
||||
.title {
|
||||
font-size: 0.75em;
|
||||
color: var(--sub-color);
|
||||
}
|
||||
.sub {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.date {
|
||||
font-size: 0.75em;
|
||||
// color: var(--sub-color);
|
||||
.sub {
|
||||
font-size: 0.75em;
|
||||
color: var(--sub-color);
|
||||
}
|
||||
}
|
||||
&.you {
|
||||
.userInfo {
|
||||
.bottom {
|
||||
font-size: 0.75em;
|
||||
color: var(--sub-color);
|
||||
}
|
||||
}
|
||||
// font-size: 1rem;
|
||||
// .stat .title {
|
||||
// font-size: 0.8em;
|
||||
// }
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: 1/1;
|
||||
margin-bottom: 0;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
grid-area: 1/1;
|
||||
align-self: center;
|
||||
justify-self: right;
|
||||
}
|
||||
.avatarNameBadge {
|
||||
display: grid;
|
||||
grid-template-columns: min-content max-content auto;
|
||||
gap: 0.5rem;
|
||||
place-items: center left;
|
||||
.avatarPlaceholder {
|
||||
color: var(--sub-color);
|
||||
}
|
||||
}
|
||||
|
||||
.leftTableWrapper,
|
||||
.rightTableWrapper {
|
||||
height: calc(100vh - 14rem);
|
||||
@extend .ffscroll;
|
||||
overflow-y: scroll;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.leftTableWrapper::-webkit-scrollbar,
|
||||
.rightTableWrapper::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
|
||||
--padding: 1em 1.5rem;
|
||||
|
||||
.sub {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
// padding: 0;
|
||||
width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: var(--sub-color);
|
||||
font-size: 0.75em;
|
||||
|
||||
td {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
&.stat {
|
||||
width: 15ch;
|
||||
text-align: right;
|
||||
}
|
||||
&.date {
|
||||
width: 20ch;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatarNameBadge {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem max-content auto;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: 1.25em max-content auto;
|
||||
gap: 0.5em;
|
||||
place-items: center left;
|
||||
.avatarPlaceholder {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 1.25rem;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
font-size: 1.25em;
|
||||
// background: var(--sub-color);
|
||||
color: var(--sub-color);
|
||||
// display: grid;
|
||||
// place-content: center center;
|
||||
border-radius: 100%;
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
.entryName {
|
||||
text-decoration: none;
|
||||
|
|
@ -159,48 +219,35 @@
|
|||
grid-column: 1/2;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.6em;
|
||||
}
|
||||
.flagsAndBadge {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.5em;
|
||||
color: var(--sub-color);
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
tr td:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr.me {
|
||||
td {
|
||||
color: var(--main-color);
|
||||
// font-weight: 900;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: var(--sub-color);
|
||||
font-size: 0.75rem;
|
||||
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-color);
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
color: var(--text-color);
|
||||
|
||||
tr.me {
|
||||
color: var(--main-color);
|
||||
a {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
td.stat {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td.date {
|
||||
text-align: right;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
tr:nth-child(odd) td {
|
||||
background: var(--sub-alt-color);
|
||||
}
|
||||
|
|
@ -213,37 +260,49 @@
|
|||
calc(var(--roundness) / 2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
tfoot {
|
||||
td {
|
||||
padding: 1rem 0.5rem;
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
bottom: -5px;
|
||||
background: var(--bg-color);
|
||||
color: var(--main-color);
|
||||
z-index: 4;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
td:first-child {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
td:last-child {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 3em;
|
||||
color: var(--sub-color);
|
||||
padding: 1em;
|
||||
}
|
||||
.error {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 2rem;
|
||||
place-content: center;
|
||||
align-items: center;
|
||||
font-size: 2em;
|
||||
color: var(--sub-color);
|
||||
padding: 1em;
|
||||
}
|
||||
.buttons {
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
grid-area: buttons;
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
align-content: start;
|
||||
background: var(--sub-alt-color);
|
||||
height: max-content;
|
||||
border-radius: var(--roundness);
|
||||
padding: 1rem;
|
||||
button {
|
||||
justify-content: start;
|
||||
padding-left: 0.75em;
|
||||
}
|
||||
.divider {
|
||||
background: var(--bg-color);
|
||||
width: 100%;
|
||||
height: 0.25em;
|
||||
border-radius: var(--roundness);
|
||||
}
|
||||
.buttonGroup {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
gap: 0.5rem;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-area: buttons;
|
||||
gap: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,4 +290,22 @@
|
|||
// border-radius: 0.2em;
|
||||
// }
|
||||
}
|
||||
.pageLeaderboards {
|
||||
.content {
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "buttons" "table";
|
||||
.buttons {
|
||||
// grid-template-columns: 1fr 1fr;
|
||||
font-size: 0.9em;
|
||||
.buttonGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
button {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,4 +19,9 @@
|
|||
// width: 1.6em;
|
||||
// }
|
||||
}
|
||||
.pageLeaderboards {
|
||||
.content {
|
||||
grid-template-columns: 15rem 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,4 +70,29 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
.pageLeaderboards {
|
||||
.content .tableAndUser {
|
||||
font-size: 0.9rem;
|
||||
.bigUser {
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
}
|
||||
.narrow {
|
||||
display: table-cell;
|
||||
}
|
||||
.wide {
|
||||
display: none;
|
||||
}
|
||||
table tbody {
|
||||
td.date {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
table td {
|
||||
padding: 1em;
|
||||
}
|
||||
.bigUser {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import * as PageLoading from "../pages/loading";
|
|||
import * as PageProfile from "../pages/profile";
|
||||
import * as PageProfileSearch from "../pages/profile-search";
|
||||
import * as Page404 from "../pages/404";
|
||||
import * as PageLeaderboards from "../pages/leaderboards";
|
||||
import * as PageAccountSettings from "../pages/account-settings";
|
||||
import * as PageTransition from "../states/page-transition";
|
||||
import * as AdController from "../controllers/ad-controller";
|
||||
|
|
@ -60,6 +61,7 @@ export async function change(
|
|||
profileSearch: PageProfileSearch.page,
|
||||
404: Page404.page,
|
||||
accountSettings: PageAccountSettings.page,
|
||||
leaderboards: PageLeaderboards.page,
|
||||
};
|
||||
|
||||
const previousPage = pages[ActivePage.get()];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as PageController from "./page-controller";
|
||||
import * as Leaderboards from "../elements/leaderboards";
|
||||
import * as TestUI from "../test/test-ui";
|
||||
import * as PageTransition from "../states/page-transition";
|
||||
import { Auth, isAuthenticated } from "../firebase";
|
||||
|
|
@ -60,15 +59,12 @@ const routes: Route[] = [
|
|||
void PageController.change("test");
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: "/leaderboards",
|
||||
// load: (): void => {
|
||||
// if (ActivePage.get() === "loading") {
|
||||
// PageController.change(PageTest.page);
|
||||
// }
|
||||
// Leaderboards.show();
|
||||
// },
|
||||
// },
|
||||
{
|
||||
path: "/leaderboards",
|
||||
load: (): void => {
|
||||
void PageController.change("leaderboards");
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
load: (): void => {
|
||||
|
|
@ -201,7 +197,3 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#popups").on("click", "#leaderboards a.entryName", () => {
|
||||
Leaderboards.hide();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,988 +0,0 @@
|
|||
import Ape from "../ape";
|
||||
import * as DB from "../db";
|
||||
import Config from "../config";
|
||||
import * as DateTime from "../utils/date-and-time";
|
||||
import * as Misc from "../utils/misc";
|
||||
import * as Arrays from "../utils/arrays";
|
||||
import * as Numbers from "@monkeytype/util/numbers";
|
||||
import * as Notifications from "./notifications";
|
||||
import { format } from "date-fns/format";
|
||||
import { isAuthenticated } from "../firebase";
|
||||
import { differenceInSeconds } from "date-fns/differenceInSeconds";
|
||||
import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import * as Skeleton from "../utils/skeleton";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import Format from "../utils/format";
|
||||
import SlimSelect from "slim-select";
|
||||
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
LeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { Mode } from "@monkeytype/contracts/schemas/shared";
|
||||
import * as TestStats from "../test/test-stats";
|
||||
|
||||
const wrapperId = "leaderboardsWrapper";
|
||||
|
||||
let currentTimeRange: "allTime" | "daily" = "allTime";
|
||||
let currentLanguage = "english";
|
||||
let showingYesterday = false;
|
||||
|
||||
type LbKey = "15" | "60";
|
||||
|
||||
let currentData: {
|
||||
[_key in LbKey]: LeaderboardEntry[];
|
||||
} = {
|
||||
"15": [],
|
||||
"60": [],
|
||||
};
|
||||
|
||||
let currentRank: {
|
||||
[_key in LbKey]:
|
||||
| (LeaderboardRank & { minWpm?: number }) //Daily LB rank has minWpm
|
||||
| Record<string, never>;
|
||||
} = {
|
||||
"15": {},
|
||||
"60": {},
|
||||
};
|
||||
|
||||
let currentAvatars: {
|
||||
[_key in LbKey]: (string | null)[];
|
||||
} = {
|
||||
"15": [],
|
||||
"60": [],
|
||||
};
|
||||
|
||||
const requesting = {
|
||||
"15": false,
|
||||
"60": false,
|
||||
};
|
||||
|
||||
const leaderboardSingleLimit = 50;
|
||||
|
||||
let updateTimer: number | undefined;
|
||||
|
||||
function clearBody(lb: LbKey): void {
|
||||
if (lb === "15") {
|
||||
$("#leaderboardsWrapper table.left tbody").empty();
|
||||
} else if (lb === "60") {
|
||||
$("#leaderboardsWrapper table.right tbody").empty();
|
||||
}
|
||||
}
|
||||
|
||||
function clearFoot(lb: LbKey): void {
|
||||
if (lb === "15") {
|
||||
$("#leaderboardsWrapper table.left tfoot").empty();
|
||||
} else if (lb === "60") {
|
||||
$("#leaderboardsWrapper table.right tfoot").empty();
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
currentData = {
|
||||
"15": [],
|
||||
"60": [],
|
||||
};
|
||||
|
||||
currentRank = {
|
||||
"15": {},
|
||||
"60": {},
|
||||
};
|
||||
|
||||
currentAvatars = {
|
||||
"15": [],
|
||||
"60": [],
|
||||
};
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = undefined;
|
||||
$("#leaderboards .subTitle").text("-");
|
||||
}
|
||||
|
||||
function updateTimerElement(): void {
|
||||
if (currentTimeRange === "daily") {
|
||||
const date = new Date();
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + 1);
|
||||
const dateNow = new Date();
|
||||
dateNow.setUTCMilliseconds(0);
|
||||
const diff = differenceInSeconds(date, dateNow);
|
||||
|
||||
$("#leaderboards .subTitle").text(
|
||||
"Next reset in: " + DateTime.secondsToString(diff, true)
|
||||
);
|
||||
} else {
|
||||
const date = new Date();
|
||||
const minutesToNextUpdate = 14 - (date.getMinutes() % 15);
|
||||
const secondsToNextUpdate = 60 - date.getSeconds();
|
||||
const totalSeconds = minutesToNextUpdate * 60 + secondsToNextUpdate;
|
||||
$("#leaderboards .subTitle").text(
|
||||
"Next update in: " + DateTime.secondsToString(totalSeconds, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
updateTimerElement();
|
||||
updateTimer = setInterval(() => {
|
||||
updateTimerElement();
|
||||
}, 1000) as unknown as number;
|
||||
}
|
||||
|
||||
function showLoader(lb: LbKey): void {
|
||||
if (lb === "15") {
|
||||
$(`#leaderboardsWrapper .leftTableLoader`).removeClass("hidden");
|
||||
} else if (lb === "60") {
|
||||
$(`#leaderboardsWrapper .rightTableLoader`).removeClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoader(lb: LbKey): void {
|
||||
if (lb === "15") {
|
||||
$(`#leaderboardsWrapper .leftTableLoader`).addClass("hidden");
|
||||
} else if (lb === "60") {
|
||||
$(`#leaderboardsWrapper .rightTableLoader`).addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function updateFooter(lb: LbKey): void {
|
||||
let side;
|
||||
if (lb === "15") {
|
||||
side = "left";
|
||||
} else {
|
||||
side = "right";
|
||||
}
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;"></>
|
||||
</tr>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!Misc.isDevEnvironment() &&
|
||||
(DB.getSnapshot()?.typingStats?.timeTyping ?? 0) < 7200
|
||||
) {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;">Your account must have 2 hours typed to be placed on the leaderboard.</>
|
||||
</tr>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (DB.getSnapshot()?.lbOptOut === true) {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;">You have opted out of the leaderboards</>
|
||||
</tr>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lbRank = currentRank[lb];
|
||||
|
||||
if (
|
||||
currentTimeRange === "daily" &&
|
||||
lbRank !== null &&
|
||||
lbRank.minWpm === undefined
|
||||
) {
|
||||
//old response format
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;">Looks like the server returned data in a new format, please refresh</>
|
||||
</tr>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
let toppercent = "";
|
||||
if (currentTimeRange === "allTime" && lbRank !== undefined && lbRank?.rank) {
|
||||
const num = Numbers.roundTo2((lbRank.rank / currentRank[lb].count) * 100);
|
||||
if (currentRank[lb].rank === 1) {
|
||||
toppercent = "GOAT";
|
||||
} else {
|
||||
toppercent = `Top ${num}%`;
|
||||
}
|
||||
toppercent = `<br><span class="sub">${toppercent}</span>`;
|
||||
}
|
||||
|
||||
const entry = lbRank?.entry;
|
||||
if (entry) {
|
||||
const date = new Date(entry.timestamp);
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td>${lbRank.rank}</td>
|
||||
<td><span class="top">You</span>${toppercent ? toppercent : ""}</td>
|
||||
<td class="alignRight">${Format.typingSpeed(entry.wpm, {
|
||||
showDecimalPlaces: true,
|
||||
})}<br>
|
||||
<div class="sub">${Format.percentage(entry.acc, {
|
||||
showDecimalPlaces: true,
|
||||
})}</div></td>
|
||||
<td class="alignRight">${Format.typingSpeed(entry.raw, {
|
||||
showDecimalPlaces: true,
|
||||
})}<br>
|
||||
<div class="sub">${Format.percentage(entry.consistency, {
|
||||
showDecimalPlaces: true,
|
||||
})}</div></td>
|
||||
<td class="alignRight">${format(date, "dd MMM yyyy")}<br>
|
||||
<div class='sub'>${format(date, "HH:mm")}</div></td>
|
||||
</tr>
|
||||
`);
|
||||
} else if (currentTimeRange === "daily") {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;">Not qualified ${`(min speed required: ${currentRank[lb]?.minWpm} wpm)`}</>
|
||||
</tr>
|
||||
`);
|
||||
} else {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;">Not qualified</>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkLbMemory(lb: LbKey): void {
|
||||
if (currentTimeRange === "daily") return;
|
||||
|
||||
let side;
|
||||
if (lb === "15") {
|
||||
side = "left";
|
||||
} else {
|
||||
side = "right";
|
||||
}
|
||||
|
||||
const memory = DB.getSnapshot()?.lbMemory?.["time"]?.[lb]?.["english"] ?? 0;
|
||||
|
||||
const rank = currentRank[lb]?.rank;
|
||||
if (rank) {
|
||||
const difference = memory - rank;
|
||||
if (difference > 0) {
|
||||
void DB.updateLbMemory("time", lb, "english", rank, true);
|
||||
if (memory !== 0) {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot tr td .top`).append(
|
||||
` (<i class="fas fa-fw fa-angle-up"></i>${Math.abs(
|
||||
difference
|
||||
)} since you last checked)`
|
||||
);
|
||||
}
|
||||
} else if (difference < 0) {
|
||||
void DB.updateLbMemory("time", lb, "english", rank, true);
|
||||
if (memory !== 0) {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot tr td .top`).append(
|
||||
` (<i class="fas fa-fw fa-angle-down"></i>${Math.abs(
|
||||
difference
|
||||
)} since you last checked)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (memory !== 0) {
|
||||
$(`#leaderboardsWrapper table.${side} tfoot tr td .top`).append(
|
||||
` ( = since you last checked)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fillTable(lb: LbKey): Promise<void> {
|
||||
if (currentData[lb] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let side: string;
|
||||
if (lb === "15") {
|
||||
side = "left";
|
||||
} else {
|
||||
side = "right";
|
||||
}
|
||||
|
||||
if (currentData[lb].length === 0) {
|
||||
$(`#leaderboardsWrapper table.${side} tbody`).html(
|
||||
"<tr><td colspan='7'>No results found</td></tr>"
|
||||
);
|
||||
}
|
||||
const loggedInUserName = DB.getSnapshot()?.name;
|
||||
|
||||
let html = "";
|
||||
for (let i = 0; i < currentData[lb].length; i++) {
|
||||
const entry = currentData[lb][i];
|
||||
if (entry === undefined) {
|
||||
break;
|
||||
}
|
||||
let meClassString = "";
|
||||
if (entry.name === loggedInUserName) {
|
||||
meClassString = ' class="me"';
|
||||
}
|
||||
const date = new Date(entry.timestamp);
|
||||
|
||||
if (currentTimeRange === "daily" && !entry.rank) {
|
||||
entry.rank = i + 1;
|
||||
}
|
||||
|
||||
let avatar = `<div class="avatarPlaceholder"><i class="fas fa-user-circle"></i></div>`;
|
||||
|
||||
if (entry.discordAvatar !== undefined) {
|
||||
avatar = `<div class="avatarPlaceholder"><i class="fas fa-circle-notch fa-spin"></i></div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr ${meClassString}>
|
||||
<td>${
|
||||
entry.rank === 1 ? '<i class="fas fa-fw fa-crown"></i>' : entry.rank
|
||||
}</td>
|
||||
<td>
|
||||
<div class="avatarNameBadge">
|
||||
<div class="lbav">${avatar}</div>
|
||||
<a href="${location.origin}/profile/${
|
||||
entry.uid
|
||||
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
|
||||
<div class="flagsAndBadge">
|
||||
${getHtmlByUserFlags(entry)}
|
||||
${entry.badgeId ? getBadgeHTMLbyId(entry.badgeId) : ""}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="alignRight">${Format.typingSpeed(entry.wpm, {
|
||||
showDecimalPlaces: true,
|
||||
})}<br>
|
||||
<div class="sub">${Format.percentage(entry.acc, {
|
||||
showDecimalPlaces: true,
|
||||
})}</div></td>
|
||||
<td class="alignRight">${Format.typingSpeed(entry.raw, {
|
||||
showDecimalPlaces: true,
|
||||
})}<br>
|
||||
<div class="sub">${Format.percentage(entry.consistency, {
|
||||
showDecimalPlaces: true,
|
||||
})}</div></td>
|
||||
<td class="alignRight">${format(date, "dd MMM yyyy")}<br>
|
||||
<div class='sub'>${format(date, "HH:mm")}</div></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
$(`#leaderboardsWrapper table.${side} tbody`).html(html);
|
||||
}
|
||||
|
||||
const showYesterdayButton = $("#leaderboardsWrapper .showYesterdayButton");
|
||||
const showYesterdayButtonText = $(
|
||||
"#leaderboardsWrapper .showYesterdayButton .text"
|
||||
);
|
||||
|
||||
export function hide(): void {
|
||||
$("#leaderboardsWrapper")
|
||||
.stop(true, true)
|
||||
.css("opacity", 1)
|
||||
.animate(
|
||||
{
|
||||
opacity: 0,
|
||||
},
|
||||
Misc.applyReducedMotion(100),
|
||||
() => {
|
||||
languageSelector?.destroy();
|
||||
languageSelector = undefined;
|
||||
clearBody("15");
|
||||
clearBody("60");
|
||||
clearFoot("15");
|
||||
clearFoot("60");
|
||||
reset();
|
||||
stopTimer();
|
||||
showingYesterday = false;
|
||||
updateYesterdayButton();
|
||||
$("#leaderboardsWrapper").addClass("hidden");
|
||||
Skeleton.remove(wrapperId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updateTitle(): void {
|
||||
const el = $("#leaderboardsWrapper .mainTitle");
|
||||
|
||||
const timeRangeString = currentTimeRange === "daily" ? "Daily" : "All-Time";
|
||||
const capitalizedLanguage =
|
||||
currentLanguage.charAt(0).toUpperCase() + currentLanguage.slice(1);
|
||||
|
||||
let text = `${timeRangeString} ${capitalizedLanguage} Leaderboards`;
|
||||
|
||||
if (showingYesterday && currentTimeRange !== "allTime") {
|
||||
text += " (Yesterday)";
|
||||
}
|
||||
|
||||
el.text(text);
|
||||
}
|
||||
|
||||
function updateYesterdayButton(): void {
|
||||
showYesterdayButton.addClass("hidden");
|
||||
if (currentTimeRange === "daily") {
|
||||
showYesterdayButton.removeClass("hidden");
|
||||
}
|
||||
if (showingYesterday) {
|
||||
showYesterdayButtonText.text("Show today");
|
||||
} else {
|
||||
showYesterdayButtonText.text("Show yesterday");
|
||||
}
|
||||
}
|
||||
|
||||
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore?: 1 } {
|
||||
const isDaily = currentTimeRange === "daily";
|
||||
const isViewingDailyAndButtonIsActive = isDaily && showingYesterday;
|
||||
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : undefined;
|
||||
|
||||
return {
|
||||
isDaily,
|
||||
daysBefore,
|
||||
};
|
||||
}
|
||||
|
||||
async function update(): Promise<void> {
|
||||
leftScrollEnabled = false;
|
||||
rightScrollEnabled = false;
|
||||
|
||||
showLoader("15");
|
||||
showLoader("60");
|
||||
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
const requestRank = isDaily
|
||||
? Ape.leaderboards.getDailyRank
|
||||
: Ape.leaderboards.getRank;
|
||||
|
||||
const baseQuery = {
|
||||
language: currentLanguage,
|
||||
mode: "time" as Mode,
|
||||
daysBefore,
|
||||
};
|
||||
|
||||
const fallbackResponse = { status: 200, body: { message: "", data: null } };
|
||||
|
||||
const lbRank15Request = isAuthenticated()
|
||||
? requestRank({ query: { ...baseQuery, mode2: "15" } })
|
||||
: fallbackResponse;
|
||||
|
||||
const lbRank60Request = isAuthenticated()
|
||||
? requestRank({ query: { ...baseQuery, mode2: "60" } })
|
||||
: fallbackResponse;
|
||||
const [lb15Data, lb60Data, lb15Rank, lb60Rank] = await Promise.all([
|
||||
requestData({ query: { ...baseQuery, mode2: "15" } }),
|
||||
requestData({ query: { ...baseQuery, mode2: "60" } }),
|
||||
lbRank15Request,
|
||||
lbRank60Request,
|
||||
]);
|
||||
|
||||
if (
|
||||
lb15Data.status !== 200 ||
|
||||
lb60Data.status !== 200 ||
|
||||
lb15Rank.status !== 200 ||
|
||||
lb60Rank.status !== 200
|
||||
) {
|
||||
const failedResponses = [lb15Data, lb60Data, lb15Rank, lb60Rank].filter(
|
||||
(it) => it.status !== 200
|
||||
);
|
||||
|
||||
hideLoader("15");
|
||||
hideLoader("60");
|
||||
Notifications.add(
|
||||
"Failed to load leaderboards: " + failedResponses[0]?.body.message,
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lb15Data.body.data !== null) currentData["15"] = lb15Data.body.data;
|
||||
if (lb60Data.body.data !== null) currentData["60"] = lb60Data.body.data;
|
||||
if (lb15Rank.body.data !== null) currentRank["15"] = lb15Rank.body.data;
|
||||
if (lb60Rank.body.data !== null) currentRank["60"] = lb60Rank.body.data;
|
||||
|
||||
const leaderboardKeys: LbKey[] = ["15", "60"];
|
||||
|
||||
leaderboardKeys.forEach(async (lbKey) => {
|
||||
hideLoader(lbKey);
|
||||
clearBody(lbKey);
|
||||
updateFooter(lbKey);
|
||||
checkLbMemory(lbKey);
|
||||
await fillTable(lbKey);
|
||||
|
||||
void getAvatarUrls(currentData[lbKey]).then((urls) => {
|
||||
currentAvatars[lbKey] = urls;
|
||||
fillAvatars(lbKey);
|
||||
});
|
||||
});
|
||||
|
||||
$("#leaderboardsWrapper .leftTableWrapper").removeClass("invisible");
|
||||
$("#leaderboardsWrapper .rightTableWrapper").removeClass("invisible");
|
||||
|
||||
updateTitle();
|
||||
updateYesterdayButton();
|
||||
$("#leaderboardsWrapper .buttons .button").removeClass("active");
|
||||
$(
|
||||
`#leaderboardsWrapper .buttonGroup.timeRange .button.` + currentTimeRange
|
||||
).addClass("active");
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableWrapper").scrollTop(0);
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableWrapper").scrollTop(0);
|
||||
|
||||
leftScrollEnabled = true;
|
||||
rightScrollEnabled = true;
|
||||
}
|
||||
|
||||
async function requestMore(lb: LbKey, prepend = false): Promise<void> {
|
||||
if (prepend && currentData[lb][0]?.rank === 1) return;
|
||||
if (requesting[lb]) return;
|
||||
requesting[lb] = true;
|
||||
showLoader(lb);
|
||||
let skipVal = Arrays.lastElementFromArray(currentData[lb])?.rank as number;
|
||||
if (prepend) {
|
||||
skipVal = (currentData[lb][0]?.rank ?? 0) - leaderboardSingleLimit;
|
||||
}
|
||||
let limitVal;
|
||||
if (skipVal < 0) {
|
||||
limitVal = Math.abs(skipVal) - 1;
|
||||
skipVal = 0;
|
||||
}
|
||||
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
|
||||
const response = await requestData({
|
||||
query: {
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip: skipVal,
|
||||
limit: limitVal,
|
||||
daysBefore,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
response.body.data === null ||
|
||||
response.body.data.length === 0
|
||||
) {
|
||||
hideLoader(lb);
|
||||
requesting[lb] = false;
|
||||
return;
|
||||
}
|
||||
const data = response.body.data;
|
||||
|
||||
if (prepend) {
|
||||
currentData[lb].unshift(...data);
|
||||
} else {
|
||||
currentData[lb].push(...data);
|
||||
}
|
||||
if (prepend && !limitVal) {
|
||||
limitVal = leaderboardSingleLimit - 1;
|
||||
}
|
||||
await fillTable(lb);
|
||||
|
||||
void getAvatarUrls(data).then((urls) => {
|
||||
if (prepend) {
|
||||
currentAvatars[lb].unshift(...urls);
|
||||
} else {
|
||||
currentAvatars[lb].push(...urls);
|
||||
}
|
||||
fillAvatars(lb);
|
||||
});
|
||||
|
||||
hideLoader(lb);
|
||||
requesting[lb] = false;
|
||||
}
|
||||
|
||||
async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
||||
showLoader(lb);
|
||||
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
|
||||
const response = await requestData({
|
||||
query: {
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip,
|
||||
daysBefore,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 503) {
|
||||
Notifications.add(
|
||||
"Leaderboards are currently updating - please try again later",
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
clearBody(lb);
|
||||
currentData[lb] = [];
|
||||
currentAvatars[lb] = [];
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
response.body.data === null ||
|
||||
response.body.data.length === 0
|
||||
) {
|
||||
hideLoader(lb);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.body.data;
|
||||
currentData[lb] = data;
|
||||
await fillTable(lb);
|
||||
|
||||
void getAvatarUrls(data).then((urls) => {
|
||||
currentAvatars[lb] = urls;
|
||||
fillAvatars(lb);
|
||||
});
|
||||
|
||||
hideLoader(lb);
|
||||
}
|
||||
|
||||
async function getAvatarUrls(
|
||||
data: LeaderboardEntry[]
|
||||
): Promise<(string | null)[]> {
|
||||
return Promise.allSettled(
|
||||
data.map(async (entry) =>
|
||||
Misc.getDiscordAvatarUrl(entry.discordId, entry.discordAvatar)
|
||||
)
|
||||
).then((promises) => {
|
||||
return promises.map((promise) => {
|
||||
if (promise.status === "fulfilled") {
|
||||
return promise.value;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillAvatars(lb: LbKey): void {
|
||||
const side = lb === "15" ? "left" : "right";
|
||||
const elements = $(`#leaderboardsWrapper table.${side} tbody .lbav`);
|
||||
|
||||
for (const [index, url] of currentAvatars[lb].entries()) {
|
||||
const element = elements[index] as HTMLElement;
|
||||
if (url !== null) {
|
||||
$(element).html(
|
||||
`<div class="avatar" style="background-image:url(${url})"></div>`
|
||||
);
|
||||
} else {
|
||||
$(element).html(
|
||||
`<div class="avatarPlaceholder"><i class="fas fa-user-circle"></i></div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function show(): void {
|
||||
if (!ConnectionState.get()) {
|
||||
Notifications.add("You can't view leaderboards while offline", 0);
|
||||
return;
|
||||
}
|
||||
Skeleton.append(wrapperId, "popups");
|
||||
if (!Misc.isPopupVisible("leaderboardsWrapper")) {
|
||||
if (isAuthenticated()) {
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").removeClass(
|
||||
"disabled"
|
||||
);
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableJumpToMe").removeClass(
|
||||
"disabled"
|
||||
);
|
||||
} else {
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").addClass(
|
||||
"disabled"
|
||||
);
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableJumpToMe").addClass(
|
||||
"disabled"
|
||||
);
|
||||
}
|
||||
$("#leaderboards table thead tr td:nth-child(3)").html(
|
||||
Config.typingSpeedUnit + '<br><div class="sub">accuracy</div>'
|
||||
);
|
||||
|
||||
languageSelector = new SlimSelect({
|
||||
select:
|
||||
"#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .languageSelect",
|
||||
settings: {
|
||||
showSearch: false,
|
||||
// contentLocation: document.querySelector(
|
||||
// "#leaderboardsWrapper"
|
||||
// ) as HTMLElement,
|
||||
// contentPosition: "relative",
|
||||
},
|
||||
data: [
|
||||
"english",
|
||||
"spanish",
|
||||
"german",
|
||||
"french",
|
||||
"portuguese",
|
||||
"indonesian",
|
||||
"italian",
|
||||
].map((lang) => ({
|
||||
value: lang,
|
||||
text: lang,
|
||||
selected: lang === currentLanguage,
|
||||
})),
|
||||
events: {
|
||||
afterChange: (newVal): void => {
|
||||
currentLanguage = newVal[0]?.value as string;
|
||||
updateTitle();
|
||||
void update();
|
||||
},
|
||||
},
|
||||
});
|
||||
$("#leaderboardsWrapper")
|
||||
.stop(true, true)
|
||||
.css("opacity", 0)
|
||||
.removeClass("hidden")
|
||||
.animate(
|
||||
{
|
||||
opacity: 1,
|
||||
},
|
||||
Misc.applyReducedMotion(125),
|
||||
() => {
|
||||
void update();
|
||||
startTimer();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$("#leaderboardsWrapper").on("click", (e) => {
|
||||
if ($(e.target).attr("id") === "leaderboardsWrapper") {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
let languageSelector: SlimSelect | undefined = undefined;
|
||||
|
||||
// const languageSelector = new SlimSelect({
|
||||
// select:
|
||||
// "#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .languageSelect",
|
||||
// settings: {
|
||||
// showSearch: false,
|
||||
// // contentLocation: document.querySelector(
|
||||
// // "#leaderboardsWrapper"
|
||||
// // ) as HTMLElement,
|
||||
// // contentPosition: "relative",
|
||||
// },
|
||||
// data: [
|
||||
// {
|
||||
// value: "english",
|
||||
// text: "english",
|
||||
// selected: true,
|
||||
// },
|
||||
// {
|
||||
// value: "spanish",
|
||||
// text: "spanish",
|
||||
// },
|
||||
// {
|
||||
// value: "german",
|
||||
// text: "german",
|
||||
// },
|
||||
// {
|
||||
// value: "french",
|
||||
// text: "french",
|
||||
// },
|
||||
// {
|
||||
// value: "portuguese",
|
||||
// text: "portuguese",
|
||||
// },
|
||||
// {
|
||||
// value: "indonesian",
|
||||
// text: "indonesian",
|
||||
// },
|
||||
// {
|
||||
// value: "italian",
|
||||
// text: "italian",
|
||||
// },
|
||||
// ],
|
||||
// events: {
|
||||
// afterChange: (newVal): void => {
|
||||
// currentLanguage = newVal[0]?.value as string;
|
||||
// updateTitle();
|
||||
// void update();
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
let leftScrollEnabled = true;
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableWrapper").on("scroll", (e) => {
|
||||
if (!leftScrollEnabled) return;
|
||||
const elem = $(e.currentTarget);
|
||||
if (Math.round(elem.scrollTop() as number) <= 50) {
|
||||
void debouncedRequestMore("15", true);
|
||||
}
|
||||
});
|
||||
|
||||
const debouncedRequestMore = debounce(500, requestMore);
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableWrapper").on("scroll", (e) => {
|
||||
if (!leftScrollEnabled) return;
|
||||
const elem = $(e.currentTarget);
|
||||
if (elem === undefined || elem[0] === undefined) return;
|
||||
if (
|
||||
Math.round(elem[0].scrollHeight - (elem.scrollTop() as number)) <=
|
||||
Math.round(elem.outerHeight() as number) + 50
|
||||
) {
|
||||
void debouncedRequestMore("15");
|
||||
}
|
||||
});
|
||||
|
||||
let rightScrollEnabled = true;
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableWrapper").on("scroll", (e) => {
|
||||
if (!rightScrollEnabled) return;
|
||||
const elem = $(e.currentTarget);
|
||||
if (Math.round(elem.scrollTop() as number) <= 50) {
|
||||
void debouncedRequestMore("60", true);
|
||||
}
|
||||
});
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableWrapper").on("scroll", (e) => {
|
||||
const elem = $(e.currentTarget);
|
||||
if (elem === undefined || elem[0] === undefined) return;
|
||||
if (
|
||||
Math.round(elem[0].scrollHeight - (elem.scrollTop() as number)) <=
|
||||
Math.round((elem.outerHeight() as number) + 50)
|
||||
) {
|
||||
void debouncedRequestMore("60");
|
||||
}
|
||||
});
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableJumpToTop").on(
|
||||
"click",
|
||||
async () => {
|
||||
leftScrollEnabled = false;
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableWrapper").scrollTop(0);
|
||||
await requestNew("15", 0);
|
||||
leftScrollEnabled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableJumpToMe").on(
|
||||
"click",
|
||||
async () => {
|
||||
if (!currentRank["15"]?.rank) return;
|
||||
leftScrollEnabled = false;
|
||||
await requestNew("15", currentRank["15"].rank - leaderboardSingleLimit / 2);
|
||||
const rowHeight = $(
|
||||
"#leaderboardsWrapper #leaderboards .leftTableWrapper table tbody td"
|
||||
).outerHeight() as number;
|
||||
$("#leaderboardsWrapper #leaderboards .leftTableWrapper").animate(
|
||||
{
|
||||
scrollTop:
|
||||
rowHeight *
|
||||
Math.min(currentRank["15"].rank, leaderboardSingleLimit / 2) -
|
||||
($(
|
||||
"#leaderboardsWrapper #leaderboards .leftTableWrapper"
|
||||
).outerHeight() as number) /
|
||||
2.25,
|
||||
},
|
||||
0,
|
||||
() => {
|
||||
leftScrollEnabled = true;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableJumpToTop").on(
|
||||
"click",
|
||||
async () => {
|
||||
rightScrollEnabled = false;
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableWrapper").scrollTop(0);
|
||||
await requestNew("60", 0);
|
||||
rightScrollEnabled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").on(
|
||||
"click",
|
||||
async () => {
|
||||
if (!currentRank["60"]?.rank) return;
|
||||
leftScrollEnabled = false;
|
||||
await requestNew("60", currentRank["60"].rank - leaderboardSingleLimit / 2);
|
||||
const rowHeight = $(
|
||||
"#leaderboardsWrapper #leaderboards .rightTableWrapper table tbody td"
|
||||
).outerHeight() as number;
|
||||
$("#leaderboardsWrapper #leaderboards .rightTableWrapper").animate(
|
||||
{
|
||||
scrollTop:
|
||||
rowHeight *
|
||||
Math.min(currentRank["60"].rank, leaderboardSingleLimit / 2) -
|
||||
($(
|
||||
"#leaderboardsWrapper #leaderboards .rightTableWrapper"
|
||||
).outerHeight() as number) /
|
||||
2.25,
|
||||
},
|
||||
0,
|
||||
() => {
|
||||
leftScrollEnabled = true;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
$(
|
||||
"#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .allTime"
|
||||
).on("click", () => {
|
||||
currentTimeRange = "allTime";
|
||||
currentLanguage = "english";
|
||||
languageSelector?.disable();
|
||||
languageSelector?.setSelected("english");
|
||||
void update();
|
||||
});
|
||||
|
||||
$(
|
||||
"#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .daily"
|
||||
).on("click", () => {
|
||||
currentTimeRange = "daily";
|
||||
updateYesterdayButton();
|
||||
languageSelector?.enable();
|
||||
void update();
|
||||
});
|
||||
|
||||
$("#leaderboardsWrapper .showYesterdayButton").on("click", () => {
|
||||
showingYesterday = !showingYesterday;
|
||||
void update();
|
||||
});
|
||||
|
||||
$(document).on("keydown", (event) => {
|
||||
if (event.key === "Escape" && Misc.isPopupVisible("leaderboardsWrapper")) {
|
||||
hide();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$("header nav").on("click", ".textButton", (e) => {
|
||||
if ($(e.currentTarget).hasClass("leaderboards")) {
|
||||
show();
|
||||
}
|
||||
});
|
||||
|
||||
$(".pageTest").on("click", "#dailyLeaderboardRank", () => {
|
||||
currentTimeRange = "daily";
|
||||
updateYesterdayButton();
|
||||
languageSelector?.enable();
|
||||
|
||||
currentLanguage = TestStats.lastResult.language;
|
||||
languageSelector?.setSelected(currentLanguage);
|
||||
void update();
|
||||
show();
|
||||
});
|
||||
|
||||
Skeleton.save(wrapperId);
|
||||
9
frontend/src/ts/event-handlers/leaderboards.ts
Normal file
9
frontend/src/ts/event-handlers/leaderboards.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { showPopup } from "../modals/simple-modals";
|
||||
|
||||
const lb = document.getElementById("pageLeaderboards");
|
||||
|
||||
lb?.querySelector(
|
||||
".jumpButtons button[data-action='goToPage']"
|
||||
)?.addEventListener("click", () => {
|
||||
showPopup("lbGoToPage");
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import "./event-handlers/test";
|
|||
import "./event-handlers/about";
|
||||
import "./event-handlers/settings";
|
||||
import "./event-handlers/account";
|
||||
import "./event-handlers/leaderboards";
|
||||
import "./event-handlers/login";
|
||||
|
||||
import "./modals/google-sign-up";
|
||||
|
|
@ -34,7 +35,6 @@ import "./controllers/route-controller";
|
|||
import "./pages/about";
|
||||
import "./elements/scroll-to-top";
|
||||
import * as Account from "./pages/account";
|
||||
import "./elements/leaderboards";
|
||||
import "./elements/no-css";
|
||||
import { egVideoListener } from "./popups/video-ad-popup";
|
||||
import "./states/connection";
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { ShowOptions } from "../utils/animated-modal";
|
||||
import { GenerateDataRequest } from "@monkeytype/contracts/dev";
|
||||
import { UserNameSchema } from "@monkeytype/contracts/users";
|
||||
import { goToPage } from "../pages/leaderboards";
|
||||
|
||||
type PopupKey =
|
||||
| "updateEmail"
|
||||
|
|
@ -60,7 +61,8 @@ type PopupKey =
|
|||
| "resetProgressCustomTextLong"
|
||||
| "updateCustomTheme"
|
||||
| "deleteCustomTheme"
|
||||
| "devGenerateData";
|
||||
| "devGenerateData"
|
||||
| "lbGoToPage";
|
||||
|
||||
const list: Record<PopupKey, SimpleModal | undefined> = {
|
||||
updateEmail: undefined,
|
||||
|
|
@ -86,6 +88,7 @@ const list: Record<PopupKey, SimpleModal | undefined> = {
|
|||
updateCustomTheme: undefined,
|
||||
deleteCustomTheme: undefined,
|
||||
devGenerateData: undefined,
|
||||
lbGoToPage: undefined,
|
||||
};
|
||||
|
||||
type AuthMethod = "password" | "github.com" | "google.com";
|
||||
|
|
@ -1309,6 +1312,36 @@ list.devGenerateData = new SimpleModal({
|
|||
};
|
||||
},
|
||||
});
|
||||
|
||||
list.lbGoToPage = new SimpleModal({
|
||||
id: "lbGoToPage",
|
||||
title: "Go to page",
|
||||
inputs: [
|
||||
{
|
||||
type: "number",
|
||||
placeholder: "Page number",
|
||||
},
|
||||
],
|
||||
buttonText: "Go",
|
||||
execFn: async (_thisPopup, pageNumber): Promise<ExecReturn> => {
|
||||
const page = parseInt(pageNumber, 10);
|
||||
if (isNaN(page) || page < 1) {
|
||||
return {
|
||||
status: 0,
|
||||
message: "Invalid page number",
|
||||
};
|
||||
}
|
||||
|
||||
goToPage(page - 1);
|
||||
|
||||
return {
|
||||
status: 1,
|
||||
message: "Navigating to page " + page,
|
||||
showNotification: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function showPopup(
|
||||
key: PopupKey,
|
||||
showParams = [] as string[],
|
||||
|
|
|
|||
1220
frontend/src/ts/pages/leaderboards.ts
Normal file
1220
frontend/src/ts/pages/leaderboards.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,8 @@ export type PageName =
|
|||
| "profile"
|
||||
| "profileSearch"
|
||||
| "404"
|
||||
| "accountSettings";
|
||||
| "accountSettings"
|
||||
| "leaderboards";
|
||||
|
||||
type Options<T> = {
|
||||
params?: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ function updateKeytips(): void {
|
|||
|
||||
if (isDevEnvironment()) {
|
||||
window.onerror = function (error): void {
|
||||
if (JSON.stringify(error).includes("x_magnitude")) return;
|
||||
Notifications.add(JSON.stringify(error), -1);
|
||||
};
|
||||
$("header #logo .top").text("localhost");
|
||||
|
|
|
|||
|
|
@ -35,5 +35,5 @@ export const contract = c.router({
|
|||
* Whenever there is a breaking change with old frontend clients increase this number.
|
||||
* This will inform the frontend to refresh.
|
||||
*/
|
||||
export const COMPATIBILITY_CHECK = 0;
|
||||
export const COMPATIBILITY_CHECK = 1;
|
||||
export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";
|
||||
|
|
|
|||
|
|
@ -6,86 +6,123 @@ import {
|
|||
responseWithNullableData,
|
||||
} from "./schemas/api";
|
||||
import {
|
||||
DailyLeaderboardRankSchema,
|
||||
LeaderboardEntrySchema,
|
||||
LeaderboardRankSchema,
|
||||
XpLeaderboardEntrySchema,
|
||||
XpLeaderboardRankSchema,
|
||||
} from "./schemas/leaderboards";
|
||||
import { LanguageSchema } from "./schemas/util";
|
||||
import { Mode2Schema, ModeSchema } from "./schemas/shared";
|
||||
import { initContract } from "@ts-rest/core";
|
||||
|
||||
export const LanguageAndModeQuerySchema = z.object({
|
||||
//TODO NOTIFY USERS ON OLD CLINT THAT SCHEMA CHNAGED
|
||||
|
||||
const LanguageAndModeQuerySchema = z.object({
|
||||
language: LanguageSchema,
|
||||
mode: ModeSchema,
|
||||
mode2: Mode2Schema,
|
||||
});
|
||||
export type LanguageAndModeQuery = z.infer<typeof LanguageAndModeQuerySchema>;
|
||||
|
||||
const PaginationQuerySchema = z.object({
|
||||
skip: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().nonnegative().max(50).optional(),
|
||||
page: z.number().int().nonnegative().default(0),
|
||||
pageSize: z.number().int().nonnegative().max(200).default(50),
|
||||
});
|
||||
|
||||
const LeaderboardResponseSchema = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
pageSize: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
|
||||
PaginationQuerySchema
|
||||
);
|
||||
export type GetLeaderboardQuery = z.infer<typeof GetLeaderboardQuerySchema>;
|
||||
|
||||
export const GetLeaderboardResponseSchema = responseWithData(
|
||||
z.array(LeaderboardEntrySchema)
|
||||
LeaderboardResponseSchema.extend({
|
||||
entries: z.array(LeaderboardEntrySchema),
|
||||
})
|
||||
);
|
||||
export type GetLeaderboardResponse = z.infer<
|
||||
typeof GetLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const GetLeaderboardRankResponseSchema = responseWithData(
|
||||
LeaderboardRankSchema
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema;
|
||||
export type GetLeaderboardRankQuery = z.infer<
|
||||
typeof GetLeaderboardRankQuerySchema
|
||||
>;
|
||||
export const GetLeaderboardRankResponseSchema = responseWithNullableData(
|
||||
LeaderboardEntrySchema
|
||||
);
|
||||
export type GetLeaderboardRankResponse = z.infer<
|
||||
typeof GetLeaderboardRankResponseSchema
|
||||
>;
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
export const GetDailyLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
|
||||
PaginationQuerySchema
|
||||
).extend({
|
||||
daysBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetDailyLeaderboardQuery = z.infer<
|
||||
typeof GetDailyLeaderboardQuerySchema
|
||||
>;
|
||||
export const GetDailyLeaderboardResponseSchema = responseWithData(
|
||||
LeaderboardResponseSchema.extend({
|
||||
entries: z.array(LeaderboardEntrySchema),
|
||||
minWpm: z.number().nonnegative(),
|
||||
})
|
||||
);
|
||||
export type GetDailyLeaderboardResponse = z.infer<
|
||||
typeof GetDailyLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
export const GetDailyLeaderboardRankQuerySchema =
|
||||
LanguageAndModeQuerySchema.extend({
|
||||
LanguageAndModeQuerySchema.merge(PaginationQuerySchema).extend({
|
||||
daysBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetDailyLeaderboardRankQuery = z.infer<
|
||||
typeof GetDailyLeaderboardRankQuerySchema
|
||||
>;
|
||||
|
||||
export const GetDailyLeaderboardQuerySchema =
|
||||
GetDailyLeaderboardRankQuerySchema.merge(PaginationQuerySchema);
|
||||
export type GetDailyLeaderboardQuery = z.infer<
|
||||
typeof GetDailyLeaderboardQuerySchema
|
||||
>;
|
||||
|
||||
export const GetLeaderboardDailyRankResponseSchema = responseWithData(
|
||||
DailyLeaderboardRankSchema
|
||||
export const GetLeaderboardDailyRankResponseSchema = responseWithNullableData(
|
||||
LeaderboardEntrySchema
|
||||
);
|
||||
export type GetLeaderboardDailyRankResponse = z.infer<
|
||||
typeof GetLeaderboardDailyRankResponseSchema
|
||||
>;
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
export const GetWeeklyXpLeaderboardQuerySchema = PaginationQuerySchema.extend({
|
||||
weeksBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetWeeklyXpLeaderboardQuery = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardQuerySchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardResponseSchema = responseWithData(
|
||||
z.array(XpLeaderboardEntrySchema)
|
||||
LeaderboardResponseSchema.extend({
|
||||
entries: z.array(XpLeaderboardEntrySchema),
|
||||
})
|
||||
);
|
||||
export type GetWeeklyXpLeaderboardResponse = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
export const GetWeeklyXpLeaderboardRankResponseSchema =
|
||||
responseWithNullableData(XpLeaderboardRankSchema.partial());
|
||||
responseWithNullableData(XpLeaderboardEntrySchema);
|
||||
export type GetWeeklyXpLeaderboardRankResponse = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardRankResponseSchema
|
||||
>;
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
const c = initContract();
|
||||
export const leaderboardsContract = c.router(
|
||||
{
|
||||
|
|
@ -108,7 +145,7 @@ export const leaderboardsContract = c.router(
|
|||
"Get the rank of the current user on the all-time leaderboard",
|
||||
method: "GET",
|
||||
path: "/rank",
|
||||
query: LanguageAndModeQuerySchema.strict(),
|
||||
query: GetLeaderboardRankQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardRankResponseSchema,
|
||||
},
|
||||
|
|
@ -123,7 +160,7 @@ export const leaderboardsContract = c.router(
|
|||
path: "/daily",
|
||||
query: GetDailyLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardResponseSchema,
|
||||
200: GetDailyLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: meta({
|
||||
authenticationOptions: { isPublic: true },
|
||||
|
|
|
|||
|
|
@ -16,16 +16,7 @@ export const LeaderboardEntrySchema = z.object({
|
|||
});
|
||||
export type LeaderboardEntry = z.infer<typeof LeaderboardEntrySchema>;
|
||||
|
||||
export const LeaderboardRankSchema = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
rank: z.number().int().nonnegative().optional(),
|
||||
entry: LeaderboardEntrySchema.optional(),
|
||||
});
|
||||
export type LeaderboardRank = z.infer<typeof LeaderboardRankSchema>;
|
||||
|
||||
export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({
|
||||
minWpm: z.number().nonnegative(),
|
||||
});
|
||||
export const DailyLeaderboardRankSchema = LeaderboardEntrySchema;
|
||||
export type DailyLeaderboardRank = z.infer<typeof DailyLeaderboardRankSchema>;
|
||||
|
||||
export const XpLeaderboardEntrySchema = z.object({
|
||||
|
|
@ -38,10 +29,6 @@ export const XpLeaderboardEntrySchema = z.object({
|
|||
timeTypedSeconds: z.number().nonnegative(),
|
||||
rank: z.number().nonnegative().int(),
|
||||
totalXp: z.number().nonnegative().int(),
|
||||
isPremium: z.boolean().optional(),
|
||||
});
|
||||
export type XpLeaderboardEntry = z.infer<typeof XpLeaderboardEntrySchema>;
|
||||
|
||||
export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({
|
||||
count: z.number().int().nonnegative(),
|
||||
});
|
||||
export type XpLeaderboardRank = z.infer<typeof XpLeaderboardRankSchema>;
|
||||
|
|
|
|||
|
|
@ -78,3 +78,13 @@ export function getCurrentWeekTimestamp(): number {
|
|||
const currentTime = Date.now();
|
||||
return getStartOfWeekTimestamp(currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp of the start of the last week.
|
||||
* @returns The timestamp of the start of the last week.
|
||||
*/
|
||||
export function getLastWeekTimestamp(): number {
|
||||
const currentTime = Date.now();
|
||||
const lastWeekTime = currentTime - 7 * MILLISECONDS_IN_DAY;
|
||||
return getStartOfWeekTimestamp(lastWeekTime);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue