From 01dee3fe159275de7b9acbe3be9c0ad88a5d0327 Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 12 Feb 2025 16:27:45 +0100 Subject: [PATCH] feat: leaderboards remake, weekly xp leaderboards (@miodec) (#6250) --- .../api/controllers/leaderboard.spec.ts | 261 ++-- .../__tests__/api/controllers/user.spec.ts | 13 +- backend/__tests__/dal/leaderboards.spec.ts | 23 +- backend/redis-scripts/get-results.lua | 2 +- backend/src/api/controllers/leaderboard.ts | 73 +- backend/src/api/controllers/result.ts | 1 + backend/src/api/controllers/user.ts | 20 +- backend/src/dal/leaderboards.ts | 40 +- backend/src/services/weekly-xp-leaderboard.ts | 49 +- backend/src/utils/daily-leaderboards.ts | 77 +- backend/src/workers/later-worker.ts | 11 +- frontend/src/html/header.html | 15 +- frontend/src/html/pages/leaderboards.html | 218 +++ frontend/src/index.html | 1 + frontend/src/styles/index.scss | 4 +- frontend/src/styles/leaderboards.scss | 407 +++--- frontend/src/styles/media-queries-green.scss | 18 + frontend/src/styles/media-queries-orange.scss | 5 + frontend/src/styles/media-queries-yellow.scss | 25 + .../src/ts/controllers/page-controller.ts | 2 + .../src/ts/controllers/route-controller.ts | 20 +- frontend/src/ts/elements/leaderboards.ts | 988 ------------- .../src/ts/event-handlers/leaderboards.ts | 9 + frontend/src/ts/index.ts | 2 +- frontend/src/ts/modals/simple-modals.ts | 35 +- frontend/src/ts/pages/leaderboards.ts | 1220 +++++++++++++++++ frontend/src/ts/pages/page.ts | 3 +- frontend/src/ts/ui.ts | 1 + packages/contracts/src/index.ts | 2 +- packages/contracts/src/leaderboards.ts | 87 +- .../contracts/src/schemas/leaderboards.ts | 17 +- packages/util/src/date-and-time.ts | 10 + 32 files changed, 2223 insertions(+), 1436 deletions(-) create mode 100644 frontend/src/html/pages/leaderboards.html delete mode 100644 frontend/src/ts/elements/leaderboards.ts create mode 100644 frontend/src/ts/event-handlers/leaderboards.ts create mode 100644 frontend/src/ts/pages/leaderboards.ts diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index fee9a51a7..54a8ad7f5 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -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", diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index b034fd8c7..2e1defd47 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -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 = { _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 diff --git a/backend/__tests__/dal/leaderboards.spec.ts b/backend/__tests__/dal/leaderboards.spec.ts index 160625049..0f1ea7595 100644 --- a/backend/__tests__/dal/leaderboards.spec.ts +++ b/backend/__tests__/dal/leaderboards.spec.ts @@ -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 diff --git a/backend/redis-scripts/get-results.lua b/backend/redis-scripts/get-results.lua index bc15ff997..3897517c3 100644 --- a/backend/redis-scripts/get-results.lua +++ b/backend/redis-scripts/get-results.lua @@ -21,4 +21,4 @@ for _, user_id in ipairs(scores_in_range) do end end -return {results, scores} +return {results, scores} \ No newline at end of file diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 129004ed8..06d527372 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -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 ): Promise { - 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 + req: MonkeyRequest ): Promise { const { language, mode, mode2 } = req.query; const { uid } = req.ctx.decodedToken; @@ -91,25 +97,33 @@ function getDailyLeaderboardWithError( export async function getDailyLeaderboard( req: MonkeyRequest -): Promise { - const { skip = 0, limit = 50 } = req.query; +): Promise { + 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 ): Promise { - 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( diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 368f02fba..881e79207 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -593,6 +593,7 @@ export async function addResult( discordId: user.discordId, badgeId: selectedBadgeId, lastActivityTimestamp: Date.now(), + isPremium, }, xpGained: xpGained.xp, timeTypedSeconds: totalDurationTypedSeconds, diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 45800c80a..062f19294 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -1071,6 +1071,12 @@ async function getAllTimeLbs(uid: string): Promise { 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 { 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 { diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 6d1f98118..da1ac3d2b 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -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 { - //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 { + return getCollection({ language, mode, mode2 }).estimatedDocumentCount(); +} + export async function getRank( mode: string, mode2: string, language: string, uid: string -): Promise { +): Promise { 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) { diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 149f64b63..bfedf5066 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -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 { 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 { + ): Promise { 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 { + const connection = RedisClient.getConnection(); + if (!connection) { + throw new Error("Redis connection is unavailable"); + } + + const { weeklyXpLeaderboardScoresKey } = + this.getThisWeeksXpLeaderboardKeys(); + + return connection.zcard(weeklyXpLeaderboardScoresKey); + } } export function get( diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 46330a16f..4af312c24 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -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 { @@ -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 { + 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 { + ): Promise { 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 { + 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( diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index 293370bcf..a4d44a4db 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -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) { diff --git a/frontend/src/html/header.html b/frontend/src/html/header.html index cb8ede59c..1cfe0fadf 100644 --- a/frontend/src/html/header.html +++ b/frontend/src/html/header.html @@ -56,7 +56,7 @@ - + --> + +
+ +
+
+
+
+
+
-
+
+
+ + + +
+
+
+ +
+
+
Updates in: -
+ +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#name + wpm +
accuracy
+
+ raw +
consistency
+
wpmaccuracyrawconsistencydate
#namexp gainedtime typed + xp gained +
time typed
+
last activity
1 +
+
+
+ +
+
+
Username
+
+
100.00100.00%100.00100.00%23 Aug 2024 12:10
2 +
+
+
+ +
+
+
Username
+
+
100.00100.00%100.00100.00%23 Aug 2024 12:10
2 +
+
+
+ +
+
+
Username
+
+
100.00100.00%100.00100.00%23 Aug 2024 12:10
+
+
+
+ + + + + + +
+
+
+
+
+ + + +
+ + + + +
+
+ diff --git a/frontend/src/index.html b/frontend/src/index.html index d8686dc1c..81082470b 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -45,6 +45,7 @@ +
.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; } } } diff --git a/frontend/src/styles/media-queries-green.scss b/frontend/src/styles/media-queries-green.scss index cba78f2ad..c91870a5a 100644 --- a/frontend/src/styles/media-queries-green.scss +++ b/frontend/src/styles/media-queries-green.scss @@ -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; + } + } + } + } + } } diff --git a/frontend/src/styles/media-queries-orange.scss b/frontend/src/styles/media-queries-orange.scss index 998717d58..f9672bb4e 100644 --- a/frontend/src/styles/media-queries-orange.scss +++ b/frontend/src/styles/media-queries-orange.scss @@ -19,4 +19,9 @@ // width: 1.6em; // } } + .pageLeaderboards { + .content { + grid-template-columns: 15rem 1fr; + } + } } diff --git a/frontend/src/styles/media-queries-yellow.scss b/frontend/src/styles/media-queries-yellow.scss index e64edfb45..85af2f495 100644 --- a/frontend/src/styles/media-queries-yellow.scss +++ b/frontend/src/styles/media-queries-yellow.scss @@ -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; + } + } + } } diff --git a/frontend/src/ts/controllers/page-controller.ts b/frontend/src/ts/controllers/page-controller.ts index 1e90ccd11..835bfe776 100644 --- a/frontend/src/ts/controllers/page-controller.ts +++ b/frontend/src/ts/controllers/page-controller.ts @@ -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()]; diff --git a/frontend/src/ts/controllers/route-controller.ts b/frontend/src/ts/controllers/route-controller.ts index afc93371e..7e5380d2e 100644 --- a/frontend/src/ts/controllers/route-controller.ts +++ b/frontend/src/ts/controllers/route-controller.ts @@ -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(); -}); diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts deleted file mode 100644 index 33c9421c9..000000000 --- a/frontend/src/ts/elements/leaderboards.ts +++ /dev/null @@ -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; -} = { - "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(` - - - - `); - return; - } - - if ( - !Misc.isDevEnvironment() && - (DB.getSnapshot()?.typingStats?.timeTyping ?? 0) < 7200 - ) { - $(`#leaderboardsWrapper table.${side} tfoot`).html(` - - Your account must have 2 hours typed to be placed on the leaderboard. - - `); - return; - } - - if (DB.getSnapshot()?.lbOptOut === true) { - $(`#leaderboardsWrapper table.${side} tfoot`).html(` - - You have opted out of the leaderboards - - `); - return; - } - - const lbRank = currentRank[lb]; - - if ( - currentTimeRange === "daily" && - lbRank !== null && - lbRank.minWpm === undefined - ) { - //old response format - $(`#leaderboardsWrapper table.${side} tfoot`).html(` - - Looks like the server returned data in a new format, please refresh - - `); - 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 = `
${toppercent}`; - } - - const entry = lbRank?.entry; - if (entry) { - const date = new Date(entry.timestamp); - $(`#leaderboardsWrapper table.${side} tfoot`).html(` - - ${lbRank.rank} - You${toppercent ? toppercent : ""} - ${Format.typingSpeed(entry.wpm, { - showDecimalPlaces: true, - })}
-
${Format.percentage(entry.acc, { - showDecimalPlaces: true, - })}
- ${Format.typingSpeed(entry.raw, { - showDecimalPlaces: true, - })}
-
${Format.percentage(entry.consistency, { - showDecimalPlaces: true, - })}
- ${format(date, "dd MMM yyyy")}
-
${format(date, "HH:mm")}
- - `); - } else if (currentTimeRange === "daily") { - $(`#leaderboardsWrapper table.${side} tfoot`).html(` - - Not qualified ${`(min speed required: ${currentRank[lb]?.minWpm} wpm)`} - - `); - } else { - $(`#leaderboardsWrapper table.${side} tfoot`).html(` - - Not qualified - - `); - } -} - -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( - ` (${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( - ` (${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 { - 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( - "No results found" - ); - } - 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 = `
`; - - if (entry.discordAvatar !== undefined) { - avatar = `
`; - } - - html += ` - - ${ - entry.rank === 1 ? '' : entry.rank - } - -
-
${avatar}
-
${entry.name} -
- ${getHtmlByUserFlags(entry)} - ${entry.badgeId ? getBadgeHTMLbyId(entry.badgeId) : ""} -
-
- - ${Format.typingSpeed(entry.wpm, { - showDecimalPlaces: true, - })}
-
${Format.percentage(entry.acc, { - showDecimalPlaces: true, - })}
- ${Format.typingSpeed(entry.raw, { - showDecimalPlaces: true, - })}
-
${Format.percentage(entry.consistency, { - showDecimalPlaces: true, - })}
- ${format(date, "dd MMM yyyy")}
-
${format(date, "HH:mm")}
- - `; - } - $(`#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 { - 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 { - 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 { - 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( - `
` - ); - } else { - $(element).html( - `
` - ); - } - } -} - -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 + '
accuracy
' - ); - - 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); diff --git a/frontend/src/ts/event-handlers/leaderboards.ts b/frontend/src/ts/event-handlers/leaderboards.ts new file mode 100644 index 000000000..5e0fe4caa --- /dev/null +++ b/frontend/src/ts/event-handlers/leaderboards.ts @@ -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"); +}); diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index a422ddafa..c5f3a7f66 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -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"; diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 5a694464e..5e51873f7 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -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 = { updateEmail: undefined, @@ -86,6 +88,7 @@ const list: Record = { 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 => { + 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[], diff --git a/frontend/src/ts/pages/leaderboards.ts b/frontend/src/ts/pages/leaderboards.ts new file mode 100644 index 000000000..adc2cd6ad --- /dev/null +++ b/frontend/src/ts/pages/leaderboards.ts @@ -0,0 +1,1220 @@ +import Page from "./page"; +import * as Skeleton from "../utils/skeleton"; +import { + LeaderboardEntry, + XpLeaderboardEntry, +} from "@monkeytype/contracts/schemas/leaderboards"; +import { capitalizeFirstLetter } from "../utils/strings"; +import Ape from "../ape"; +import { Mode } from "@monkeytype/contracts/schemas/shared"; +import * as Notifications from "../elements/notifications"; +import Format from "../utils/format"; +import { Auth, isAuthenticated } from "../firebase"; +import * as DB from "../db"; +import { format } from "date-fns"; +import { differenceInSeconds } from "date-fns/differenceInSeconds"; +import * as DateTime from "../utils/date-and-time"; +import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; +import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller"; +import { getDiscordAvatarUrl, isDevEnvironment } from "../utils/misc"; +import { abbreviateNumber } from "../utils/numbers"; +import { + getCurrentWeekTimestamp, + getLastWeekTimestamp, + getStartOfDayTimestamp, +} from "@monkeytype/util/date-and-time"; +import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; +// import * as ServerConfiguration from "../ape/server-configuration"; + +type LeaderboardType = "allTime" | "weekly" | "daily"; + +type AllTimeState = { + type: "allTime"; + mode: "time"; + mode2: "15" | "60"; + data: LeaderboardEntry[] | null; + count: number; + userData: LeaderboardEntry | null; +}; + +type WeeklyState = { + type: "weekly"; + lastWeek: boolean; + data: XpLeaderboardEntry[] | null; + count: number; + userData: XpLeaderboardEntry | null; +}; + +type DailyState = { + type: "daily"; + mode: "time"; + mode2: "15" | "60"; + yesterday: boolean; + minWpm: number; + language: string; + data: LeaderboardEntry[] | null; + count: number; + userData: LeaderboardEntry | null; +}; + +type State = { + type: LeaderboardType; + loading: boolean; + updating: boolean; + page: number; + pageSize: number; + title: string; + error?: string; + discordAvatarUrls: Map; +} & (AllTimeState | WeeklyState | DailyState); + +const state = { + loading: true, + updating: false, + type: "allTime", + mode2: "15", + data: null, + userData: null, + page: 0, + pageSize: 50, + title: "All-time English Time 15 Leaderboard", + discordAvatarUrls: new Map(), +} as State; + +function updateTitle(): void { + const type = + state.type === "allTime" + ? "All-time" + : state.type === "weekly" + ? "Weekly XP" + : "Daily"; + + const language = + state.type === "daily" + ? capitalizeFirstLetter(state.language) + : state.type === "allTime" + ? "English" + : ""; + + const mode = + state.type === "allTime" + ? ` Time ${state.mode2}` + : state.type === "daily" + ? ` Time ${state.mode2}` + : ""; + + state.title = `${type} ${language} ${mode} Leaderboard`; + $(".page.pageLeaderboards .bigtitle >.text").text(state.title); + + $(".page.pageLeaderboards .bigtitle .subtext").addClass("hidden"); + $(".page.pageLeaderboards .bigtitle button").addClass("hidden"); + $(".page.pageLeaderboards .bigtitle .subtext .divider").addClass("hidden"); + + if (state.type === "daily") { + $(".page.pageLeaderboards .bigtitle .subtext").removeClass("hidden"); + $( + ".page.pageLeaderboards .bigtitle button[data-action='toggleYesterday']" + ).removeClass("hidden"); + $(".page.pageLeaderboards .bigtitle .subtext .divider").removeClass( + "hidden" + ); + + if (state.yesterday) { + $( + ".page.pageLeaderboards .bigtitle button[data-action='toggleYesterday']" + ).html(` + + show today + `); + } else { + $( + ".page.pageLeaderboards .bigtitle button[data-action='toggleYesterday']" + ).html(` + + show yesterday + `); + } + + let timestamp = getStartOfDayTimestamp(new Date().getTime()); + + if (state.yesterday) { + timestamp -= 24 * 60 * 60 * 100; + } + + const dateString = format(timestamp, "EEEE, do MMMM yyyy"); + $(".page.pageLeaderboards .bigtitle .subtext > .text").text( + `${dateString}` + ); + } else if (state.type === "weekly") { + $(".page.pageLeaderboards .bigtitle .subtext").removeClass("hidden"); + $( + ".page.pageLeaderboards .bigtitle button[data-action='toggleLastWeek']" + ).removeClass("hidden"); + $(".page.pageLeaderboards .bigtitle .subtext .divider").removeClass( + "hidden" + ); + + if (state.lastWeek) { + $(".page.pageLeaderboards .bigtitle button[data-action='toggleLastWeek']") + .html(` + + show this week + `); + } else { + $(".page.pageLeaderboards .bigtitle button[data-action='toggleLastWeek']") + .html(` + + show last week + `); + } + + let fn = getCurrentWeekTimestamp(); + + if (state.lastWeek) { + fn = getLastWeekTimestamp(); + } + + const dateString = `${format(fn, "EEEE, do MMMM yyyy")} - ${format( + fn + 6 * 24 * 60 * 60 * 1000, + "EEEE, do MMMM yyyy" + )}`; + $(".page.pageLeaderboards .bigtitle .subtext > .text").text( + `${dateString}` + ); + } +} + +async function requestData(update = false): Promise { + if (update) { + state.updating = true; + state.error = undefined; + } else { + state.loading = true; + state.error = undefined; + state.data = null; + state.userData = null; + } + updateContent(); + + if (state.type === "allTime" || state.type === "daily") { + const baseQuery = { + language: state.type === "allTime" ? "english" : state.language, + mode: "time" as Mode, + mode2: state.mode2, + }; + + let response; + + if (state.type === "allTime") { + response = await Ape.leaderboards.get({ + query: { ...baseQuery, page: state.page }, + }); + } else { + response = await Ape.leaderboards.getDaily({ + query: { + ...baseQuery, + page: state.page, + daysBefore: state.yesterday ? 1 : undefined, + }, + }); + } + + if (response.status === 200) { + state.data = response.body.data.entries; + state.count = response.body.data.count; + state.pageSize = response.body.data.pageSize; + + if (state.type === "daily") { + //@ts-ignore not sure why this is causing errors when it's clearly defined in the schema + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + state.minWpm = response.body.data.minWpm; + } + } else { + state.data = null; + state.error = "Something went wrong"; + Notifications.add( + "Failed to get leaderboard: " + response.body.message, + -1 + ); + } + + if (isAuthenticated() && state.userData === null) { + let rankResponse; + + if (state.type === "allTime") { + rankResponse = await Ape.leaderboards.getRank({ + query: { ...baseQuery }, + }); + } else { + rankResponse = await Ape.leaderboards.getDailyRank({ + query: { + ...baseQuery, + }, + }); + } + + if (rankResponse.status === 200) { + if (rankResponse.body.data !== null) { + state.userData = rankResponse.body.data; + } + } else { + state.userData = null; + state.error = "Something went wrong"; + Notifications.add( + "Failed to get rank: " + rankResponse.body.message, + -1 + ); + } + } + + if (state.data !== null) { + const entriesMissingAvatars = state.data.filter( + (entry) => !state.discordAvatarUrls.has(entry.uid) + ); + void getAvatarUrls(entriesMissingAvatars).then((urlMap) => { + state.discordAvatarUrls = new Map([ + ...state.discordAvatarUrls, + ...urlMap, + ]); + fillAvatars(); + }); + } + + state.loading = false; + state.updating = false; + updateContent(); + if (!update && isAuthenticated()) { + fillUser(); + } + return; + } else if (state.type === "weekly") { + const data = await Ape.leaderboards.getWeeklyXp({ + query: { page: state.page, weeksBefore: state.lastWeek ? 1 : undefined }, + }); + + if (data.status === 200) { + state.data = data.body.data.entries; + state.count = data.body.data.count; + state.pageSize = data.body.data.pageSize; + } else { + state.data = null; + state.error = "Something went wrong"; + Notifications.add("Failed to get leaderboard: " + data.body.message, -1); + } + + if (isAuthenticated() && state.userData === null) { + const userData = await Ape.leaderboards.getWeeklyXpRank(); + + if (userData.status === 200) { + if (userData.body.data !== null) { + state.userData = userData.body.data; + } + } else { + state.userData = null; + state.error = "Something went wrong"; + Notifications.add("Failed to get rank: " + userData.body.message, -1); + } + } + + if (state.data !== null) { + const entriesMissingAvatars = state.data.filter( + (entry) => !state.discordAvatarUrls.has(entry.uid) + ); + void getAvatarUrls(entriesMissingAvatars).then((urlMap) => { + state.discordAvatarUrls = new Map([ + ...state.discordAvatarUrls, + ...urlMap, + ]); + fillAvatars(); + }); + } + + state.loading = false; + state.updating = false; + updateContent(); + if (!update && isAuthenticated()) { + fillUser(); + } + return; + } else { + // state.updating = false; + // state.loading = false; + // state.error = "Unsupported mode"; + // updateContent(); + } +} + +function updateJumpButtons(): void { + const el = $(".page.pageLeaderboards .titleAndButtons .jumpButtons"); + el.find("button").removeClass("active"); + + const totalPages = Math.ceil(state.count / state.pageSize); + + if (totalPages <= 1) { + el.find("button").addClass("disabled"); + return; + } else { + el.find("button").removeClass("disabled"); + } + + if (state.page === 0) { + el.find("button[data-action='previousPage']").addClass("disabled"); + el.find("button[data-action='firstPage']").addClass("disabled"); + } else { + el.find("button[data-action='previousPage']").removeClass("disabled"); + el.find("button[data-action='firstPage']").removeClass("disabled"); + } + + if (isAuthenticated() && state.userData) { + const userPage = Math.floor(state.userData.rank / state.pageSize); + if (state.page === userPage) { + el.find("button[data-action='userPage']").addClass("disabled"); + } else { + el.find("button[data-action='userPage']").removeClass("disabled"); + } + } + + if (state.page >= totalPages - 1) { + el.find("button[data-action='nextPage']").addClass("disabled"); + } else { + el.find("button[data-action='nextPage']").removeClass("disabled"); + } +} + +async function getAvatarUrls( + data: LeaderboardEntry[] | XpLeaderboardEntry[] +): Promise> { + const results = await Promise.allSettled( + data.map(async (entry) => ({ + uid: entry.uid, + url: await getDiscordAvatarUrl(entry.discordId, entry.discordAvatar), + })) + ); + + const avatarMap = new Map(); + results.forEach((result) => { + if (result.status === "fulfilled" && result.value.url !== null) { + avatarMap.set(result.value.uid, result.value.url); + } + }); + + return avatarMap; +} +function fillAvatars(): void { + const elements = $(".page.pageLeaderboards table .lbav"); + + for (const element of elements) { + const uid = $(element).siblings(".entryName").attr("uid") as string; + const url = state.discordAvatarUrls.get(uid); + + if (url !== undefined) { + $(element).html( + `
` + ); + } else { + $(element).html( + `
` + ); + } + } +} + +function buildTableRow(entry: LeaderboardEntry, me = false): string { + let avatar = `
`; + + if (entry.discordAvatar !== undefined) { + avatar = `
`; + } + + const meClass = me ? "me" : ""; + + const formatted = { + wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }), + acc: Format.percentage(entry.acc, { showDecimalPlaces: true }), + raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }), + con: Format.percentage(entry.consistency, { showDecimalPlaces: true }), + }; + + return ` + + ${ + entry.rank === 1 ? '' : entry.rank + } + +
+
${avatar}
+ ${entry.name} +
+ ${getHtmlByUserFlags(entry)} + ${entry.badgeId ? getBadgeHTMLbyId(entry.badgeId) : ""} +
+
+ + + ${formatted.wpm} +
${formatted.acc}
+ + + + ${formatted.raw} +
${formatted.con}
+ + ${formatted.wpm} + ${formatted.acc} + ${formatted.raw} + ${formatted.con} + ${format( + entry.timestamp, + "dd MMM yyyy" + )}
${format(entry.timestamp, "HH:mm")}
+ + `; +} + +function buildWeeklyTableRow(entry: XpLeaderboardEntry, me = false): string { + let avatar = `
`; + + if (entry.discordAvatar !== undefined) { + avatar = `
`; + } + + const meClass = me ? "me" : ""; + + const activeDiff = formatDistanceToNow(entry.lastActivityTimestamp, { + addSuffix: true, + }); + + return ` + + ${ + entry.rank === 1 ? '' : entry.rank + } + +
+
${avatar}
+ ${entry.name} +
+ ${getHtmlByUserFlags(entry)} + ${entry.badgeId ? getBadgeHTMLbyId(entry.badgeId) : ""} +
+
+ + ${ + entry.totalXp < 1000 ? entry.totalXp : abbreviateNumber(entry.totalXp) + } + ${DateTime.secondsToString( + Math.round(entry.timeTypedSeconds), + true, + true, + ":" + )} + + ${entry.totalXp < 1000 ? entry.totalXp : abbreviateNumber(entry.totalXp)} +
${DateTime.secondsToString( + Math.round(entry.timeTypedSeconds), + true, + true, + ":" + )} + + + ${format(entry.lastActivityTimestamp, "dd MMM yyyy")} +
+ ${format(entry.lastActivityTimestamp, "HH:mm")} +
+ + + `; +} + +function fillTable(): void { + const table = $(".page.pageLeaderboards table tbody"); + table.empty(); + + $(".page.pageLeaderboards table thead").addClass("hidden"); + if (state.type === "allTime" || state.type === "daily") { + $(".page.pageLeaderboards table thead.allTimeAndDaily").removeClass( + "hidden" + ); + } else if (state.type === "weekly") { + $(".page.pageLeaderboards table thead.weekly").removeClass("hidden"); + } + + if (state.data === null || state.data.length === 0) { + table.append(`No data`); + $(".page.pageLeaderboards table").removeClass("hidden"); + return; + } + + if (state.type === "allTime" || state.type === "daily") { + for (const entry of state.data) { + const me = Auth?.currentUser?.uid === entry.uid; + table.append(buildTableRow(entry, me)); + } + } else if (state.type === "weekly") { + for (const entry of state.data) { + const me = Auth?.currentUser?.uid === entry.uid; + table.append(buildWeeklyTableRow(entry, me)); + } + } + + $(".page.pageLeaderboards table").removeClass("hidden"); +} + +function getLbMemoryDifference(): number | null { + if (state.type !== "allTime") return null; + if (state.userData === null) return null; + + const memory = + DB.getSnapshot()?.lbMemory?.["time"]?.[state.mode2]?.["english"] ?? 0; + + const rank = state.userData.rank; + const diff = memory - rank; + + if (diff !== 0) { + void DB.updateLbMemory("time", state.mode2, "english", rank, true); + } + + return diff; +} + +function fillUser(): void { + if (isAuthenticated() && DB.getSnapshot()?.lbOptOut === true) { + $(".page.pageLeaderboards .bigUser").html( + '
You have opted out of the leaderboards.
' + ); + return; + } + + if (isAuthenticated() && DB.getSnapshot()?.banned === true) { + $(".page.pageLeaderboards .bigUser").html( + '
Your account is banned
' + ); + return; + } + + if ( + isAuthenticated() && + !isDevEnvironment() && + (DB.getSnapshot()?.typingStats?.timeTyping ?? 0) < 72000 + ) { + $(".page.pageLeaderboards .bigUser").html( + '
Your account must have 2 hours typed to be placed on the leaderboard.
' + ); + return; + } + + if (isAuthenticated() && state.type === "daily" && state.userData === null) { + $(".page.pageLeaderboards .bigUser").html( + `
Not qualified (min speed required: ${state.minWpm} wpm)
` + ); + return; + } + + if (isAuthenticated() && state.userData === null) { + $(".page.pageLeaderboards .bigUser").html( + `
Not qualified
` + ); + return; + } + + if (state.data === null) { + return; + } + + if ( + (state.type === "weekly" && state.lastWeek) || + (state.type === "daily" && state.yesterday) + ) { + $(".page.pageLeaderboards .bigUser").addClass("hidden"); + $(".page.pageLeaderboards .tableAndUser > .divider").removeClass("hidden"); + return; + } + + if (state.type === "allTime" || state.type === "daily") { + if (!state.userData || !state.count) { + $(".page.pageLeaderboards .bigUser").addClass("hidden"); + $(".page.pageLeaderboards .tableAndUser > .divider").removeClass( + "hidden" + ); + return; + } + + const userData = state.userData; + const percentile = (userData.rank / state.count) * 100; + let percentileString = `Top ${percentile.toFixed(2)}%`; + if (userData.rank === 1) { + percentileString = "GOAT"; + } + + const diff = getLbMemoryDifference(); + let diffText; + + if (diff === null) { + diffText = ""; + } else if (diff === 0) { + diffText = ` ( = since you last checked)`; + } else if (diff > 0) { + diffText = ` (${Math.abs( + diff + )} since you last checked + )`; + } else { + diffText = ` (${Math.abs( + diff + )} since you last checked + )`; + } + + const formatted = { + wpm: Format.typingSpeed(userData.wpm, { showDecimalPlaces: true }), + acc: Format.percentage(userData.acc, { showDecimalPlaces: true }), + raw: Format.typingSpeed(userData.raw, { showDecimalPlaces: true }), + con: Format.percentage(userData.consistency, { showDecimalPlaces: true }), + }; + + const html = ` +
${ + userData.rank === 1 + ? '' + : userData.rank + }
+
+
You (${percentileString})
+
${diffText}
+
+
+
wpm
+
${formatted.wpm}
+
+
+
accuracy
+
${formatted.acc}
+
+
+
raw
+
${formatted.raw}
+
+
+
consistency
+
${formatted.con}
+
+
+
date
+
${format( + userData.timestamp, + "dd MMM yyyy HH:mm" + )}
+
+ + +
+
${formatted.wpm}
+
${formatted.acc}
+
+
+
${formatted.raw}
+
${formatted.con}
+
+
+
${format(userData.timestamp, "dd MMM yyyy")}
+
${format(userData.timestamp, "HH:mm")}
+
+ `; + + $(".page.pageLeaderboards .bigUser").html(html); + } else if (state.type === "weekly") { + if (!state.userData || !state.count) { + $(".page.pageLeaderboards .bigUser").addClass("hidden"); + return; + } + + const userData = state.userData; + const percentile = (userData.rank / state.count) * 100; + let percentileString = `Top ${percentile.toFixed(2)}%`; + if (userData.rank === 1) { + percentileString = "GOAT"; + } + + const diff = getLbMemoryDifference(); + let diffText; + + if (diff === null) { + diffText = ""; + } else if (diff === 0) { + diffText = ` ( = since you last checked)`; + } else if (diff > 0) { + diffText = ` (${Math.abs( + diff + )} since you last checked + )`; + } else { + diffText = ` (${Math.abs( + diff + )} since you last checked + )`; + } + + const formatted = { + xp: + userData.totalXp < 1000 + ? userData.totalXp + : abbreviateNumber(userData.totalXp), + time: DateTime.secondsToString( + Math.round(userData.timeTypedSeconds), + true, + true, + ":" + ), + }; + + const html = ` +
${ + userData.rank === 1 + ? '' + : userData.rank + }
+
+
You (${percentileString})
+
${diffText}
+
+
+
xp gained
+
${formatted.xp}
+
+
+
time typed
+
${formatted.time}
+
+
+
${formatted.xp}
+
${formatted.time}
+
+
+
date
+
${format( + userData.lastActivityTimestamp, + "dd MMM yyyy HH:mm" + )}
+
+
+
${format(userData.lastActivityTimestamp, "dd MMM yyyy")}
+
${format( + userData.lastActivityTimestamp, + "HH:mm" + )}
+
+ `; + + $(".page.pageLeaderboards .bigUser").html(html); + } + $(".page.pageLeaderboards .bigUser").removeClass("hidden"); + $(".page.pageLeaderboards .tableAndUser > .divider").addClass("hidden"); +} + +function updateContent(): void { + $(".page.pageLeaderboards .loading").addClass("hidden"); + $(".page.pageLeaderboards .updating").addClass("hidden"); + $(".page.pageLeaderboards .error").addClass("hidden"); + + if (state.error !== undefined) { + $(".page.pageLeaderboards .error").removeClass("hidden"); + $(".page.pageLeaderboards .error p").text(state.error); + enableButtons(); + return; + } + + if (state.updating) { + disableButtons(); + $(".page.pageLeaderboards .updating").removeClass("hidden"); + return; + } else if (state.loading) { + disableButtons(); + $(".page.pageLeaderboards .bigUser").addClass("hidden"); + $(".page.pageLeaderboards .titleAndButtons").addClass("hidden"); + $(".page.pageLeaderboards .loading").removeClass("hidden"); + $(".page.pageLeaderboards table").addClass("hidden"); + return; + } else { + enableButtons(); + } + + if (isAuthenticated()) { + $(".page.pageLeaderboards .needAuth").removeClass("hidden"); + } else { + $(".page.pageLeaderboards .needAuth").addClass("hidden"); + } + + if (state.data === null) { + Notifications.add("Data is null"); + return; + } + + $(".page.pageLeaderboards .titleAndButtons").removeClass("hidden"); + updateJumpButtons(); + updateTimerVisibility(); + fillTable(); +} + +function updateTypeButtons(): void { + const el = $(".page.pageLeaderboards .buttonGroup.typeButtons"); + el.find("button").removeClass("active"); + el.find(`button[data-type=${state.type}]`).addClass("active"); +} + +function updateSecondaryButtons(): void { + $(".page.pageLeaderboards .buttonGroup.secondary").addClass("hidden"); + $(".page.pageLeaderboards .buttons .divider").addClass("hidden"); + $(".page.pageLeaderboards .buttons .divider2").addClass("hidden"); + + if (state.type === "allTime") { + $(".page.pageLeaderboards .buttonGroup.modeButtons").removeClass("hidden"); + $(".page.pageLeaderboards .buttons .divider").removeClass("hidden"); + $(".page.pageLeaderboards .buttons .divider2").addClass("hidden"); + + updateModeButtons(); + } + if (state.type === "daily") { + $(".page.pageLeaderboards .buttonGroup.modeButtons").removeClass("hidden"); + $(".page.pageLeaderboards .buttonGroup.languageButtons").removeClass( + "hidden" + ); + $(".page.pageLeaderboards .buttons .divider").removeClass("hidden"); + $(".page.pageLeaderboards .buttons .divider2").removeClass("hidden"); + + updateModeButtons(); + updateLanguageButtons(); + } +} + +let updateTimer: number | undefined; + +function updateTimerElement(): void { + if (state.type === "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); + + $(".page.pageLeaderboards .titleAndButtons .timer").text( + "Next reset in: " + DateTime.secondsToString(diff, true) + ); + } else if (state.type === "allTime") { + const date = new Date(); + const minutesToNextUpdate = 14 - (date.getMinutes() % 15); + const secondsToNextUpdate = 60 - date.getSeconds(); + const totalSeconds = minutesToNextUpdate * 60 + secondsToNextUpdate; + $(".page.pageLeaderboards .titleAndButtons .timer").text( + "Next update in: " + DateTime.secondsToString(totalSeconds, true) + ); + } else if (state.type === "weekly") { + const nextWeekTimestamp = + getCurrentWeekTimestamp() + 7 * 24 * 60 * 60 * 1000; + const currentTime = new Date().getTime(); + const totalSeconds = Math.floor((nextWeekTimestamp - currentTime) / 1000); + $(".page.pageLeaderboards .titleAndButtons .timer").text( + "Next reset in: " + + DateTime.secondsToString(totalSeconds, true, true, ":", true, true) + ); + } +} + +function updateTimerVisibility(): void { + let visible = true; + + if ( + (state.type === "daily" && state.yesterday) || + (state.type === "weekly" && state.lastWeek) + ) { + visible = false; + } + + if (visible) { + $(".page.pageLeaderboards .titleAndButtons .timer").removeClass( + "invisible" + ); + } else { + $(".page.pageLeaderboards .titleAndButtons .timer").addClass("invisible"); + } +} + +function startTimer(): void { + updateTimerElement(); + updateTimer = setInterval(() => { + updateTimerElement(); + }, 1000) as unknown as number; +} + +function stopTimer(): void { + clearInterval(updateTimer); + updateTimer = undefined; + $(".page.pageLeaderboards .titleAndButtons .timer").text("-"); +} + +// async function appendLanguageButtons(): Promise { +// const languages = +// (await ServerConfiguration.get()?.dailyLeaderboards.validModeRules.map( +// (r) => r.language +// )) ?? []; + +// const el = $(".page.pageLeaderboards .buttonGroup.languageButtons"); +// el.empty(); + +// for (const language of languages) { +// el.append(` +// +// `); +// } +// } + +function updateModeButtons(): void { + if (state.type !== "allTime" && state.type !== "daily") return; + const el = $(".page.pageLeaderboards .buttonGroup.modeButtons"); + el.find("button").removeClass("active"); + el.find(`button[data-mode=${state.mode2}]`).addClass("active"); +} + +function updateLanguageButtons(): void { + if (state.type !== "daily") return; + const el = $(".page.pageLeaderboards .buttonGroup.languageButtons"); + el.find("button").removeClass("active"); + el.find(`button[data-language=${state.language}]`).addClass("active"); +} + +function disableButtons(): void { + $(".page.pageLeaderboards button").prop("disabled", true); +} + +function enableButtons(): void { + $(".page.pageLeaderboards button").prop("disabled", false); +} + +export function goToPage(pageId: number): void { + if (pageId < 0 || pageId === state.page) return; + handleJumpButton("goToPage", pageId); +} + +function handleJumpButton(action: string, page?: number): void { + if (action === "firstPage") { + state.page = 0; + } else if (action === "previousPage" && state.page > 0) { + const totalPages = Math.ceil(state.count / state.pageSize); + if (state.page > totalPages) { + state.page = totalPages - 1; + } else { + state.page -= 1; + } + } else if (action === "nextPage") { + state.page += 1; + } else if (action === "goToPage" && page !== undefined) { + state.page = page; + } else if (action === "userPage") { + if (isAuthenticated()) { + const user = Auth?.currentUser; + if (user) { + const rank = state.userData?.rank; + if (rank) { + const page = Math.floor(rank / state.pageSize); + + if (state.page === page) { + return; + } + + state.page = page; + } + } + } + } else { + return; + } + updateGetParameters(); + void requestData(true); + updateContent(); +} + +function handleYesterdayLastWeekButton(action: string): void { + if (state.type === "daily" && action === "toggleYesterday") { + state.yesterday = !state.yesterday; + } else if (state.type === "weekly" && action === "toggleLastWeek") { + state.lastWeek = !state.lastWeek; + } + + updateGetParameters(); + void requestData(); + updateContent(); + updateTitle(); +} + +function updateGetParameters(): void { + const params = new URLSearchParams(); + + params.set("type", state.type); + if (state.type === "allTime") { + params.set("mode2", state.mode2); + } else if (state.type === "daily") { + params.set("language", state.language); + params.set("mode2", state.mode2); + if (state.yesterday) { + params.set("yesterday", "true"); + } else { + params.delete("yesterday"); + } + } else if (state.type === "weekly") { + if (state.lastWeek) { + params.set("lastWeek", "true"); + } else { + params.delete("lastWeek"); + } + } + + params.set("page", (state.page + 1).toString()); + + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); +} + +function readGetParameters(): void { + const params = new URLSearchParams(window.location.search); + + const type = params.get("type") as "allTime" | "weekly" | "daily"; + if (type) { + state.type = type; + } + + if (state.type === "allTime") { + const mode = params.get("mode2") as "15" | "60"; + if (mode) { + state.mode2 = mode; + } + } else if (state.type === "daily") { + const language = params.get("language"); + const dailyMode = params.get("mode2") as "15" | "60"; + const yesterday = params.get("yesterday") as string; + if (language !== null) { + state.language = language; + } + if (dailyMode) { + state.mode2 = dailyMode; + } + if (yesterday !== null && yesterday === "true") { + state.yesterday = true; + } + } else if (state.type === "weekly") { + const lastWeek = params.get("lastWeek") as string; + if (lastWeek !== null && lastWeek === "true") { + state.lastWeek = true; + } + } + + const page = params.get("page"); + if (page !== null) { + state.page = parseInt(page, 10) - 1; + + if (state.page < 0) { + state.page = 0; + } + } +} + +$(".page.pageLeaderboards .jumpButtons button").on("click", function () { + const action = $(this).data("action") as string; + if (action !== "goToPage") { + handleJumpButton(action); + } +}); + +$(".page.pageLeaderboards .bigtitle button").on("click", function () { + const action = $(this).data("action") as string; + handleYesterdayLastWeekButton(action); +}); + +$(".page.pageLeaderboards .buttonGroup.typeButtons").on( + "click", + "button", + function () { + const type = $(this).data("type") as "allTime" | "weekly" | "daily"; + if (state.type === type) return; + state.type = type; + if (state.type === "daily") { + state.language = "english"; + state.yesterday = false; + } + if (state.type === "weekly") { + state.lastWeek = false; + } + state.data = null; + state.page = 0; + void requestData(); + updateTypeButtons(); + updateTitle(); + updateSecondaryButtons(); + updateContent(); + updateGetParameters(); + } +); + +$(".page.pageLeaderboards .buttonGroup.secondary").on( + "click", + "button", + function () { + const mode = $(this).data("mode") as "15" | "60"; + const language = $(this).data("language") as string; + if ( + mode !== undefined && + (state.type === "allTime" || state.type === "daily") + ) { + if (state.mode2 === mode) return; + state.mode2 = mode; + } else if (language !== undefined && state.type === "daily") { + if (state.language === language) return; + state.language = language; + } else { + return; + } + state.data = null; + void requestData(); + updateSecondaryButtons(); + updateTitle(); + updateContent(); + updateGetParameters(); + } +); + +export const page = new Page({ + name: "leaderboards", + element: $(".page.pageLeaderboards"), + path: "/leaderboards", + afterHide: async (): Promise => { + Skeleton.remove("pageLeaderboards"); + stopTimer(); + }, + beforeShow: async (): Promise => { + Skeleton.append("pageLeaderboards", "main"); + // await appendLanguageButtons(); //todo figure out this race condition + readGetParameters(); + startTimer(); + updateTypeButtons(); + updateTitle(); + updateSecondaryButtons(); + updateContent(); + updateGetParameters(); + void requestData(); + }, + afterShow: async (): Promise => { + updateSecondaryButtons(); + state.discordAvatarUrls = new Map(); + }, +}); + +$(async () => { + Skeleton.save("pageLeaderboards"); +}); diff --git a/frontend/src/ts/pages/page.ts b/frontend/src/ts/pages/page.ts index 397f39088..b7e6b87a2 100644 --- a/frontend/src/ts/pages/page.ts +++ b/frontend/src/ts/pages/page.ts @@ -8,7 +8,8 @@ export type PageName = | "profile" | "profileSearch" | "404" - | "accountSettings"; + | "accountSettings" + | "leaderboards"; type Options = { params?: Record; diff --git a/frontend/src/ts/ui.ts b/frontend/src/ts/ui.ts index d2fc91a23..abe7bd53c 100644 --- a/frontend/src/ts/ui.ts +++ b/frontend/src/ts/ui.ts @@ -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"); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 779c5402a..595e48162 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts index 6c48460cc..7d7a000be 100644 --- a/packages/contracts/src/leaderboards.ts +++ b/packages/contracts/src/leaderboards.ts @@ -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; + 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; + 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 }, diff --git a/packages/contracts/src/schemas/leaderboards.ts b/packages/contracts/src/schemas/leaderboards.ts index 305b5b7b3..6af7500c0 100644 --- a/packages/contracts/src/schemas/leaderboards.ts +++ b/packages/contracts/src/schemas/leaderboards.ts @@ -16,16 +16,7 @@ export const LeaderboardEntrySchema = z.object({ }); export type LeaderboardEntry = z.infer; -export const LeaderboardRankSchema = z.object({ - count: z.number().int().nonnegative(), - rank: z.number().int().nonnegative().optional(), - entry: LeaderboardEntrySchema.optional(), -}); -export type LeaderboardRank = z.infer; - -export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({ - minWpm: z.number().nonnegative(), -}); +export const DailyLeaderboardRankSchema = LeaderboardEntrySchema; export type DailyLeaderboardRank = z.infer; 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; - -export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({ - count: z.number().int().nonnegative(), -}); -export type XpLeaderboardRank = z.infer; diff --git a/packages/util/src/date-and-time.ts b/packages/util/src/date-and-time.ts index 0dfa2d5a9..901b179bc 100644 --- a/packages/util/src/date-and-time.ts +++ b/packages/util/src/date-and-time.ts @@ -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); +}