From dcac53aa3b0fa2c9078ed87adaa11ffd40f9cf73 Mon Sep 17 00:00:00 2001 From: torturado <73028283+torturado@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:39:54 +0200 Subject: [PATCH 1/8] feat(leaderboard): add endpoints for retrieving next distinct WPM values - Implemented `getNextLeaderboardWpm` and `getNextDailyLeaderboardWpm` functions in the leaderboard controller. - Added corresponding routes for `/leaderboards/next` and `/leaderboards/daily/next`. - Updated DAL to fetch the next WPM based on user entries. - Enhanced tests to cover authentication and response scenarios for the new endpoints. - Introduced new pace caret options for "next" and "nextDaily" in the frontend configuration. --- .../api/controllers/leaderboard.spec.ts | 98 +++++++++++++++++++ backend/src/api/controllers/leaderboard.ts | 55 +++++++++++ backend/src/api/routes/leaderboards.ts | 8 ++ backend/src/dal/leaderboards.ts | 26 +++++ backend/src/utils/daily-leaderboards.ts | 33 +++++++ .../ts/commandline/commandline-metadata.ts | 17 +++- .../settings/custom-setting/PaceCaret.tsx | 19 ++-- .../test/modes-notice/TestModesNotice.tsx | 5 +- frontend/src/ts/config/metadata.tsx | 21 +++- frontend/src/ts/config/pace-caret-options.ts | 56 +++++++++++ frontend/src/ts/queries/leaderboards.ts | 37 +++++++ frontend/src/ts/test/pace-caret.ts | 51 +++++++++- packages/contracts/src/leaderboards.ts | 35 +++++++ packages/schemas/src/configs.ts | 2 + 14 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 frontend/src/ts/config/pace-caret-options.ts diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 1b48ca1a95ea..a0efd9b20548 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -493,6 +493,54 @@ describe("Loaderboard Controller", () => { }); }); + describe("get next leaderboard WPM", () => { + const getNextWpmMock = vi.spyOn(LeaderboardDal, "getNextWpm"); + + beforeEach(() => { + getNextWpmMock.mockReset(); + getNextWpmMock.mockResolvedValue(null); + }); + + it("requires authentication", async () => { + await mockApp + .get("/leaderboards/next") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(401); + }); + + it("returns the next distinct WPM", async () => { + getNextWpmMock.mockResolvedValue(105.25); + + const { body } = await mockApp + .get("/leaderboards/next") + .set("Authorization", `Bearer ${uid}`) + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + expect(body.data).toBe(105.25); + expect(getNextWpmMock).toHaveBeenCalledWith("time", "60", "english", uid); + }); + + it("returns null for unsupported modes and missing entries", async () => { + const unsupported = await mockApp + .get("/leaderboards/next") + .set("Authorization", `Bearer ${uid}`) + .query({ language: "spanish", mode: "time", mode2: "60" }) + .expect(200); + + expect(unsupported.body.data).toBeNull(); + expect(getNextWpmMock).not.toHaveBeenCalled(); + + const missing = await mockApp + .get("/leaderboards/next") + .set("Authorization", `Bearer ${uid}`) + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + expect(missing.body.data).toBeNull(); + }); + }); + describe("get daily leaderboard", () => { const getDailyLeaderboardMock = vi.spyOn( DailyLeaderboards, @@ -1054,6 +1102,56 @@ describe("Loaderboard Controller", () => { }); }); + describe("get next daily leaderboard WPM", () => { + const getDailyLeaderboardMock = vi.spyOn( + DailyLeaderboards, + "getDailyLeaderboard", + ); + const getNextWpmMock = vi.fn(); + + beforeEach(async () => { + getDailyLeaderboardMock.mockReset(); + getNextWpmMock.mockReset(); + getNextWpmMock.mockResolvedValue(null); + getDailyLeaderboardMock.mockReturnValue({ + getNextWpm: getNextWpmMock, + } as any); + await dailyLeaderboardEnabled(true); + }); + + it("requires authentication", async () => { + await mockApp + .get("/leaderboards/daily/next") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(401); + }); + + it("returns the next daily WPM or null", async () => { + getNextWpmMock.mockResolvedValue(99.5); + + const { body } = await mockApp + .get("/leaderboards/daily/next") + .set("Authorization", `Bearer ${uid}`) + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + expect(body.data).toBe(99.5); + expect(getNextWpmMock).toHaveBeenCalledWith( + uid, + (await configuration).dailyLeaderboards, + ); + + getDailyLeaderboardMock.mockReturnValue(null); + const unavailable = await mockApp + .get("/leaderboards/daily/next") + .set("Authorization", `Bearer ${uid}`) + .query({ language: "spanish", mode: "time", mode2: "60" }) + .expect(200); + + expect(unavailable.body.data).toBeNull(); + }); + }); + describe("get xp weekly leaderboard", () => { const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); const getResultMock = vi.fn(); diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 899dbd3e1555..70c84a0b89ca 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -14,6 +14,7 @@ import { GetLeaderboardRankQuery, GetLeaderboardRankResponse, GetLeaderboardResponse, + NextLeaderboardWpmResponse, GetWeeklyXpLeaderboardQuery, GetWeeklyXpLeaderboardRankQuery, GetWeeklyXpLeaderboardRankResponse, @@ -105,6 +106,35 @@ export async function getRankFromLeaderboard( return new MonkeyResponse("Rank retrieved", omit(data, ["_id"])); } +export async function getNextLeaderboardWpm( + req: MonkeyRequest, +): Promise { + const { language, mode, mode2 } = req.query; + let nextWpm: number | null | false = null; + + if ( + language === "english" && + mode === "time" && + ["15", "60"].includes(mode2) + ) { + try { + nextWpm = await LeaderboardsDAL.getNextWpm( + mode, + mode2, + language, + req.ctx.decodedToken.uid, + ); + } catch { + nextWpm = null; + } + } + + return new MonkeyResponse( + "Next leaderboard WPM retrieved", + nextWpm === false ? null : nextWpm, + ); +} + function getDailyLeaderboardWithError( { language, mode, mode2, daysBefore }: DailyLeaderboardQuery, config: Configuration["dailyLeaderboards"], @@ -189,6 +219,31 @@ export async function getDailyLeaderboardRank( return new MonkeyResponse("Daily leaderboard rank retrieved", rank); } +export async function getNextDailyLeaderboardWpm( + req: MonkeyRequest, +): Promise { + const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard( + req.query.language, + req.query.mode, + req.query.mode2, + req.ctx.configuration.dailyLeaderboards, + ); + + if (dailyLeaderboard === null) { + return new MonkeyResponse("Next daily leaderboard WPM retrieved", null); + } + + try { + const nextWpm = await dailyLeaderboard.getNextWpm( + req.ctx.decodedToken.uid, + req.ctx.configuration.dailyLeaderboards, + ); + return new MonkeyResponse("Next daily leaderboard WPM retrieved", nextWpm); + } catch { + return new MonkeyResponse("Next daily leaderboard WPM retrieved", null); + } +} + function getWeeklyXpLeaderboardWithError( config: Configuration["leaderboards"]["weeklyXp"], weeksBefore?: number, diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index a9da7a143df4..0b526544f689 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -13,6 +13,10 @@ export default s.router(leaderboardsContract, { handler: async (r) => callController(LeaderboardController.getRankFromLeaderboard)(r), }, + getNext: { + handler: async (r) => + callController(LeaderboardController.getNextLeaderboardWpm)(r), + }, getDaily: { handler: async (r) => callController(LeaderboardController.getDailyLeaderboard)(r), @@ -21,6 +25,10 @@ export default s.router(leaderboardsContract, { handler: async (r) => callController(LeaderboardController.getDailyLeaderboardRank)(r), }, + getDailyNext: { + handler: async (r) => + callController(LeaderboardController.getNextDailyLeaderboardWpm)(r), + }, getWeeklyXp: { handler: async (r) => callController(LeaderboardController.getWeeklyXpLeaderboard)(r), diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 2bc20a4329ce..46bdca7c96fb 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -172,6 +172,32 @@ export async function getRank( } } +export async function getNextWpm( + mode: string, + mode2: string, + language: string, + uid: string, +): Promise { + try { + const currentEntry = await getRank(mode, mode2, language, uid); + if (currentEntry === false) return false; + if (currentEntry === null) return null; + + const nextEntry = await getCollection({ language, mode, mode2 }).findOne( + { wpm: { $gt: currentEntry.wpm } }, + { sort: { wpm: 1 }, projection: { wpm: 1 } }, + ); + + return nextEntry?.wpm ?? null; + } catch (e) { + // oxlint-disable-next-line no-unsafe-member-access + if (e.error === 175) { + return false; + } + throw e; + } +} + export async function update( mode: string, mode2: string, diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index e038616e81fb..a7e020a30ded 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -239,6 +239,39 @@ export class DailyLeaderboard { ); } } + + public async getNextWpm( + uid: string, + dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], + ): Promise { + const connection = RedisClient.getConnection(); + if (!connection || !dailyLeaderboardsConfig.enabled) { + return null; + } + + const currentEntry = await this.getRank(uid, dailyLeaderboardsConfig); + if (currentEntry === null || currentEntry.rank <= 1) { + return null; + } + + const results = await this.getResults( + 0, + currentEntry.rank - 1, + dailyLeaderboardsConfig, + true, + ); + let nextWpm: number | null = null; + for (const entry of results?.entries ?? []) { + if ( + entry.wpm > currentEntry.wpm && + (nextWpm === null || entry.wpm < nextWpm) + ) { + nextWpm = entry.wpm; + } + } + + return nextWpm; + } } export async function purgeUserFromDailyLeaderboards( diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts index 5c8af2d95cfa..968b0b6e99ee 100644 --- a/frontend/src/ts/commandline/commandline-metadata.ts +++ b/frontend/src/ts/commandline/commandline-metadata.ts @@ -15,6 +15,10 @@ import { KnownFontName } from "@monkeytype/schemas/fonts"; import * as UI from "../ui"; import { Validation } from "../types/validation"; import { typedKeys } from "@monkeytype/util/objects"; +import { + getPaceCaretContext, + isPaceCaretModeAvailable, +} from "../config/pace-caret-options"; //TODO: remove display property and instead use optionsMetadata from configMetadata // eventually this file should be fully merged into config metadata, probably under the 'commandline' property @@ -482,7 +486,18 @@ export const commandlineConfigMetadata: CommandlineConfigMetadataObject = { paceCaret: { display: "Pace caret mode...", subgroup: { - options: ["off", "pb", "tagPb", "last", "average", "daily"], + options: [ + "off", + "pb", + "tagPb", + "last", + "average", + "daily", + "next", + "nextDaily", + ], + isAvailable: (value) => () => + isPaceCaretModeAvailable(value, getPaceCaretContext()), afterExec: () => { TestLogic.restart(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx index 124af98666bc..781c8a7cc81a 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx @@ -7,8 +7,13 @@ import { For, JSXElement } from "solid-js"; import { configMetadata, + getOptionLabel, getOptionSearchKeywords, } from "../../../../config/metadata"; +import { + getPaceCaretContext, + isPaceCaretModeAvailable, +} from "../../../../config/pace-caret-options"; import { setConfig } from "../../../../config/setters"; import { getConfig } from "../../../../config/store"; import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; @@ -77,15 +82,13 @@ export function PaceCaret(): JSXElement { />
- + + isPaceCaretModeAvailable(option, getPaceCaretContext()), + )} + > {(option) => { - const optionMeta = configMetadata.paceCaret - .optionsMetadata as Record< - string, - { displayString?: string } - >; - const displayString = - optionMeta?.[String(option)]?.displayString ?? String(option); + const displayString = getOptionLabel("paceCaret", option); return (