From 0ef52ed9da35b39e20a0f324c9caf1ce250bb626 Mon Sep 17 00:00:00 2001 From: Bruce Berrios <58147810+Bruception@users.noreply.github.com> Date: Thu, 26 May 2022 10:30:11 -0400 Subject: [PATCH] Add daily leaderboards (#3023) bruception * Setup daily leaderboards backend (#2987) bruception * Setup daily leaderboards backend * Add enabled checks * Consistent naming * Add initial unit tests * Use more flexible daily leaderboard rule logic * Fix seed rule * Add LRU Cache + Rank Calculation * Use native functions * Optional daily leaderboard rank * Proper status code for invalid lb mode * Add result criteria check * Make daily leaderboard cache size configurable * Add Ape endpoint for daily leaderboard (#2997) * support for switching to viewing daily lbs * test * buttons * only checking daily if user has more than 2 hours typed * updated structure * setting rank if its undefined * only when daily * storing uid * fixed media queries * Daily leaderboards pagination (#3006) * Pagination * Remove with scores * Add daily leaderboard rank (#3014) Bruception * Add daily leaderboard rank * Remove unused import * Use object instead * Add client logic * Add limit checks * Announce top daily leaderboard results (#3017) * Add rank in daily leaderboard results (#3022) * not showing lb memory and top % on daily * Fix rank pagination * Actual fix * showing new rank Co-authored-by: Miodec --- backend/__tests__/dal/ape-keys.spec.ts | 22 ++ .../utils/daily-leaderboards.spec.ts | 93 ++++++++ backend/__tests__/utils/misc.spec.ts | 40 ++++ backend/package-lock.json | 165 +++++++++++++- backend/package.json | 1 + backend/redis-scripts/add-result.lua | 38 ++++ backend/redis-scripts/get-results.lua | 18 ++ backend/setup-tests.ts | 6 + backend/src/api/controllers/leaderboard.ts | 53 +++++ backend/src/api/controllers/result.ts | 46 +++- backend/src/api/routes/leaderboards.ts | 61 +++-- backend/src/constants/base-configuration.ts | 11 +- backend/src/init/redis.ts | 25 +- .../src/jobs/announce-daily-leaderboards.ts | 67 ++++++ backend/src/jobs/index.ts | 3 +- backend/src/server.ts | 5 +- backend/src/tasks/george.ts | 59 +++-- backend/src/types/types.d.ts | 14 ++ backend/src/utils/daily-leaderboards.ts | 213 ++++++++++++++++++ backend/src/utils/misc.ts | 10 + frontend/src/styles/leaderboards.scss | 19 +- frontend/src/styles/z_media-queries.scss | 25 +- frontend/src/ts/ape/endpoints/leaderboards.ts | 24 +- frontend/src/ts/ape/types/ape.d.ts | 28 ++- frontend/src/ts/elements/leaderboards.ts | 138 +++++++++--- frontend/src/ts/test/test-logic.ts | 11 + frontend/static/html/popups.html | 18 +- 27 files changed, 1073 insertions(+), 140 deletions(-) create mode 100644 backend/__tests__/dal/ape-keys.spec.ts create mode 100644 backend/__tests__/utils/daily-leaderboards.spec.ts create mode 100644 backend/__tests__/utils/misc.spec.ts create mode 100644 backend/redis-scripts/add-result.lua create mode 100644 backend/redis-scripts/get-results.lua create mode 100644 backend/src/jobs/announce-daily-leaderboards.ts create mode 100644 backend/src/utils/daily-leaderboards.ts diff --git a/backend/__tests__/dal/ape-keys.spec.ts b/backend/__tests__/dal/ape-keys.spec.ts new file mode 100644 index 000000000..b92a3ae4a --- /dev/null +++ b/backend/__tests__/dal/ape-keys.spec.ts @@ -0,0 +1,22 @@ +import { ObjectId } from "mongodb"; +import { addApeKey } from "../../src/dal/ape-keys"; + +describe("ApeKeysDal", () => { + it("should be able to add a new ape key", async () => { + const apeKey = { + _id: new ObjectId(), + uid: "123", + name: "test", + hash: "12345", + createdOn: Date.now(), + modifiedOn: Date.now(), + lastUsedOn: Date.now(), + useCount: 0, + enabled: true, + }; + + const apeKeyId = await addApeKey(apeKey); + + expect(apeKeyId).toBe(apeKey._id.toHexString()); + }); +}); diff --git a/backend/__tests__/utils/daily-leaderboards.spec.ts b/backend/__tests__/utils/daily-leaderboards.spec.ts new file mode 100644 index 000000000..294801e52 --- /dev/null +++ b/backend/__tests__/utils/daily-leaderboards.spec.ts @@ -0,0 +1,93 @@ +import { + initializeDailyLeaderboardsCache, + getDailyLeaderboard, +} from "../../src/utils/daily-leaderboards"; + +const dailyLeaderboardsConfig = { + enabled: true, + maxResults: 3, + leaderboardExpirationTimeInDays: 1, + validModeRules: [ + { + language: "(english|spanish)", + mode: "time", + mode2: "(15|60)", + }, + { + language: "french", + mode: "words", + mode2: "\\d+", + }, + ], + dailyLeaderboardCacheSize: 3, + topResultsToAnnounce: 3, +}; + +describe("Daily Leaderboards", () => { + it("should properly handle valid and invalid modes", () => { + initializeDailyLeaderboardsCache(dailyLeaderboardsConfig); + + const modeCases = [ + { + case: { + language: "english", + mode: "time", + mode2: "60", + }, + expected: true, + }, + { + case: { + language: "spanish", + mode: "time", + mode2: "15", + }, + expected: true, + }, + { + case: { + language: "english", + mode: "time", + mode2: "600", + }, + expected: false, + }, + { + case: { + language: "spanish", + mode: "words", + mode2: "150", + }, + expected: false, + }, + { + case: { + language: "french", + mode: "time", + mode2: "600", + }, + expected: false, + }, + { + case: { + language: "french", + mode: "words", + mode2: "100", + }, + expected: true, + }, + ]; + + modeCases.forEach(({ case: { language, mode, mode2 }, expected }) => { + const result = getDailyLeaderboard( + language, + mode, + mode2, + dailyLeaderboardsConfig + ); + expect(!!result).toBe(expected); + }); + }); + + // TODO: Setup Redis mock and test the rest of this +}); diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts new file mode 100644 index 000000000..299398d4b --- /dev/null +++ b/backend/__tests__/utils/misc.spec.ts @@ -0,0 +1,40 @@ +import _ from "lodash"; +import * as misc from "../../src/utils/misc"; + +describe("Misc Utils", () => { + it("getCurrentDayTimestamp", () => { + Date.now = jest.fn(() => 1652743381); + + const currentDay = misc.getCurrentDayTimestamp(); + expect(currentDay).toBe(1641600000); + }); + + it("matchesAPattern", () => { + const testCases = { + "eng.*": { + cases: ["english", "aenglish", "en", "eng"], + expected: [true, false, false, true], + }, + + "\\d+": { + cases: ["b", "2", "331", "1a"], + expected: [false, true, true, false], + }, + "(hi|hello)": { + cases: ["hello", "hi", "hillo", "hi hello"], + expected: [true, true, false, false], + }, + ".+": { + cases: ["a2", "b2", "c1", ""], + expected: [true, true, true, false], + }, + }; + + _.each(testCases, (testCase, pattern) => { + const { cases, expected } = testCase; + _.each(cases, (caseValue, index) => { + expect(misc.matchesAPattern(caseValue, pattern)).toBe(expected[index]); + }); + }); + }); +}); diff --git a/backend/package-lock.json b/backend/package-lock.json index 058e404d6..699569beb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,7 @@ "ioredis": "4.28.5", "joi": "17.6.0", "lodash": "4.17.21", + "lru-cache": "7.10.1", "mongodb": "4.4.0", "node-fetch": "2.6.7", "nodemon": "2.0.7", @@ -1276,6 +1277,17 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { "version": "5.0.0", "license": "ISC", @@ -3597,6 +3609,18 @@ "dev": true, "license": "MIT" }, + "node_modules/fastify/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fastify/node_modules/semver": { "version": "7.3.5", "dev": true, @@ -4058,6 +4082,18 @@ "node": ">=10" } }, + "node_modules/google-auth-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/google-gax": { "version": "2.30.0", "license": "Apache-2.0", @@ -5274,6 +5310,18 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-snapshot/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-snapshot/node_modules/semver": { "version": "7.3.7", "dev": true, @@ -5930,13 +5978,11 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz", + "integrity": "sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A==", "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/lru-memoizer": { @@ -6299,6 +6345,18 @@ "node": ">=0.10" } }, + "node_modules/mongodb-memory-server-core/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mongodb-memory-server-core/node_modules/mongodb": { "version": "3.7.3", "dev": true, @@ -7895,6 +7953,18 @@ "node": ">= 6" } }, + "node_modules/superagent/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/superagent/node_modules/mime": { "version": "2.6.0", "dev": true, @@ -8313,6 +8383,18 @@ } } }, + "node_modules/ts-jest/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-jest/node_modules/semver": { "version": "7.3.7", "dev": true, @@ -9808,6 +9890,14 @@ "tar": "^6.1.11" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "nopt": { "version": "5.0.0", "requires": { @@ -11396,6 +11486,15 @@ "tiny-lru": "^8.0.1" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.3.5", "dev": true, @@ -11703,6 +11802,17 @@ "gtoken": "^5.0.4", "jws": "^4.0.0", "lru-cache": "^6.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + } } }, "google-gax": { @@ -12496,6 +12606,15 @@ "semver": "^7.3.2" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.3.7", "dev": true, @@ -12961,10 +13080,9 @@ "version": "1.0.1" }, "lru-cache": { - "version": "6.0.0", - "requires": { - "yallist": "^4.0.0" - } + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz", + "integrity": "sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A==" }, "lru-memoizer": { "version": "2.1.4", @@ -13186,6 +13304,15 @@ "version": "1.5.1", "dev": true }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "mongodb": { "version": "3.7.3", "dev": true, @@ -14189,6 +14316,15 @@ "mime-types": "^2.1.12" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "mime": { "version": "2.6.0", "dev": true @@ -14433,6 +14569,15 @@ "yargs-parser": "20.x" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.3.7", "dev": true, diff --git a/backend/package.json b/backend/package.json index 490831d7f..49f467ce8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,7 @@ "ioredis": "4.28.5", "joi": "17.6.0", "lodash": "4.17.21", + "lru-cache": "7.10.1", "mongodb": "4.4.0", "node-fetch": "2.6.7", "nodemon": "2.0.7", diff --git a/backend/redis-scripts/add-result.lua b/backend/redis-scripts/add-result.lua new file mode 100644 index 000000000..debd9de8b --- /dev/null +++ b/backend/redis-scripts/add-result.lua @@ -0,0 +1,38 @@ +local redis_call = redis.call +local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] + +local max_results = tonumber(ARGV[1]) +local leaderboard_expiration_time = ARGV[2] +local user_id = ARGV[3] +local result_score = ARGV[4] +local result_data = ARGV[5] + +local number_of_results_changed = redis_call('ZADD', leaderboard_scores_key, 'GT', 'CH', result_score, user_id) + +if (number_of_results_changed == 1) then + redis_call('HSET', leaderboard_results_key, user_id, result_data) +end + +local number_of_results = redis_call('ZCARD', leaderboard_scores_key) + +local removed_user_id = nil + +if (number_of_results > max_results) then + local user_with_lowest_score = redis_call('ZPOPMIN', leaderboard_scores_key) + removed_user_id = user_with_lowest_score[1] + + if (removed_user_id ~= nil) then + redis_call('HDEL', leaderboard_results_key, removed_user_id) + end +end + +if (number_of_results == 1) then -- Indicates that this is the first score of the day, set the leaderboard keys to expire at specified time + redis_call('EXPIREAT', leaderboard_scores_key, leaderboard_expiration_time) + redis_call('EXPIREAT', leaderboard_results_key, leaderboard_expiration_time) +end + +if (number_of_results_changed == 1 and removed_user_id ~= user_id) then + return redis_call('ZREVRANK', leaderboard_scores_key, user_id) +end + +return nil diff --git a/backend/redis-scripts/get-results.lua b/backend/redis-scripts/get-results.lua new file mode 100644 index 000000000..9a0db82b9 --- /dev/null +++ b/backend/redis-scripts/get-results.lua @@ -0,0 +1,18 @@ +local redis_call = redis.call +local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] + +local min_rank = tonumber(ARGV[1]) +local max_rank = tonumber(ARGV[2]) + +local results = {} +local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV') + +for _, user_id in ipairs(scores_in_range) do + local result_data = redis_call('HGET', leaderboard_results_key, user_id) + + if (result_data ~= nil) then + results[#results + 1] = result_data + end +end + +return results diff --git a/backend/setup-tests.ts b/backend/setup-tests.ts index ed3708b33..97a98f9de 100644 --- a/backend/setup-tests.ts +++ b/backend/setup-tests.ts @@ -73,6 +73,12 @@ beforeEach(async () => { } }); +const realDateNow = Date.now; + +afterEach(() => { + Date.now = realDateNow; +}); + afterAll(async () => { await connection.close(); }); diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 6b0ee29ce..893f493a8 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -2,6 +2,7 @@ import _ from "lodash"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; import MonkeyError from "../../utils/error"; +import * as DailyLeaderboards from "../../utils/daily-leaderboards"; export async function getLeaderboard( req: MonkeyTypes.Request @@ -67,3 +68,55 @@ export async function getRankFromLeaderboard( return new MonkeyResponse("Rank retrieved", data); } + +function getDailyLeaderboardWithError( + req: MonkeyTypes.Request +): DailyLeaderboards.DailyLeaderboard { + const { language, mode, mode2 } = req.query; + + const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard( + language as string, + mode as string, + mode2 as string, + req.ctx.configuration.dailyLeaderboards + ); + if (!dailyLeaderboard) { + throw new MonkeyError(404, "There is no daily leaderboard for this mode"); + } + + return dailyLeaderboard; +} + +export async function getDailyLeaderboard( + req: MonkeyTypes.Request +): Promise { + const { skip = 0, limit = 50 } = req.query; + + const dailyLeaderboard = getDailyLeaderboardWithError(req); + + const minRank = parseInt(skip as string, 10); + const maxRank = minRank + parseInt(limit as string, 10) - 1; + + const topResults = await dailyLeaderboard.getResults( + minRank, + maxRank, + req.ctx.configuration.dailyLeaderboards + ); + + return new MonkeyResponse("Daily leaderboard retrieved", topResults); +} + +export async function getDailyLeaderboardRank( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const dailyLeaderboard = getDailyLeaderboardWithError(req); + + const rank = await dailyLeaderboard.getRank( + uid, + req.ctx.configuration.dailyLeaderboards + ); + + return new MonkeyResponse("Daily leaderboard rank retrieved", rank); +} diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 355f44fd3..524093437 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -23,6 +23,7 @@ import { import MonkeyStatusCodes from "../../constants/monkey-status-codes"; import { incrementResult } from "../../utils/prometheus"; import * as George from "../../tasks/george"; +import { getDailyLeaderboard } from "../../utils/daily-leaderboards"; try { if (anticheatImplemented() === false) throw new Error("undefined"); @@ -76,6 +77,13 @@ export async function updateTags( return new MonkeyResponse("Result tags updated"); } +interface AddResultData { + isPb: boolean; + tagPbs: any[]; + insertedId: ObjectId; + dailyLeaderboardRank?: number; +} + export async function addResult( req: MonkeyTypes.Request ): Promise { @@ -296,6 +304,37 @@ export async function addResult( updateTypingStats(uid, result.restartCount, tt); PublicStatsDAL.updateStats(result.restartCount, tt); + const dailyLeaderboardsConfig = req.ctx.configuration.dailyLeaderboards; + const dailyLeaderboard = getDailyLeaderboard( + result.language, + result.mode, + result.mode2, + dailyLeaderboardsConfig + ); + + let dailyLeaderboardRank = -1; + + const { funbox, bailedOut } = result; + const validResultCriteria = + (funbox === "none" || funbox === "plus_one" || funbox === "plus_two") && + !bailedOut && + (user.timeTyping ?? 0) > 7200; + + if (dailyLeaderboard && validResultCriteria) { + dailyLeaderboardRank = await dailyLeaderboard.addResult( + { + name: user.name, + wpm: result.wpm, + raw: result.rawWpm, + acc: result.acc, + consistency: result.consistency, + timestamp: result.timestamp, + uid, + }, + dailyLeaderboardsConfig + ); + } + if (result.bailedOut === false) delete result.bailedOut; if (result.blindMode === false) delete result.blindMode; if (result.lazyMode === false) delete result.lazyMode; @@ -322,13 +361,16 @@ export async function addResult( ); } - const data = { + const data: AddResultData = { isPb, - name: result.name, tagPbs, insertedId: addedResult.insertedId, }; + if (dailyLeaderboardRank !== -1) { + data.dailyLeaderboardRank = dailyLeaderboardRank; + } + incrementResult(result); return new MonkeyResponse("Result saved", data); diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 6c0a8956e..e4a20ca59 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -4,22 +4,39 @@ import * as RateLimit from "../../middlewares/rate-limit"; import apeRateLimit from "../../middlewares/ape-rate-limit"; import { authenticateRequest } from "../../middlewares/auth"; import * as LeaderboardController from "../controllers/leaderboard"; -import { asyncHandler, validateRequest } from "../../middlewares/api-utils"; +import { + asyncHandler, + validateRequest, + validateConfiguration, +} from "../../middlewares/api-utils"; + +const BASE_LEADERBOARD_VALIDATION_SCHEMA = { + language: joi.string().required(), + mode: joi.string().required(), + mode2: joi.string().required(), +}; + +const LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT = { + ...BASE_LEADERBOARD_VALIDATION_SCHEMA, + skip: joi.number().min(0), + limit: joi.number().min(0).max(50), +}; const router = Router(); +const requireDailyLeaderboardsEnabled = validateConfiguration({ + criteria: (configuration) => { + return configuration.dailyLeaderboards.enabled; + }, + invalidMessage: "Daily leaderboards are not available at this time.", +}); + router.get( "/", RateLimit.leaderboardsGet, authenticateRequest({ isPublic: true, acceptApeKeys: true }), validateRequest({ - query: { - language: joi.string().required(), - mode: joi.string().required(), - mode2: joi.string().required(), - skip: joi.number().min(0), - limit: joi.number().min(0).max(50), - }, + query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT, }), asyncHandler(LeaderboardController.getLeaderboard) ); @@ -30,13 +47,31 @@ router.get( authenticateRequest({ acceptApeKeys: true }), apeRateLimit, validateRequest({ - query: { - language: joi.string().required(), - mode: joi.string().required(), - mode2: joi.string().required(), - }, + query: BASE_LEADERBOARD_VALIDATION_SCHEMA, }), asyncHandler(LeaderboardController.getRankFromLeaderboard) ); +router.get( + "/daily", + requireDailyLeaderboardsEnabled, + RateLimit.leaderboardsGet, + authenticateRequest({ isPublic: true }), + validateRequest({ + query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT, + }), + asyncHandler(LeaderboardController.getDailyLeaderboard) +); + +router.get( + "/daily/rank", + requireDailyLeaderboardsEnabled, + RateLimit.leaderboardsGet, + authenticateRequest(), + validateRequest({ + query: BASE_LEADERBOARD_VALIDATION_SCHEMA, + }), + asyncHandler(LeaderboardController.getDailyLeaderboardRank) +); + export default router; diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 527a6bdf6..3aac39962 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -27,13 +27,22 @@ const BASE_CONFIGURATION: MonkeyTypes.Configuration = { enabled: false, }, favoriteQuotes: { - maxFavorites: 100, + maxFavorites: 0, }, autoBan: { enabled: false, maxCount: 5, maxHours: 1, }, + dailyLeaderboards: { + enabled: false, + maxResults: 0, + leaderboardExpirationTimeInDays: 0, + validModeRules: [], + // GOTCHA! MUST ATLEAST BE 1, LRUCache module will make process crash and die + dailyLeaderboardCacheSize: 1, + topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results. + }, }; export default BASE_CONFIGURATION; diff --git a/backend/src/init/redis.ts b/backend/src/init/redis.ts index e7f97dca0..daa2343be 100644 --- a/backend/src/init/redis.ts +++ b/backend/src/init/redis.ts @@ -1,9 +1,28 @@ +import fs from "fs"; +import _ from "lodash"; +import { join } from "path"; import IORedis from "ioredis"; import Logger from "../utils/logger"; let connection: IORedis.Redis; let connected = false; +const REDIS_SCRIPTS_DIRECTORY_PATH = join(__dirname, "../../redis-scripts"); + +function loadScripts(client: IORedis.Redis): void { + const scriptFiles = fs.readdirSync(REDIS_SCRIPTS_DIRECTORY_PATH); + + scriptFiles.forEach((scriptFile) => { + const scriptPath = join(REDIS_SCRIPTS_DIRECTORY_PATH, scriptFile); + const scriptSource = fs.readFileSync(scriptPath, "utf-8"); + const scriptName = _.camelCase(scriptFile.split(".")[0]); + + client.defineCommand(scriptName, { + lua: scriptSource, + }); + }); +} + export async function connect(): Promise { if (connected) { return; @@ -27,15 +46,19 @@ export async function connect(): Promise { try { await connection.connect(); + + Logger.info("Loading custom redis scripts..."); + loadScripts(connection); + connected = true; } catch (error) { + Logger.error(error.message); if (MODE === "dev") { await connection.quit(); Logger.warning( `Failed to connect to redis. Continuing in dev mode, running without redis.` ); } else { - Logger.error(error.message); Logger.error( "Failed to connect to redis. Exiting with exit status code 1." ); diff --git a/backend/src/jobs/announce-daily-leaderboards.ts b/backend/src/jobs/announce-daily-leaderboards.ts new file mode 100644 index 000000000..2e3066917 --- /dev/null +++ b/backend/src/jobs/announce-daily-leaderboards.ts @@ -0,0 +1,67 @@ +import { CronJob } from "cron"; +import { getCurrentDayTimestamp } from "../utils/misc"; +import { getCachedConfiguration } from "../init/configuration"; +import { DailyLeaderboard } from "../utils/daily-leaderboards"; +import { announceDailyLeaderboardTopResults } from "../tasks/george"; + +const CRON_SCHEDULE = "1 0 * * *"; // At 00:01. +const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +const leaderboardsToAnnounce = [ + { + language: "english", + mode: "time", + mode2: "15", + }, + { + language: "english", + mode: "time", + mode2: "60", + }, +]; + +async function announceDailyLeaderboard( + language: string, + mode: string, + mode2: string, + dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"] +): Promise { + const yesterday = getCurrentDayTimestamp() - ONE_DAY_IN_MILLISECONDS; + const dailyLeaderboard = new DailyLeaderboard( + language, + mode, + mode2, + yesterday + ); + + const topResults = await dailyLeaderboard.getResults( + 0, + dailyLeaderboardsConfig.topResultsToAnnounce - 1, + dailyLeaderboardsConfig + ); + if (topResults.length === 0) { + return; + } + + const leaderboardId = `${mode} ${mode2} ${language}`; + await announceDailyLeaderboardTopResults( + leaderboardId, + yesterday, + topResults + ); +} + +async function announceDailyLeaderboards(): Promise { + const { dailyLeaderboards } = await getCachedConfiguration(); + if (!dailyLeaderboards.enabled) { + return; + } + + await Promise.allSettled( + leaderboardsToAnnounce.map(({ language, mode, mode2 }) => { + return announceDailyLeaderboard(language, mode, mode2, dailyLeaderboards); + }) + ); +} + +export default new CronJob(CRON_SCHEDULE, announceDailyLeaderboards); diff --git a/backend/src/jobs/index.ts b/backend/src/jobs/index.ts index 803c614be..574adb0fa 100644 --- a/backend/src/jobs/index.ts +++ b/backend/src/jobs/index.ts @@ -1,4 +1,5 @@ import updateLeaderboards from "./update-leaderboards"; import deleteOldLogs from "./delete-old-logs"; +import announceDailyLeaderboards from "./announce-daily-leaderboards"; -export default [updateLeaderboards, deleteOldLogs]; +export default [updateLeaderboards, deleteOldLogs, announceDailyLeaderboards]; diff --git a/backend/src/server.ts b/backend/src/server.ts index fbb49dc62..e7ab249f2 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,6 +5,7 @@ import serviceAccount from "./credentials/serviceAccountKey.json"; // eslint-dis import * as db from "./init/db"; import jobs from "./jobs"; import { getLiveConfiguration } from "./init/configuration"; +import { initializeDailyLeaderboardsCache } from "./utils/daily-leaderboards"; import app from "./app"; import { Server } from "http"; import { version } from "./version"; @@ -28,7 +29,7 @@ async function bootServer(port: number): Promise { Logger.success("Firebase app initialized"); Logger.info("Fetching live configuration..."); - await getLiveConfiguration(); + const liveConfiguration = await getLiveConfiguration(); Logger.success("Live configuration fetched"); Logger.info("Connecting to redis..."); @@ -40,6 +41,8 @@ async function bootServer(port: number): Promise { Logger.info("Initializing task queues..."); initJobQueue(RedisClient.getConnection()); Logger.success("Task queues initialized"); + + initializeDailyLeaderboardsCache(liveConfiguration.dailyLeaderboards); } Logger.info("Starting cron jobs..."); diff --git a/backend/src/tasks/george.ts b/backend/src/tasks/george.ts index c230618d3..9ab88a4c9 100644 --- a/backend/src/tasks/george.ts +++ b/backend/src/tasks/george.ts @@ -3,19 +3,14 @@ import { Queue, QueueScheduler } from "bullmq"; const QUEUE_NAME = "george-tasks"; -type GeorgeTaskArgument = string | number; - interface GeorgeTask { name: string; - args: GeorgeTaskArgument[]; + args: any[]; } -function buildGeorgeTask( - task: string, - taskArgs: GeorgeTaskArgument[] -): GeorgeTask { +function buildGeorgeTask(taskName: string, taskArgs: any[]): GeorgeTask { return { - name: task, + name: taskName, args: taskArgs, }; } @@ -68,46 +63,49 @@ export async function updateDiscordRole( discordId: string, wpm: number ): Promise { - const task = "updateRole"; - const updateDiscordRoleTask = buildGeorgeTask(task, [discordId, wpm]); - await addToQueue(task, updateDiscordRoleTask); + const taskName = "updateRole"; + const updateDiscordRoleTask = buildGeorgeTask(taskName, [discordId, wpm]); + await addToQueue(taskName, updateDiscordRoleTask); } export async function linkDiscord( discordId: string, uid: string ): Promise { - const task = "linkDiscord"; - const linkDiscordTask = buildGeorgeTask(task, [discordId, uid]); - await addToQueue(task, linkDiscordTask); + const taskName = "linkDiscord"; + const linkDiscordTask = buildGeorgeTask(taskName, [discordId, uid]); + await addToQueue(taskName, linkDiscordTask); } export async function unlinkDiscord( discordId: string, uid: string ): Promise { - const task = "unlinkDiscord"; - const unlinkDiscordTask = buildGeorgeTask(task, [discordId, uid]); - await addToQueue(task, unlinkDiscordTask); + const taskName = "unlinkDiscord"; + const unlinkDiscordTask = buildGeorgeTask(taskName, [discordId, uid]); + await addToQueue(taskName, unlinkDiscordTask); } export async function awardChallenge( discordId: string, challengeName: string ): Promise { - const task = "awardChallenge"; - const awardChallengeTask = buildGeorgeTask(task, [discordId, challengeName]); - await addToQueue(task, awardChallengeTask); + const taskName = "awardChallenge"; + const awardChallengeTask = buildGeorgeTask(taskName, [ + discordId, + challengeName, + ]); + await addToQueue(taskName, awardChallengeTask); } export async function announceLeaderboardUpdate( newRecords: any[], leaderboardId: string ): Promise { - const task = "announceLeaderboardUpdate"; + const taskName = "announceLeaderboardUpdate"; const leaderboardUpdateTasks = newRecords.map((record) => { - const taskData = buildGeorgeTask(task, [ + const taskData = buildGeorgeTask(taskName, [ record.discordId ?? record.name, record.rank, leaderboardId, @@ -118,10 +116,25 @@ export async function announceLeaderboardUpdate( ]); return { - name: task, + name: taskName, data: taskData, }; }); await addToQueueBulk(leaderboardUpdateTasks); } + +export async function announceDailyLeaderboardTopResults( + leaderboardId: string, + leaderboardTimestamp: number, + topResults: any[] +): Promise { + const taskName = "announceDailyLeaderboardTopResults"; + + const dailyLeaderboardTopResultsTask = buildGeorgeTask(taskName, [ + taskName, + [leaderboardId, leaderboardTimestamp, topResults], + ]); + + await addToQueue(taskName, dailyLeaderboardTopResultsTask); +} diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index a1633012d..c698e7f00 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -3,6 +3,12 @@ type ObjectId = import("mongodb").ObjectId; type ExpressRequest = import("express").Request; declare namespace MonkeyTypes { + interface ValidModeRule { + language: string; + mode: string; + mode2: string; + } + interface Configuration { maintenance: boolean; quoteReport: { @@ -34,6 +40,14 @@ declare namespace MonkeyTypes { maxCount: number; maxHours: number; }; + dailyLeaderboards: { + enabled: boolean; + leaderboardExpirationTimeInDays: number; + maxResults: number; + validModeRules: ValidModeRule[]; + dailyLeaderboardCacheSize: number; + topResultsToAnnounce: number; + }; } interface DecodedToken { diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts new file mode 100644 index 000000000..0cacd6466 --- /dev/null +++ b/backend/src/utils/daily-leaderboards.ts @@ -0,0 +1,213 @@ +import _ from "lodash"; +import LRUCache from "lru-cache"; +import * as RedisClient from "../init/redis"; +import { getCurrentDayTimestamp, matchesAPattern } from "./misc"; + +interface DailyLeaderboardEntry { + uid: string; + name: string; + wpm: number; + raw: number; + acc: number; + consistency: number; + timestamp: number; + rank?: number; + count?: number; +} + +const dailyLeaderboardNamespace = "monkeytypes:dailyleaderboard"; +const scoresNamespace = `${dailyLeaderboardNamespace}:scores`; +const resultsNamespace = `${dailyLeaderboardNamespace}:results`; + +function compareDailyLeaderboardEntries( + a: DailyLeaderboardEntry, + b: DailyLeaderboardEntry +): number { + if (a.wpm !== b.wpm) { + return b.wpm - a.wpm; + } + + if (a.acc !== b.acc) { + return b.acc - a.acc; + } + + return a.timestamp - b.timestamp; +} + +export class DailyLeaderboard { + private leaderboardResultsKeyName: string; + private leaderboardScoresKeyName: string; + private leaderboardModeKey: string; + private customTime: number; + + constructor(language: string, mode: string, mode2: string, customTime = -1) { + this.leaderboardModeKey = `${language}:${mode}:${mode2}`; + this.leaderboardResultsKeyName = `${resultsNamespace}:${this.leaderboardModeKey}`; + this.leaderboardScoresKeyName = `${scoresNamespace}:${this.leaderboardModeKey}`; + this.customTime = customTime; + } + + private getTodaysLeaderboardKeys(): { + currentDayTimestamp: number; + leaderboardScoresKey: string; + leaderboardResultsKey: string; + } { + const currentDayTimestamp = + this.customTime === -1 ? getCurrentDayTimestamp() : this.customTime; + const leaderboardScoresKey = `${this.leaderboardScoresKeyName}:${currentDayTimestamp}`; + const leaderboardResultsKey = `${this.leaderboardResultsKeyName}:${currentDayTimestamp}`; + + return { + currentDayTimestamp, + leaderboardScoresKey, + leaderboardResultsKey, + }; + } + + public async addResult( + entry: DailyLeaderboardEntry, + dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"] + ): Promise { + const connection = RedisClient.getConnection(); + if (!connection || !dailyLeaderboardsConfig.enabled) { + return -1; + } + + const { currentDayTimestamp, leaderboardScoresKey, leaderboardResultsKey } = + this.getTodaysLeaderboardKeys(); + + const { maxResults, leaderboardExpirationTimeInDays } = + dailyLeaderboardsConfig; + const leaderboardExpirationDurationInMilliseconds = + leaderboardExpirationTimeInDays * 24 * 60 * 60 * 1000; + + const leaderboardExpirationTimeInSeconds = Math.floor( + (currentDayTimestamp + leaderboardExpirationDurationInMilliseconds) / 1000 + ); + + // @ts-ignore + const rank = await connection.addResult( + 2, + leaderboardScoresKey, + leaderboardResultsKey, + maxResults, + leaderboardExpirationTimeInSeconds, + entry.uid, + entry.wpm, + JSON.stringify(entry) + ); + + if (rank === null) { + return -1; + } + + return rank + 1; + } + + public async getResults( + minRank: number, + maxRank: number, + dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"] + ): Promise { + const connection = RedisClient.getConnection(); + if (!connection || !dailyLeaderboardsConfig.enabled) { + return []; + } + + const { leaderboardScoresKey, leaderboardResultsKey } = + this.getTodaysLeaderboardKeys(); + + // @ts-ignore + const results: string[] = await connection.getResults( + 2, + leaderboardScoresKey, + leaderboardResultsKey, + minRank, + maxRank + ); + + const normalizedResults: DailyLeaderboardEntry[] = results + .map((result) => JSON.parse(result)) + .sort(compareDailyLeaderboardEntries); + + const resultsWithRanks: DailyLeaderboardEntry[] = normalizedResults.map( + (result, index) => ({ + ...result, + rank: minRank + index + 1, + }) + ); + + return resultsWithRanks; + } + + public async getRank( + uid: string, + dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"] + ): Promise { + const connection = RedisClient.getConnection(); + if (!connection || !dailyLeaderboardsConfig.enabled) { + return null; + } + + const { leaderboardScoresKey, leaderboardResultsKey } = + this.getTodaysLeaderboardKeys(); + + const [[, rank], [, count], [, result]] = await connection + .multi() + .zrevrank(leaderboardScoresKey, uid) + .zcard(leaderboardScoresKey) + .hget(leaderboardResultsKey, uid) + .exec(); + + if (rank === null) { + return null; + } + + return { + rank: rank + 1, + count: count ?? 0, + ...JSON.parse(result ?? "null"), + }; + } +} + +let DAILY_LEADERBOARDS: LRUCache; + +export function initializeDailyLeaderboardsCache( + configuration: MonkeyTypes.Configuration["dailyLeaderboards"] +): void { + const { dailyLeaderboardCacheSize } = configuration; + + DAILY_LEADERBOARDS = new LRUCache({ + max: dailyLeaderboardCacheSize, + }); +} + +export function getDailyLeaderboard( + language: string, + mode: string, + mode2: string, + dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"] +): DailyLeaderboard | null { + const { validModeRules, enabled } = dailyLeaderboardsConfig; + + const isValidMode = validModeRules.some((rule) => { + const matchesLanguage = matchesAPattern(language, rule.language); + const matchesMode = matchesAPattern(mode, rule.mode); + const matchesMode2 = matchesAPattern(mode2, rule.mode2); + return matchesLanguage && matchesMode && matchesMode2; + }); + + if (!enabled || !isValidMode || !DAILY_LEADERBOARDS) { + return null; + } + + const key = `${language}:${mode}:${mode2}`; + + if (!DAILY_LEADERBOARDS.has(key)) { + const dailyLeaderboard = new DailyLeaderboard(language, mode, mode2); + DAILY_LEADERBOARDS.set(key, dailyLeaderboard); + } + + return DAILY_LEADERBOARDS.get(key) ?? null; +} diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 6bcf91da4..4c9c2583f 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -80,3 +80,13 @@ export function padNumbers( number.toString().padStart(maxLength, fillString) ); } + +export function getCurrentDayTimestamp(): number { + const currentTime = Date.now(); + return currentTime - (currentTime % 86400000); +} + +export function matchesAPattern(text: string, pattern: string): boolean { + const regex = new RegExp(`^${pattern}$`); + return regex.test(text); +} diff --git a/frontend/src/styles/leaderboards.scss b/frontend/src/styles/leaderboards.scss index 99ab258a4..66f03cc68 100644 --- a/frontend/src/styles/leaderboards.scss +++ b/frontend/src/styles/leaderboards.scss @@ -9,7 +9,7 @@ padding: 1rem; display: grid; gap: 1rem 0; - grid-template-rows: 2rem auto; + grid-template-rows: auto auto; grid-template-areas: "title buttons" "tables tables"; @@ -18,23 +18,29 @@ .leaderboardsTop { width: 200%; min-width: 100%; - display: flex; + display: grid; align-items: center; justify-content: space-between; + grid-template-areas: + "title buttons" + "subtitle buttons"; - .buttonGroup .button { - padding: 0.4rem 2.18rem; + .buttons { + grid-area: buttons; + .buttonGroup .button { + padding: 0.4rem 2.18rem; + } } } .mainTitle { font-size: 2.5rem; - line-height: 2.5rem; grid-area: title; } .subTitle { color: var(--sub-color); + grid-area: subtitle; } .title { @@ -177,8 +183,9 @@ .buttonGroup { display: grid; grid-auto-flow: column; + grid-auto-columns: 1fr; gap: 1rem; - grid-area: 1/2; + grid-area: buttons; } } } diff --git a/frontend/src/styles/z_media-queries.scss b/frontend/src/styles/z_media-queries.scss index 0d6ca85ab..8e0b91a9e 100644 --- a/frontend/src/styles/z_media-queries.scss +++ b/frontend/src/styles/z_media-queries.scss @@ -1,23 +1,15 @@ @media only screen and (max-width: 1250px) { #leaderboardsWrapper #leaderboards { - grid-template-rows: 1.5rem auto; - padding: 1rem; - gap: 1rem; .mainTitle { font-size: 2rem; - line-height: 2rem; } .title { font-size: 1rem; } .leaderboardsTop { .buttonGroup { - gap: 0.1rem !important; - - .button { - padding: 0.4rem !important; - font-size: 0.7rem !important; - } + grid-auto-flow: row; + gap: 0.5rem; } } .tables table { @@ -98,14 +90,12 @@ } @media only screen and (max-width: 900px) { - // #leaderboards { - // .mainTitle { - // font-size: 1.5rem !important; - // line-height: 1.5rem !important; - // } - // } + #leaderboards { + .mainTitle { + font-size: 1.5rem !important; + } + } #bannerCenter .banner .container { - grid-template-columns: 1fr auto; .image { display: none; } @@ -310,7 +300,6 @@ #leaderboardsWrapper { #leaderboards { - grid-template-rows: 2.5rem auto; .leaderboardsTop { flex-direction: column; align-items: baseline; diff --git a/frontend/src/ts/ape/endpoints/leaderboards.ts b/frontend/src/ts/ape/endpoints/leaderboards.ts index 076d6e299..90df571d5 100644 --- a/frontend/src/ts/ape/endpoints/leaderboards.ts +++ b/frontend/src/ts/ape/endpoints/leaderboards.ts @@ -4,35 +4,37 @@ export default function getLeaderboardsEndpoints( apeClient: Ape.Client ): Ape.Endpoints["leaderboards"] { async function get( - language: string, - mode: MonkeyTypes.Mode, - mode2: string | number, - skip = 0, - limit = 50 + query: Ape.EndpointTypes.LeadeboardQueryWithPagination ): Ape.EndpointData { + const { language, mode, mode2, isDaily, skip = 0, limit = 50 } = query; + const searchQuery = { language, mode, mode2, - skip, + skip: Math.max(skip, 0), limit: Math.max(Math.min(limit, 50), 0), }; - return await apeClient.get(BASE_PATH, { searchQuery }); + const endpointPath = `${BASE_PATH}/${isDaily ? "daily" : ""}`; + + return await apeClient.get(endpointPath, { searchQuery }); } async function getRank( - language: string, - mode: MonkeyTypes.Mode, - mode2: string | number + query: Ape.EndpointTypes.LeaderboardQuery ): Ape.EndpointData { + const { language, mode, mode2, isDaily } = query; + const searchQuery = { language, mode, mode2, }; - return await apeClient.get(`${BASE_PATH}/rank`, { searchQuery }); + const endpointPath = `${BASE_PATH}${isDaily ? "/daily" : ""}/rank`; + + return await apeClient.get(endpointPath, { searchQuery }); } return { get, getRank }; diff --git a/frontend/src/ts/ape/types/ape.d.ts b/frontend/src/ts/ape/types/ape.d.ts index cf0332b78..b5f4334c0 100644 --- a/frontend/src/ts/ape/types/ape.d.ts +++ b/frontend/src/ts/ape/types/ape.d.ts @@ -34,6 +34,20 @@ declare namespace Ape { type EndpointData = Promise; type Endpoint = () => EndpointData; + declare namespace EndpointTypes { + interface LeaderboardQuery { + language: string; + mode: MonkeyTypes.Mode; + mode2: string | number; + isDaily?: boolean; + } + + interface LeadeboardQueryWithPagination extends LeaderboardQuery { + skip?: number; + limit?: number; + } + } + interface Endpoints { configs: { get: Endpoint; @@ -41,18 +55,8 @@ declare namespace Ape { }; leaderboards: { - get: ( - language: string, - mode: MonkeyTypes.Mode, - mode2: string | number, - skip: number, - limit?: number - ) => EndpointData; - getRank: ( - language: string, - mode: MonkeyTypes.Mode, - mode2: string | number - ) => EndpointData; + get: (query: EndpointTypes.LeadeboardQueryWithPagination) => EndpointData; + getRank: (query: EndpointTypes.LeaderboardQuery) => EndpointData; }; presets: { diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts index 8db6d145a..236ea9f8e 100644 --- a/frontend/src/ts/elements/leaderboards.ts +++ b/frontend/src/ts/elements/leaderboards.ts @@ -5,8 +5,9 @@ import * as Misc from "../utils/misc"; import * as Notifications from "./notifications"; import format from "date-fns/format"; import { Auth } from "../firebase"; +import differenceInSeconds from "date-fns/differenceInSeconds"; -const currentLeaderboard = "time_15"; +let currentTimeRange: "allTime" | "daily" = "allTime"; type LbKey = 15 | 60; @@ -64,17 +65,30 @@ function reset(): void { function stopTimer(): void { clearInterval(updateTimer); updateTimer = undefined; - $("#leaderboards .subTitle").text("Next update in: --:--"); + $("#leaderboards .subTitle").text("-"); } function updateTimerElement(): void { - 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: " + Misc.secondsToString(totalSeconds, true) - ); + 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: " + Misc.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: " + Misc.secondsToString(totalSeconds, true) + ); + } } function startTimer(): void { @@ -133,7 +147,7 @@ function updateFooter(lb: LbKey): void { `); let toppercent; - if (currentRank[lb]) { + if (currentTimeRange === "allTime" && currentRank[lb]) { const num = Misc.roundTo2( (currentRank[lb]["rank"] / (currentRank[lb].count as number)) * 100 ); @@ -142,14 +156,16 @@ function updateFooter(lb: LbKey): void { } else { toppercent = `Top ${num}%`; } + toppercent = `
${toppercent}`; } + if (currentRank[lb]) { const entry = currentRank[lb]; const date = new Date(entry.timestamp); $(`#leaderboardsWrapper table.${side} tfoot`).html(` ${entry.rank} - You
${toppercent} + You${toppercent ? toppercent : ""} ${(Config.alwaysShowCPM ? entry.wpm * 5 : entry.wpm @@ -171,6 +187,8 @@ function updateFooter(lb: LbKey): void { } function checkLbMemory(lb: LbKey): void { + if (currentTimeRange === "daily") return; + let side; if (lb === 15) { side = "left"; @@ -221,6 +239,13 @@ function fillTable(lb: LbKey, prepend?: number): void { } else { side = "right"; } + + if (currentData[lb].length === 0) { + $(`#leaderboardsWrapper table.${side} tbody`).html( + "No results found" + ); + } + const loggedInUserName = DB.getSnapshot()?.name; let a = currentData[lb].length - leaderboardSingleLimit; @@ -242,6 +267,11 @@ function fillTable(lb: LbKey, prepend?: number): void { meClassString = ' class="me"'; } const date = new Date(entry.timestamp); + + if (currentTimeRange === "daily" && !entry.rank) { + entry.rank = i + 1; + } + html += ` ${ @@ -294,24 +324,39 @@ export function hide(): void { ); } -async function update(): Promise { - $("#leaderboardsWrapper .buttons .button").removeClass("active"); - $( - `#leaderboardsWrapper .buttons .button[board=${currentLeaderboard}]` - ).addClass("active"); +function updateTitle(): void { + const el = $("#leaderboardsWrapper .mainTitle"); + const timeRangeString = currentTimeRange === "daily" ? "Daily" : "All-Time"; + + el.text(`${timeRangeString} English Leaderboards`); +} + +async function update(): Promise { showLoader(15); showLoader(60); - const leaderboardRequests = [ - Ape.leaderboards.get("english", "time", "15", 0), - Ape.leaderboards.get("english", "time", "60", 0), - ]; + const timeModes = ["15", "60"]; + + const leaderboardRequests = timeModes.map((mode2) => { + return Ape.leaderboards.get({ + language: "english", + mode: "time", + mode2, + isDaily: currentTimeRange === "daily", + }); + }); if (Auth.currentUser) { leaderboardRequests.push( - Ape.leaderboards.getRank("english", "time", "15"), - Ape.leaderboards.getRank("english", "time", "60") + ...timeModes.map((mode2) => { + return Ape.leaderboards.getRank({ + language: "english", + mode: "time", + mode2, + isDaily: currentTimeRange === "daily", + }); + }) ); } @@ -319,6 +364,8 @@ async function update(): Promise { const failedResponse = responses.find((response) => response.status !== 200); if (failedResponse) { + hideLoader(15); + hideLoader(60); return Notifications.add( "Failed to load leaderboards: " + failedResponse.message, -1 @@ -346,6 +393,12 @@ async function update(): Promise { $("#leaderboardsWrapper .leftTableWrapper").removeClass("invisible"); $("#leaderboardsWrapper .rightTableWrapper").removeClass("invisible"); + + updateTitle(); + $("#leaderboardsWrapper .buttons .button").removeClass("active"); + $( + `#leaderboardsWrapper .buttonGroup.timeRange .button.` + currentTimeRange + ).addClass("active"); } async function requestMore(lb: LbKey, prepend = false): Promise { @@ -363,13 +416,14 @@ async function requestMore(lb: LbKey, prepend = false): Promise { skipVal = 0; } - const response = await Ape.leaderboards.get( - "english", - "time", - lb, - skipVal, - limitVal - ); + const response = await Ape.leaderboards.get({ + language: "english", + mode: "time", + mode2: lb.toString(), + isDaily: currentTimeRange === "daily", + skip: skipVal, + limit: limitVal, + }); const data: MonkeyTypes.LeaderboardEntry[] = response.data; if (response.status !== 200 || data.length === 0) { @@ -392,7 +446,13 @@ async function requestMore(lb: LbKey, prepend = false): Promise { async function requestNew(lb: LbKey, skip: number): Promise { showLoader(lb); - const response = await Ape.leaderboards.get("english", "time", lb, skip); + const response = await Ape.leaderboards.get({ + language: "english", + mode: "time", + mode2: lb.toString(), + isDaily: currentTimeRange === "daily", + skip, + }); const data: MonkeyTypes.LeaderboardEntry[] = response.data; if (response.status === 503) { @@ -522,7 +582,7 @@ $("#leaderboardsWrapper #leaderboards .leftTableJumpToTop").on( $("#leaderboardsWrapper #leaderboards .leftTableJumpToMe").on( "click", async () => { - if (currentRank[15].rank === undefined) return; + if (!currentRank[15]?.rank) return; leftScrollEnabled = false; await requestNew(15, currentRank[15].rank - leaderboardSingleLimit / 2); const rowHeight = $( @@ -559,7 +619,7 @@ $("#leaderboardsWrapper #leaderboards .rightTableJumpToTop").on( $("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").on( "click", async () => { - if (currentRank[60].rank === undefined) return; + if (!currentRank[60]?.rank) return; leftScrollEnabled = false; await requestNew(60, currentRank[60].rank - leaderboardSingleLimit / 2); const rowHeight = $( @@ -583,6 +643,20 @@ $("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").on( } ); +$( + "#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .allTime" +).on("click", () => { + currentTimeRange = "allTime"; + update(); +}); + +$( + "#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .daily" +).on("click", () => { + currentTimeRange = "daily"; + update(); +}); + $(document).on("click", "#top #menu .text-button", (e) => { if ($(e.currentTarget).hasClass("leaderboards")) { show(); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index a9d39dea5..101a18cf1 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1613,6 +1613,17 @@ export async function finish(difficultyFailed = false): Promise { ); } + if (response.data.dailyLeaderboardRank) { + Notifications.add( + `New ${completedEvent.mode} ${completedEvent.mode2} rank: ` + + Misc.getPositionString(response.data.dailyLeaderboardRank), + 1, + 10, + "Daily Leaderboard", + "list-ol" + ); + } + $("#retrySavingResultButton").addClass("hidden"); } diff --git a/frontend/static/html/popups.html b/frontend/static/html/popups.html index 091cc8710..23a16ab8a 100644 --- a/frontend/static/html/popups.html +++ b/frontend/static/html/popups.html @@ -674,20 +674,20 @@