diff --git a/backend/__tests__/__integration__/dal/connections.spec.ts b/backend/__tests__/__integration__/dal/connections.spec.ts index 709c987b2fee..7aa62d99cb47 100644 --- a/backend/__tests__/__integration__/dal/connections.spec.ts +++ b/backend/__tests__/__integration__/dal/connections.spec.ts @@ -362,4 +362,43 @@ describe("ConnectionsDal", () => { ).toEqual([decoy]); }); }); + + describe("getFriendsUids", () => { + it("should return friend uids", async () => { + //GIVE + const uid = new ObjectId().toHexString(); + const friendOne = await createConnection({ + initiatorUid: uid, + status: "accepted", + }); + const friendTwo = await createConnection({ + receiverUid: uid, + status: "accepted", + }); + const friendThree = await createConnection({ + receiverUid: uid, + status: "accepted", + }); + const _pending = await createConnection({ + initiatorUid: uid, + status: "pending", + }); + const _blocked = await createConnection({ + initiatorUid: uid, + status: "blocked", + }); + const _decoy = await createConnection({}); + + //WHEN + const friendUids = await ConnectionsDal.getFriendsUids(uid); + + //THEN + expect(friendUids).toEqual([ + uid, + friendOne.receiverUid, + friendTwo.initiatorUid, + friendThree.initiatorUid, + ]); + }); + }); }); diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index afe64f556787..63a75af779e9 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -32,12 +32,12 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "15", "english"); - const result = await LeaderboardsDal.get("time", "15", "english", 0, 50); + const results = await LeaderboardsDal.get("time", "15", "english", 0, 50); //THEN - expect(result).toHaveLength(1); + expect(results).toHaveLength(1); expect( - (result as LeaderboardsDal.DBLeaderboardEntry[])[0] + (results as LeaderboardsDal.DBLeaderboardEntry[])[0] ).toHaveProperty("uid", applicableUser.uid); }); @@ -50,7 +50,7 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "15", "english"); - const result = (await LeaderboardsDal.get( + const results = (await LeaderboardsDal.get( "time", "15", "english", @@ -59,7 +59,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => _.omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: rank1 }), @@ -77,7 +77,7 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "60", "english"); - const result = (await LeaderboardsDal.get( + const results = (await LeaderboardsDal.get( "time", "60", "english", @@ -86,7 +86,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => _.omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1 }), @@ -262,7 +262,7 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "15", "english"); - const result = (await LeaderboardsDal.get( + const results = (await LeaderboardsDal.get( "time", "15", "english", @@ -272,14 +272,169 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - expect(result[0]?.isPremium).toBeUndefined(); + expect(results[0]?.isPremium).toBeUndefined(); + }); + }); + + describe("get", () => { + it("should get for page", async () => { + //GIVEN + const _rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const rank3 = await createUser(lbBests(undefined, pb(95, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2, + true + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = results.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 3, user: rank3 }), + expectedLbEntry("60", { rank: 4, user: rank4 }), + ]); + }); + it("should get for friends only", async () => { + //GIVEN + const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2))); + const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const _rank3 = await createUser(lbBests(undefined, pb(100, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 0, + 50, + false, + [rank1.uid, rank4.uid] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = results.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 1, user: rank1, friendsRank: 1 }), + expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 2 }), + ]); + }); + it("should get for friends only with page", async () => { + //GIVEN + const rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const _rank3 = await createUser(lbBests(undefined, pb(95, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2, + false, + [rank1.uid, rank2.uid, rank4.uid] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = results.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }), + ]); + }); + it("should return empty list if no friends", async () => { + //GIVEN + + //WHEN + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2, + false, + [] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + //THEN + expect(results).toEqual([]); + }); + }); + describe("getCount / getRank", () => { + it("should get count", async () => { + //GIVEN + await createUser(lbBests(undefined, pb(105))); + await createUser(lbBests(undefined, pb(100))); + const me = await createUser(lbBests(undefined, pb(95))); + await createUser(lbBests(undefined, pb(90))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN / THEN + + expect(await LeaderboardsDal.getCount("time", "60", "english")) // + .toEqual(4); + expect(await LeaderboardsDal.getRank("time", "60", "english", me.uid)) // + .toEqual( + expect.objectContaining({ + wpm: 95, + rank: 3, + name: me.name, + uid: me.uid, + }) + ); + }); + it("should get for friends only", async () => { + //GIVEN + const friendOne = await createUser(lbBests(undefined, pb(105))); + await createUser(lbBests(undefined, pb(100))); + await createUser(lbBests(undefined, pb(95))); + const friendTwo = await createUser(lbBests(undefined, pb(90))); + const me = await createUser(lbBests(undefined, pb(99))); + + console.log("me", me.uid); + + await LeaderboardsDal.update("time", "60", "english"); + + const friends = [friendOne.uid, friendTwo.uid, me.uid]; + + //WHEN / THEN + + expect(await LeaderboardsDal.getCount("time", "60", "english", friends)) // + .toEqual(3); + expect( + await LeaderboardsDal.getRank("time", "60", "english", me.uid, friends) + ) // + .toEqual( + expect.objectContaining({ + wpm: 99, + rank: 3, + friendsRank: 2, + name: me.name, + uid: me.uid, + }) + ); }); }); }); function expectedLbEntry( time: string, - { rank, user, badgeId, isPremium }: ExpectedLbEntry + { rank, user, badgeId, isPremium, friendsRank }: ExpectedLbEntry ) { // @ts-expect-error const lbBest: PersonalBest = @@ -299,6 +454,7 @@ function expectedLbEntry( discordAvatar: user.discordAvatar, badgeId, isPremium, + friendsRank, }; } @@ -351,4 +507,5 @@ type ExpectedLbEntry = { user: UserDal.DBUser; badgeId?: number; isPremium?: boolean; + friendsRank?: number; }; diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index fb42f52b73c5..e80f0de60bcd 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -3,6 +3,7 @@ import { setup } from "../../__testData__/controller-test"; import _ from "lodash"; import { ObjectId } from "mongodb"; import * as LeaderboardDal from "../../../src/dal/leaderboards"; +import * as ConnectionsDal from "../../../src/dal/connections"; import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; import * as Configuration from "../../../src/init/configuration"; @@ -29,10 +30,13 @@ describe("Loaderboard Controller", () => { describe("get leaderboard", () => { const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get"); const getLeaderboardCountMock = vi.spyOn(LeaderboardDal, "getCount"); + const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); beforeEach(() => { getLeaderboardMock.mockClear(); getLeaderboardCountMock.mockClear(); + getFriendsUidsMock.mockClear(); + getLeaderboardCountMock.mockResolvedValue(42); }); it("should get for english time 60", async () => { @@ -93,7 +97,15 @@ describe("Loaderboard Controller", () => { "english", 0, 50, - false + false, + undefined + ); + + expect(getLeaderboardCountMock).toHaveBeenCalledWith( + "time", + "60", + "english", + undefined ); }); @@ -133,7 +145,48 @@ describe("Loaderboard Controller", () => { "english", page, pageSize, - false + false, + undefined + ); + }); + + it("should get for friendsOnly", async () => { + //GIVEN + await enableConnectionsFeature(true); + getLeaderboardMock.mockResolvedValue([]); + getFriendsUidsMock.mockResolvedValue(["uidOne", "uidTwo"]); + getLeaderboardCountMock.mockResolvedValue(2); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .set("Authorization", `Bearer ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + expect(body.data.count).toEqual(2); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + 0, + 50, + false, + ["uidOne", "uidTwo"] + ); + expect(getLeaderboardCountMock).toHaveBeenCalledWith( + "time", + "60", + "english", + ["uidOne", "uidTwo"] ); }); @@ -233,9 +286,11 @@ describe("Loaderboard Controller", () => { describe("get rank", () => { const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank"); + const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); afterEach(() => { getLeaderboardRankMock.mockClear(); + getFriendsUidsMock.mockClear(); }); it("fails withouth authentication", async () => { @@ -250,7 +305,7 @@ describe("Loaderboard Controller", () => { const entryId = new ObjectId(); const resultEntry = { - _id: entryId.toHexString(), + _id: entryId, wpm: 10, acc: 80, timestamp: 1200, @@ -272,15 +327,45 @@ describe("Loaderboard Controller", () => { //THEN expect(body).toEqual({ message: "Rank retrieved", - data: resultEntry, + data: { ...resultEntry, _id: undefined }, }); expect(getLeaderboardRankMock).toHaveBeenCalledWith( "time", "60", "english", - uid + uid, + undefined + ); + }); + it("should get for english time 60 friends only", async () => { + //GIVEN + await enableConnectionsFeature(true); + const friends = ["friendOne", "friendTwo"]; + getFriendsUidsMock.mockResolvedValue(friends); + getLeaderboardRankMock.mockResolvedValue({} as any); + + //WHEN + await mockApp + .get("/leaderboards/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getLeaderboardRankMock).toHaveBeenCalledWith( + "time", + "60", + "english", + uid, + friends ); + expect(getFriendsUidsMock).toHaveBeenCalledWith(uid); }); it("should get with ape key", async () => { await acceptApeKeys(true); @@ -1244,3 +1329,13 @@ async function weeklyLeaderboardEnabled(enabled: boolean): Promise { mockConfig ); } + +async function enableConnectionsFeature(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + connections: { enabled: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 689a2341e6c4..201da3def097 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; +import * as ConnectionsDal from "../../dal/connections"; import MonkeyError from "../../utils/error"; import * as DailyLeaderboards from "../../utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; @@ -30,7 +31,9 @@ import { MonkeyRequest } from "../types"; export async function getLeaderboard( req: MonkeyRequest ): Promise { - const { language, mode, mode2, page, pageSize } = req.query; + const { language, mode, mode2, page, pageSize, friendsOnly } = req.query; + const { uid } = req.ctx.decodedToken; + const connectionsConfig = req.ctx.configuration.connections; if ( mode !== "time" || @@ -40,13 +43,20 @@ export async function getLeaderboard( throw new MonkeyError(404, "There is no leaderboard for this mode"); } + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + connectionsConfig + ); + const leaderboard = await LeaderboardsDAL.get( mode, mode2, language, page, pageSize, - req.ctx.configuration.users.premium.enabled + req.ctx.configuration.users.premium.enabled, + friendUids ); if (leaderboard === false) { @@ -56,7 +66,12 @@ export async function getLeaderboard( ); } - const count = await LeaderboardsDAL.getCount(mode, mode2, language); + const count = await LeaderboardsDAL.getCount( + mode, + mode2, + language, + friendUids + ); const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); return new MonkeyResponse("Leaderboard retrieved", { @@ -69,10 +84,23 @@ export async function getLeaderboard( export async function getRankFromLeaderboard( req: MonkeyRequest ): Promise { - const { language, mode, mode2 } = req.query; + const { language, mode, mode2, friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const connectionsConfig = req.ctx.configuration.connections; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + connectionsConfig + ); - const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid); + const data = await LeaderboardsDAL.getRank( + mode, + mode2, + language, + uid, + friendUids + ); if (data === false) { throw new MonkeyError( 503, @@ -80,7 +108,7 @@ export async function getRankFromLeaderboard( ); } - return new MonkeyResponse("Rank retrieved", data); + return new MonkeyResponse("Rank retrieved", _.omit(data, "_id")); } function getDailyLeaderboardWithError( @@ -213,3 +241,17 @@ export async function getWeeklyXpLeaderboardRank( return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry); } + +async function getFriendsUids( + uid: string, + friendsOnly: boolean, + friendsConfig: Configuration["connections"] +): Promise { + if (uid !== "" && friendsOnly) { + if (!friendsConfig.enabled) { + throw new MonkeyError(503, "This feature is currently unavailable."); + } + return await ConnectionsDal.getFriendsUids(uid); + } + return undefined; +} diff --git a/backend/src/dal/connections.ts b/backend/src/dal/connections.ts index b6448aa46fc9..a2924bd2cae9 100644 --- a/backend/src/dal/connections.ts +++ b/backend/src/dal/connections.ts @@ -186,6 +186,29 @@ export async function deleteByUid(uid: string): Promise { }); } +/** + * Return uids of all accepted connections for the given uid including the uid. + * @param uid + * @returns + */ +export async function getFriendsUids(uid: string): Promise { + return Array.from( + new Set( + ( + await getCollection() + .find( + { + status: "accepted", + $or: [{ initiatorUid: uid }, { receiverUid: uid }], + }, + { projection: { initiatorUid: true, receiverUid: true } } + ) + .toArray() + ).flatMap((it) => [it.initiatorUid, it.receiverUid]) + ) + ); +} + function getKey(initiatorUid: string, receiverUid: string): string { const ids = [initiatorUid, receiverUid]; ids.sort(); diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 42594eb1baf1..8de1b68bb9bf 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -9,7 +9,7 @@ import { } from "../init/configuration"; import { addLog } from "./logs"; -import { Collection, ObjectId } from "mongodb"; +import { Collection, Document, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; @@ -34,28 +34,48 @@ export async function get( language: string, page: number, pageSize: number, - premiumFeaturesEnabled: boolean = false + premiumFeaturesEnabled: boolean = false, + userIds?: string[] ): Promise { if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } + if (userIds?.length === 0) { + return []; + } + const skip = page * pageSize; const limit = pageSize; + const pipeline: Document[] = [ + { $sort: { rank: 1 } }, + { $skip: skip }, + { $limit: limit }, + ]; + + if (userIds !== undefined) { + pipeline.unshift( + { $match: { uid: { $in: userIds } } }, + { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + } + ); + } + try { - const preset = await getCollection({ language, mode, mode2 }) - .find() - .sort({ rank: 1 }) - .skip(skip) - .limit(limit) - .toArray(); + let leaderboard = (await getCollection({ language, mode, mode2 }) + .aggregate(pipeline) + .toArray()) as DBLeaderboardEntry[]; if (!premiumFeaturesEnabled) { - return preset.map((it) => omit(it, "isPremium")); + leaderboard = leaderboard.map((it) => omit(it, "isPremium")); } - return preset; + return leaderboard; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.error === 175) { @@ -71,19 +91,25 @@ const cachedCounts = new Map(); export async function getCount( mode: string, mode2: string, - language: string + language: string, + userIds?: string[] ): Promise { const key = `${language}_${mode}_${mode2}`; - if (cachedCounts.has(key)) { + if (userIds === undefined && cachedCounts.has(key)) { return cachedCounts.get(key) as number; } else { - const count = await getCollection({ + const lb = getCollection({ language, mode, mode2, - }).estimatedDocumentCount(); - cachedCounts.set(key, count); - return count; + }); + if (userIds === undefined) { + const count = await lb.estimatedDocumentCount(); + cachedCounts.set(key, count); + return count; + } else { + return lb.countDocuments({ uid: { $in: userIds } }); + } } } @@ -91,14 +117,34 @@ export async function getRank( mode: string, mode2: string, language: string, - uid: string + uid: string, + userIds?: string[] ): Promise { try { - const entry = await getCollection({ language, mode, mode2 }).findOne({ - uid, - }); + if (userIds === undefined) { + const entry = await getCollection({ language, mode, mode2 }).findOne({ + uid, + }); - return entry; + return entry; + } else if (userIds.length === 0) { + return null; + } else { + const entry = await getCollection({ language, mode, mode2 }) + .aggregate([ + { $match: { uid: { $in: userIds } } }, + { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }, + { $match: { uid } }, + ]) + .toArray(); + + return entry[0] as DBLeaderboardEntry; + } } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.error === 175) { diff --git a/frontend/src/html/pages/leaderboards.html b/frontend/src/html/pages/leaderboards.html index 1041691f3d0d..161bdd499249 100644 --- a/frontend/src/html/pages/leaderboards.html +++ b/frontend/src/html/pages/leaderboards.html @@ -49,6 +49,7 @@ + @@ -538,6 +559,12 @@ function fillTable(): void { const table = $(".page.pageLeaderboards table tbody"); table.empty(); + if (state.friendsOnly) { + table.parent().addClass("friendsOnly"); + } else { + table.parent().removeClass("friendsOnly"); + } + $(".page.pageLeaderboards table thead").addClass("hidden"); if (state.type === "allTime" || state.type === "daily") { $(".page.pageLeaderboards table thead.allTimeAndDaily").removeClass( @@ -653,9 +680,13 @@ function fillUser(): void { } const userData = state.userData; - const percentile = (userData.rank / state.count) * 100; + const rank = state.friendsOnly + ? (userData.friendsRank as number) + : userData.rank; + const percentile = (rank / state.count) * 100; + let percentileString = `Top ${percentile.toFixed(2)}%`; - if (userData.rank === 1) { + if (rank === 1) { percentileString = "GOAT"; } @@ -687,9 +718,7 @@ function fillUser(): void { const html = `
${ - userData.rank === 1 - ? '' - : userData.rank + rank === 1 ? '' : rank }
You (${percentileString})
@@ -889,6 +918,7 @@ function updateContent(): void { function updateSideButtons(): void { updateTypeButtons(); + updateFriendsButtons(); updateModeButtons(); updateLanguageButtons(); } @@ -899,14 +929,43 @@ function updateTypeButtons(): void { el.find(`button[data-type=${state.type}]`).addClass("active"); } +function updateFriendsButtons(): void { + const friendsOnlyGroup = $( + ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons" + ); + if ( + state.type === "allTime" && + isAuthenticated() && + (ServerConfiguration.get()?.connections.enabled ?? false) + ) { + friendsOnlyGroup.removeClass("hidden"); + } else { + friendsOnlyGroup.addClass("hidden"); + state.friendsOnly = false; + return; + } + + const everyoneButton = $( + ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons .everyone" + ); + const friendsOnlyButton = $( + ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons .friendsOnly" + ); + if (state.friendsOnly) { + friendsOnlyButton.addClass("active"); + everyoneButton.removeClass("active"); + } else { + friendsOnlyButton.removeClass("active"); + everyoneButton.addClass("active"); + } +} + function updateModeButtons(): void { if (state.type !== "allTime" && state.type !== "daily") { $(".page.pageLeaderboards .buttonGroup.modeButtons").addClass("hidden"); - $(".page.pageLeaderboards .sideButtons .divider").addClass("hidden"); return; } $(".page.pageLeaderboards .buttonGroup.modeButtons").removeClass("hidden"); - $(".page.pageLeaderboards .sideButtons .divider").removeClass("hidden"); const el = $(".page.pageLeaderboards .buttonGroup.modeButtons"); el.find("button").removeClass("active"); @@ -935,13 +994,11 @@ function updateModeButtons(): void { function updateLanguageButtons(): void { if (state.type !== "daily") { $(".page.pageLeaderboards .buttonGroup.languageButtons").addClass("hidden"); - $(".page.pageLeaderboards .sideButtons .divider2").addClass("hidden"); return; } $(".page.pageLeaderboards .buttonGroup.languageButtons").removeClass( "hidden" ); - $(".page.pageLeaderboards .sideButtons .divider2").removeClass("hidden"); const el = $(".page.pageLeaderboards .buttonGroup.languageButtons"); el.find("button").removeClass("active"); @@ -1137,7 +1194,9 @@ async function appendModeAndLanguageButtons(): Promise { ` ); }); - $(".modeButtons").html(mode2Buttons.join("\n")); + $(".modeButtons").html( + `
` + mode2Buttons.join("\n") + ); const availableLanguages = Array.from( new Set( @@ -1155,7 +1214,9 @@ async function appendModeAndLanguageButtons(): Promise { ${lang} ` ); - $(".languageButtons").html(languageButtons.join("\n")); + $(".languageButtons").html( + `
` + languageButtons.join("\n") + ); } function disableButtons(): void { @@ -1253,6 +1314,9 @@ function updateGetParameters(): void { params.page = state.page + 1; + if (state.friendsOnly) { + params.friendsOnly = true; + } page.setUrlParams(params); selectorLS.set(state); @@ -1268,6 +1332,8 @@ function readGetParameters(params?: UrlParameter): void { state.type = params.type; } + state.friendsOnly = params.friendsOnly ?? false; + if (state.type === "allTime") { if (params.mode2 !== undefined) { state.mode2 = params.mode2 as AllTimeState["mode2"]; @@ -1345,9 +1411,11 @@ $(".page.pageLeaderboards .buttonGroup.typeButtons").on( if (state.type === "daily") { state.language = "english"; state.yesterday = false; + state.friendsOnly = false; } if (state.type === "weekly") { state.lastWeek = false; + state.friendsOnly = false; } checkIfLeaderboardIsValid(); state.data = null; @@ -1412,6 +1480,20 @@ $(".page.pageLeaderboards .buttonGroup.languageButtons").on( } ); +$(".page.pageLeaderboards .buttonGroup.friendsOnlyButtons").on( + "click", + "button", + () => { + state.friendsOnly = !state.friendsOnly; + state.page = 0; + void requestData(); + updateTitle(); + updateSideButtons(); + updateContent(); + updateGetParameters(); + } +); + export const page = new PageWithUrlParams({ id: "leaderboards", element: $(".page.pageLeaderboards"), diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts index 9e8fab615bd7..d1ef48081e76 100644 --- a/packages/contracts/src/leaderboards.ts +++ b/packages/contracts/src/leaderboards.ts @@ -27,6 +27,14 @@ const PaginationQuerySchema = z.object({ export type PaginationQuery = z.infer; +const FriendsOnlyQuerySchema = z.object({ + friendsOnly: z + .boolean() + .optional() + .describe("include only users from your friends list, defaults to false."), +}); +export type FriendsOnlyQuery = z.infer; + const LeaderboardResponseSchema = z.object({ count: z.number().int().nonnegative(), pageSize: z.number().int().positive(), @@ -36,7 +44,7 @@ const LeaderboardResponseSchema = z.object({ export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge( PaginationQuerySchema -); +).merge(FriendsOnlyQuerySchema); export type GetLeaderboardQuery = z.infer; export const GetLeaderboardResponseSchema = responseWithData( @@ -50,7 +58,9 @@ export type GetLeaderboardResponse = z.infer< //-------------------------------------------------------------------------- -export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema; +export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema.merge( + FriendsOnlyQuerySchema +); export type GetLeaderboardRankQuery = z.infer< typeof GetLeaderboardRankQuerySchema >; @@ -76,7 +86,7 @@ export type GetDailyLeaderboardQuery = z.infer< >; export const GetDailyLeaderboardResponseSchema = responseWithData( LeaderboardResponseSchema.extend({ - entries: z.array(LeaderboardEntrySchema), + entries: z.array(LeaderboardEntrySchema.omit({ friendsRank: true })), minWpm: z.number().nonnegative(), }) ); @@ -92,7 +102,7 @@ export type GetDailyLeaderboardRankQuery = z.infer< typeof GetDailyLeaderboardRankQuerySchema >; export const GetLeaderboardDailyRankResponseSchema = responseWithNullableData( - LeaderboardEntrySchema + LeaderboardEntrySchema.omit({ friendsRank: true }) ); export type GetLeaderboardDailyRankResponse = z.infer< typeof GetLeaderboardDailyRankResponseSchema diff --git a/packages/schemas/src/leaderboards.ts b/packages/schemas/src/leaderboards.ts index 9c299ee29ce5..6812676a39fe 100644 --- a/packages/schemas/src/leaderboards.ts +++ b/packages/schemas/src/leaderboards.ts @@ -11,6 +11,12 @@ export const LeaderboardEntrySchema = z.object({ discordId: z.string().optional(), discordAvatar: z.string().optional(), rank: z.number().nonnegative().int(), + friendsRank: z + .number() + .nonnegative() + .int() + .optional() + .describe("only available on friendsOnly leaderboard"), badgeId: z.number().int().optional(), isPremium: z.boolean().optional(), }); @@ -18,14 +24,12 @@ export type LeaderboardEntry = z.infer; export const RedisDailyLeaderboardEntrySchema = LeaderboardEntrySchema.omit({ rank: true, + friendsRank: true, }); export type RedisDailyLeaderboardEntry = z.infer< typeof RedisDailyLeaderboardEntrySchema >; -export const DailyLeaderboardRankSchema = LeaderboardEntrySchema; -export type DailyLeaderboardRank = z.infer; - export const RedisXpLeaderboardEntrySchema = z.object({ uid: z.string(), name: z.string(),
# name @@ -173,8 +174,21 @@ daily - + + + - + ${entry.friendsRank ?? ""} ${ entry.rank === 1 ? '' : entry.rank }