mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
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 <bartnikjack@gmail.com>
This commit is contained in:
parent
66c912e817
commit
0ef52ed9da
22
backend/__tests__/dal/ape-keys.spec.ts
Normal file
22
backend/__tests__/dal/ape-keys.spec.ts
Normal file
|
@ -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());
|
||||
});
|
||||
});
|
93
backend/__tests__/utils/daily-leaderboards.spec.ts
Normal file
93
backend/__tests__/utils/daily-leaderboards.spec.ts
Normal file
|
@ -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
|
||||
});
|
40
backend/__tests__/utils/misc.spec.ts
Normal file
40
backend/__tests__/utils/misc.spec.ts
Normal file
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
165
backend/package-lock.json
generated
165
backend/package-lock.json
generated
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
38
backend/redis-scripts/add-result.lua
Normal file
38
backend/redis-scripts/add-result.lua
Normal file
|
@ -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
|
18
backend/redis-scripts/get-results.lua
Normal file
18
backend/redis-scripts/get-results.lua
Normal file
|
@ -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
|
|
@ -73,6 +73,12 @@ beforeEach(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
const realDateNow = Date.now;
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = realDateNow;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await connection.close();
|
||||
});
|
||||
|
|
|
@ -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<MonkeyResponse> {
|
||||
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<MonkeyResponse> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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<MonkeyResponse> {
|
||||
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<void> {
|
||||
if (connected) {
|
||||
return;
|
||||
|
@ -27,15 +46,19 @@ export async function connect(): Promise<void> {
|
|||
|
||||
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."
|
||||
);
|
||||
|
|
67
backend/src/jobs/announce-daily-leaderboards.ts
Normal file
67
backend/src/jobs/announce-daily-leaderboards.ts
Normal file
|
@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
|
@ -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];
|
||||
|
|
|
@ -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<Server> {
|
|||
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<Server> {
|
|||
Logger.info("Initializing task queues...");
|
||||
initJobQueue(RedisClient.getConnection());
|
||||
Logger.success("Task queues initialized");
|
||||
|
||||
initializeDailyLeaderboardsCache(liveConfiguration.dailyLeaderboards);
|
||||
}
|
||||
|
||||
Logger.info("Starting cron jobs...");
|
||||
|
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const taskName = "announceDailyLeaderboardTopResults";
|
||||
|
||||
const dailyLeaderboardTopResultsTask = buildGeorgeTask(taskName, [
|
||||
taskName,
|
||||
[leaderboardId, leaderboardTimestamp, topResults],
|
||||
]);
|
||||
|
||||
await addToQueue(taskName, dailyLeaderboardTopResultsTask);
|
||||
}
|
||||
|
|
14
backend/src/types/types.d.ts
vendored
14
backend/src/types/types.d.ts
vendored
|
@ -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 {
|
||||
|
|
213
backend/src/utils/daily-leaderboards.ts
Normal file
213
backend/src/utils/daily-leaderboards.ts
Normal file
|
@ -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<number> {
|
||||
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<DailyLeaderboardEntry[]> {
|
||||
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<DailyLeaderboardEntry | null> {
|
||||
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<string, DailyLeaderboard>;
|
||||
|
||||
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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
|
28
frontend/src/ts/ape/types/ape.d.ts
vendored
28
frontend/src/ts/ape/types/ape.d.ts
vendored
|
@ -34,6 +34,20 @@ declare namespace Ape {
|
|||
type EndpointData = Promise<Response>;
|
||||
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: {
|
||||
|
|
|
@ -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 = `<br><span class="sub">${toppercent}</span>`;
|
||||
}
|
||||
|
||||
if (currentRank[lb]) {
|
||||
const entry = currentRank[lb];
|
||||
const date = new Date(entry.timestamp);
|
||||
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
|
||||
<tr>
|
||||
<td>${entry.rank}</td>
|
||||
<td><span class="top">You</span><br><span class="sub">${toppercent}</span></td>
|
||||
<td><span class="top">You</span>${toppercent ? toppercent : ""}</td>
|
||||
<td class="alignRight">${(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(
|
||||
"<tr><td colspan='7'>No results found</td></tr>"
|
||||
);
|
||||
}
|
||||
|
||||
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 += `
|
||||
<tr ${meClassString}>
|
||||
<td>${
|
||||
|
@ -294,24 +324,39 @@ export function hide(): void {
|
|||
);
|
||||
}
|
||||
|
||||
async function update(): Promise<void> {
|
||||
$("#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<void> {
|
||||
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<void> {
|
|||
|
||||
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<void> {
|
|||
|
||||
$("#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<void> {
|
||||
|
@ -363,13 +416,14 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
|
|||
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<void> {
|
|||
async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
||||
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();
|
||||
|
|
|
@ -1613,6 +1613,17 @@ export async function finish(difficultyFailed = false): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
@ -674,20 +674,20 @@
|
|||
<div id="leaderboardsWrapper" class="popupWrapper hidden">
|
||||
<div id="leaderboards">
|
||||
<div class="leaderboardsTop">
|
||||
<div class="mainTitle">Leaderboards</div>
|
||||
<div class="mainTitle">All-Time English Leaderboards</div>
|
||||
<div class="subTitle">Next update in: --:--</div>
|
||||
<!-- <div class="buttons">
|
||||
<div class="buttonGroup">
|
||||
<div class="button active" board="time_15">time 15</div>
|
||||
<div class="button" board="time_60">time 60</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="buttons">
|
||||
<div class="buttonGroup timeRange">
|
||||
<div class="button allTime">all-time</div>
|
||||
<div class="button daily">daily</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tables">
|
||||
<div class="titleAndTable">
|
||||
<div class="titleAndButtons">
|
||||
<div class="title">
|
||||
English Time 15
|
||||
Time 15
|
||||
<i
|
||||
class="hidden leftTableLoader fas fa-fw fa-spin fa-circle-notch"
|
||||
></i>
|
||||
|
@ -731,7 +731,7 @@
|
|||
<div class="titleAndTable">
|
||||
<div class="titleAndButtons">
|
||||
<div class="title">
|
||||
English Time 60
|
||||
Time 60
|
||||
<i
|
||||
class="hidden rightTableLoader fas fa-fw fa-spin fa-circle-notch"
|
||||
></i>
|
||||
|
|
Loading…
Reference in a new issue