feat: leaderboards remake, weekly xp leaderboards (@miodec) (#6250)

This commit is contained in:
Jack 2025-02-12 16:27:45 +01:00 committed by Miodec
parent e7685c5861
commit 01dee3fe15
32 changed files with 2223 additions and 1436 deletions

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -21,4 +21,4 @@ for _, user_id in ipairs(scores_in_range) do
end
end
return {results, scores}
return {results, scores}

View file

@ -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(

View file

@ -593,6 +593,7 @@ export async function addResult(
discordId: user.discordId,
badgeId: selectedBadgeId,
lastActivityTimestamp: Date.now(),
isPremium,
},
xpGained: xpGained.xp,
timeTypedSeconds: totalDurationTypedSeconds,

View file

@ -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 {

View file

@ -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) {

View file

@ -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(

View file

@ -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(

View file

@ -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) {

View file

@ -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"

View 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>

View file

@ -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

View file

@ -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";

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}
}
}
}

View file

@ -19,4 +19,9 @@
// width: 1.6em;
// }
}
.pageLeaderboards {
.content {
grid-template-columns: 15rem 1fr;
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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()];

View file

@ -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();
});

View file

@ -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);

View 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");
});

View file

@ -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";

View file

@ -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[],

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@ export type PageName =
| "profile"
| "profileSearch"
| "404"
| "accountSettings";
| "accountSettings"
| "leaderboards";
type Options<T> = {
params?: Record<string, string>;

View file

@ -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");

View file

@ -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";

View file

@ -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 },

View file

@ -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>;

View file

@ -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);
}