mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
Merge branch 'master' into newtribemerge
This commit is contained in:
commit
b3865152e4
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
|
@ -2,6 +2,9 @@
|
|||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"vitest.explorer",
|
||||
"huntertran.auto-markdown-toc"
|
||||
"huntertran.auto-markdown-toc",
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
|
|
37
backend/__tests__/__testData__/auth.ts
Normal file
37
backend/__tests__/__testData__/auth.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { Configuration } from "@monkeytype/shared-types";
|
||||
import { randomBytes } from "crypto";
|
||||
import { hash } from "bcrypt";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { base64UrlEncode } from "../../src/utils/misc";
|
||||
import * as ApeKeyDal from "../../src/dal/ape-keys";
|
||||
|
||||
export async function mockAuthenticateWithApeKey(
|
||||
uid: string,
|
||||
config: Configuration
|
||||
): Promise<string> {
|
||||
if (!config.apeKeys.acceptKeys)
|
||||
throw Error("config.apeKeys.acceptedKeys needs to be set to true");
|
||||
const { apeKeyBytes, apeKeySaltRounds } = config.apeKeys;
|
||||
|
||||
const apiKey = randomBytes(apeKeyBytes).toString("base64url");
|
||||
const saltyHash = await hash(apiKey, apeKeySaltRounds);
|
||||
|
||||
const apeKey: MonkeyTypes.ApeKeyDB = {
|
||||
_id: new ObjectId(),
|
||||
name: "bob",
|
||||
enabled: true,
|
||||
uid,
|
||||
hash: saltyHash,
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: -1,
|
||||
useCount: 0,
|
||||
};
|
||||
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
vi.spyOn(ApeKeyDal, "getApeKey").mockResolvedValue(apeKey);
|
||||
vi.spyOn(ApeKeyDal, "updateLastUsedOn").mockResolvedValue();
|
||||
|
||||
return base64UrlEncode(`${apeKeyId}.${apiKey}`);
|
||||
}
|
|
@ -15,7 +15,7 @@ describe("ApeKeyController", () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
await enableApeKeysEndpoints(true);
|
||||
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: true }));
|
||||
getUserMock.mockResolvedValue(user(uid, {}));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load diff
144
backend/__tests__/api/controllers/public.spec.ts
Normal file
144
backend/__tests__/api/controllers/public.spec.ts
Normal file
|
@ -0,0 +1,144 @@
|
|||
import request from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import * as PublicDal from "../../../src/dal/public";
|
||||
const mockApp = request(app);
|
||||
|
||||
describe("PublicController", () => {
|
||||
describe("get speed histogram", () => {
|
||||
const getSpeedHistogramMock = vi.spyOn(PublicDal, "getSpeedHistogram");
|
||||
|
||||
afterEach(() => {
|
||||
getSpeedHistogramMock.mockReset();
|
||||
});
|
||||
|
||||
it("gets for english time 60", async () => {
|
||||
//GIVEN
|
||||
getSpeedHistogramMock.mockResolvedValue({ "0": 1, "10": 2 });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({ language: "english", mode: "time", mode2: "60" });
|
||||
//.expect(200);
|
||||
console.log(body);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Public speed histogram retrieved",
|
||||
data: { "0": 1, "10": 2 },
|
||||
});
|
||||
|
||||
expect(getSpeedHistogramMock).toHaveBeenCalledWith(
|
||||
"english",
|
||||
"time",
|
||||
"60"
|
||||
);
|
||||
});
|
||||
|
||||
it("gets for mode", async () => {
|
||||
for (const mode of ["time", "words", "quote", "zen", "custom"]) {
|
||||
const response = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({ language: "english", mode, mode2: "custom" });
|
||||
expect(response.status, "for mode " + mode).toEqual(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("gets for mode2", async () => {
|
||||
for (const mode2 of [
|
||||
"10",
|
||||
"25",
|
||||
"50",
|
||||
"100",
|
||||
"15",
|
||||
"30",
|
||||
"60",
|
||||
"120",
|
||||
"zen",
|
||||
"custom",
|
||||
]) {
|
||||
const response = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({ language: "english", mode: "words", mode2 });
|
||||
|
||||
expect(response.status, "for mode2 " + mode2).toEqual(200);
|
||||
}
|
||||
});
|
||||
it("fails for missing query", async () => {
|
||||
const { body } = await mockApp.get("/public/speedHistogram").expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: [
|
||||
'"language" Required',
|
||||
'"mode" Required',
|
||||
'"mode2" Needs to be either a number, "zen" or "custom."',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("fails for invalid query", async () => {
|
||||
const { body } = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({
|
||||
language: "en?gli.sh",
|
||||
mode: "unknownMode",
|
||||
mode2: "unknownMode2",
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: [
|
||||
'"language" Can only contain letters [a-zA-Z0-9_+]',
|
||||
`"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`,
|
||||
'"mode2" Needs to be a number or a number represented as a string e.g. "10".',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("fails for unknown query", async () => {
|
||||
const { body } = await mockApp
|
||||
.get("/public/speedHistogram")
|
||||
.query({
|
||||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
extra: "value",
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("get typing stats", () => {
|
||||
const getTypingStatsMock = vi.spyOn(PublicDal, "getTypingStats");
|
||||
|
||||
afterEach(() => {
|
||||
getTypingStatsMock.mockReset();
|
||||
});
|
||||
|
||||
it("gets without authentication", async () => {
|
||||
//GIVEN
|
||||
getTypingStatsMock.mockResolvedValue({
|
||||
testsCompleted: 23,
|
||||
testsStarted: 42,
|
||||
timeTyping: 1000,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/public/typingStats").expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Public typing stats retrieved",
|
||||
data: {
|
||||
testsCompleted: 23,
|
||||
testsStarted: 42,
|
||||
timeTyping: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,6 +4,8 @@ import * as UserDal from "../../src/dal/user";
|
|||
import * as LeaderboardsDal from "../../src/dal/leaderboards";
|
||||
import * as PublicDal from "../../src/dal/public";
|
||||
import * as Configuration from "../../src/init/configuration";
|
||||
import type { DBLeaderboardEntry } from "../../src/dal/leaderboards";
|
||||
import type { PersonalBest } from "@monkeytype/contracts/schemas/shared";
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
||||
import * as DB from "../../src/init/db";
|
||||
|
@ -29,7 +31,9 @@ describe("LeaderboardsDal", () => {
|
|||
|
||||
//THEN
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("uid", applicableUser.uid);
|
||||
expect(
|
||||
(result as LeaderboardsDal.DBLeaderboardEntry[])[0]
|
||||
).toHaveProperty("uid", applicableUser.uid);
|
||||
});
|
||||
|
||||
it("should create leaderboard time english 15", async () => {
|
||||
|
@ -46,7 +50,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -72,7 +76,7 @@ describe("LeaderboardsDal", () => {
|
|||
"60",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -98,7 +102,7 @@ describe("LeaderboardsDal", () => {
|
|||
"60",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(lb[0]).not.toHaveProperty("discordId");
|
||||
|
@ -121,7 +125,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(lb[0]).not.toHaveProperty("consistency");
|
||||
|
@ -183,7 +187,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -219,7 +223,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -251,7 +255,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(result[0]?.isPremium).toBeUndefined();
|
||||
|
@ -263,8 +267,10 @@ function expectedLbEntry(
|
|||
time: string,
|
||||
{ rank, user, badgeId, isPremium }: ExpectedLbEntry
|
||||
) {
|
||||
const lbBest: SharedTypes.PersonalBest =
|
||||
user.lbPersonalBests?.time[time].english;
|
||||
// @ts-expect-error
|
||||
const lbBest: PersonalBest =
|
||||
// @ts-expect-error
|
||||
user.lbPersonalBests?.time[Number.parseInt(time)].english;
|
||||
|
||||
return {
|
||||
rank,
|
||||
|
@ -308,10 +314,10 @@ async function createUser(
|
|||
}
|
||||
|
||||
function lbBests(
|
||||
pb15?: SharedTypes.PersonalBest,
|
||||
pb60?: SharedTypes.PersonalBest
|
||||
pb15?: PersonalBest,
|
||||
pb60?: PersonalBest
|
||||
): MonkeyTypes.LbPersonalBests {
|
||||
const result = { time: {} };
|
||||
const result: MonkeyTypes.LbPersonalBests = { time: {} };
|
||||
if (pb15) result.time["15"] = { english: pb15 };
|
||||
if (pb60) result.time["60"] = { english: pb60 };
|
||||
return result;
|
||||
|
@ -321,7 +327,7 @@ function pb(
|
|||
wpm: number,
|
||||
acc: number = 90,
|
||||
timestamp: number = 1
|
||||
): SharedTypes.PersonalBest {
|
||||
): PersonalBest {
|
||||
return {
|
||||
acc,
|
||||
consistency: 100,
|
||||
|
@ -335,7 +341,7 @@ function pb(
|
|||
};
|
||||
}
|
||||
|
||||
function premium(expirationDeltaSeconds) {
|
||||
function premium(expirationDeltaSeconds: number) {
|
||||
return {
|
||||
premium: {
|
||||
startTimestamp: 0,
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"start": "node ./dist/server.js",
|
||||
"test": "vitest run",
|
||||
"test-coverage": "vitest run --coverage",
|
||||
"dev": "concurrently \"tsx watch --clear-screen=false --inspect ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"npx eslint-watch \"./src/**/*.ts\"\"",
|
||||
"dev": "concurrently -p none \"tsx watch --clear-screen=false --inspect ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"esw src/ -w --ext .ts --cache --color\"",
|
||||
"knip": "knip",
|
||||
"docker-db-only": "docker compose -f docker/compose.db-only.yml up",
|
||||
"docker": "docker compose -f docker/compose.yml up",
|
||||
|
@ -65,7 +65,7 @@
|
|||
"@monkeytype/eslint-config": "workspace:*",
|
||||
"@monkeytype/shared-types": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"@redocly/cli": "1.18.1",
|
||||
"@redocly/cli": "1.19.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/cron": "1.7.3",
|
||||
|
@ -85,7 +85,7 @@
|
|||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"concurrently": "8.2.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-watch": "8.0.0",
|
||||
|
@ -95,7 +95,7 @@
|
|||
"supertest": "6.2.3",
|
||||
"tsx": "4.16.2",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "1.6.0",
|
||||
"vitest": "2.0.5",
|
||||
"vitest-mongodb": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,16 @@ export function getOpenApi(): OpenAPIObject {
|
|||
description: "Ape keys provide access to certain API endpoints.",
|
||||
"x-displayName": "Ape Keys",
|
||||
},
|
||||
{
|
||||
name: "public",
|
||||
description: "Public endpoints such as typing stats.",
|
||||
"x-displayName": "public",
|
||||
},
|
||||
{
|
||||
name: "leaderboards",
|
||||
description: "All-time and daily leaderboards of the fastest typers.",
|
||||
"x-displayName": "Leaderboards",
|
||||
},
|
||||
{
|
||||
name: "psas",
|
||||
description: "Public service announcements.",
|
||||
|
|
|
@ -4,85 +4,81 @@ import {
|
|||
MILLISECONDS_IN_DAY,
|
||||
getCurrentWeekTimestamp,
|
||||
} from "../../utils/misc";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import * as LeaderboardsDAL from "../../dal/leaderboards";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
|
||||
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
||||
import {
|
||||
GetDailyLeaderboardQuery,
|
||||
GetDailyLeaderboardRankQuery,
|
||||
GetLeaderboardDailyRankResponse,
|
||||
GetLeaderboardQuery,
|
||||
GetLeaderboardRankResponse,
|
||||
GetLeaderboardResponse as GetLeaderboardResponse,
|
||||
GetWeeklyXpLeaderboardQuery,
|
||||
GetWeeklyXpLeaderboardRankResponse,
|
||||
GetWeeklyXpLeaderboardResponse,
|
||||
LanguageAndModeQuery,
|
||||
} from "@monkeytype/contracts/leaderboards";
|
||||
import { Configuration } from "@monkeytype/shared-types";
|
||||
|
||||
export async function getLeaderboard(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { language, mode, mode2, skip, limit = 50 } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const queryLimit = Math.min(parseInt(limit as string, 10), 50);
|
||||
req: MonkeyTypes.Request2<GetLeaderboardQuery>
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { language, mode, mode2, skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const leaderboard = await LeaderboardsDAL.get(
|
||||
mode as string,
|
||||
mode2 as string,
|
||||
language as string,
|
||||
parseInt(skip as string, 10),
|
||||
queryLimit
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
skip,
|
||||
limit
|
||||
);
|
||||
|
||||
if (leaderboard === false) {
|
||||
return new MonkeyResponse(
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
null,
|
||||
503
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds."
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedLeaderboard = _.map(leaderboard, (entry) => {
|
||||
return uid && entry.uid === uid
|
||||
? entry
|
||||
: _.omit(entry, ["_id", "difficulty", "language"]);
|
||||
});
|
||||
const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"]));
|
||||
|
||||
return new MonkeyResponse("Leaderboard retrieved", normalizedLeaderboard);
|
||||
return new MonkeyResponse2("Leaderboard retrieved", normalizedLeaderboard);
|
||||
}
|
||||
|
||||
export async function getRankFromLeaderboard(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<LanguageAndModeQuery>
|
||||
): Promise<GetLeaderboardRankResponse> {
|
||||
const { language, mode, mode2 } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = await LeaderboardsDAL.getRank(
|
||||
mode as string,
|
||||
mode2 as string,
|
||||
language as string,
|
||||
uid
|
||||
);
|
||||
const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid);
|
||||
if (data === false) {
|
||||
return new MonkeyResponse(
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
null,
|
||||
503
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds."
|
||||
);
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Rank retrieved", data);
|
||||
return new MonkeyResponse2("Rank retrieved", data);
|
||||
}
|
||||
|
||||
function getDailyLeaderboardWithError(
|
||||
req: MonkeyTypes.Request
|
||||
{ language, mode, mode2, daysBefore }: GetDailyLeaderboardRankQuery,
|
||||
config: Configuration["dailyLeaderboards"]
|
||||
): DailyLeaderboards.DailyLeaderboard {
|
||||
const { language, mode, mode2, daysBefore } = req.query;
|
||||
|
||||
const normalizedDayBefore = parseInt(daysBefore as string, 10);
|
||||
const currentDayTimestamp = getCurrentDayTimestamp();
|
||||
const dayBeforeTimestamp =
|
||||
currentDayTimestamp - normalizedDayBefore * MILLISECONDS_IN_DAY;
|
||||
|
||||
const customTimestamp = _.isNil(daysBefore) ? -1 : dayBeforeTimestamp;
|
||||
const customTimestamp =
|
||||
daysBefore === undefined
|
||||
? -1
|
||||
: getCurrentDayTimestamp() - daysBefore * MILLISECONDS_IN_DAY;
|
||||
|
||||
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
|
||||
language as string,
|
||||
mode as string,
|
||||
mode2 as string,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
config,
|
||||
customTimestamp
|
||||
);
|
||||
if (!dailyLeaderboard) {
|
||||
|
@ -93,14 +89,17 @@ function getDailyLeaderboardWithError(
|
|||
}
|
||||
|
||||
export async function getDailyLeaderboard(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetDailyLeaderboardQuery>
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(req);
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
const minRank = parseInt(skip as string, 10);
|
||||
const maxRank = minRank + parseInt(limit as string, 10) - 1;
|
||||
const minRank = skip;
|
||||
const maxRank = minRank + limit - 1;
|
||||
|
||||
const topResults = await dailyLeaderboard.getResults(
|
||||
minRank,
|
||||
|
@ -109,40 +108,37 @@ export async function getDailyLeaderboard(
|
|||
req.ctx.configuration.users.premium.enabled
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard retrieved", topResults);
|
||||
return new MonkeyResponse2("Daily leaderboard retrieved", topResults);
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboardRank(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetDailyLeaderboardRankQuery>
|
||||
): Promise<GetLeaderboardDailyRankResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(req);
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
const rank = await dailyLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
|
||||
return new MonkeyResponse2("Daily leaderboard rank retrieved", rank);
|
||||
}
|
||||
|
||||
function getWeeklyXpLeaderboardWithError(
|
||||
req: MonkeyTypes.Request
|
||||
{ weeksBefore }: GetWeeklyXpLeaderboardQuery,
|
||||
config: Configuration["leaderboards"]["weeklyXp"]
|
||||
): WeeklyXpLeaderboard.WeeklyXpLeaderboard {
|
||||
const { weeksBefore } = req.query;
|
||||
const customTimestamp =
|
||||
weeksBefore === undefined
|
||||
? -1
|
||||
: getCurrentWeekTimestamp() - weeksBefore * MILLISECONDS_IN_DAY * 7;
|
||||
|
||||
const normalizedWeeksBefore = parseInt(weeksBefore as string, 10);
|
||||
const currentWeekTimestamp = getCurrentWeekTimestamp();
|
||||
const weekBeforeTimestamp =
|
||||
currentWeekTimestamp - normalizedWeeksBefore * MILLISECONDS_IN_DAY * 7;
|
||||
|
||||
const customTimestamp = _.isNil(weeksBefore) ? -1 : weekBeforeTimestamp;
|
||||
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
customTimestamp
|
||||
);
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(config, customTimestamp);
|
||||
if (!weeklyXpLeaderboard) {
|
||||
throw new MonkeyError(404, "XP leaderboard for this week not found.");
|
||||
}
|
||||
|
@ -151,33 +147,39 @@ function getWeeklyXpLeaderboardWithError(
|
|||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardResults(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetWeeklyXpLeaderboardQuery>
|
||||
): Promise<GetWeeklyXpLeaderboardResponse> {
|
||||
const { skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const minRank = parseInt(skip as string, 10);
|
||||
const maxRank = minRank + parseInt(limit as string, 10) - 1;
|
||||
const minRank = skip;
|
||||
const maxRank = minRank + limit - 1;
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req);
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
const results = await weeklyXpLeaderboard.getResults(
|
||||
minRank,
|
||||
maxRank,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard retrieved", results);
|
||||
return new MonkeyResponse2("Weekly xp leaderboard retrieved", results);
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardRank(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetWeeklyXpLeaderboardRankResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req);
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
{},
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
const rankEntry = await weeklyXpLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry);
|
||||
return new MonkeyResponse2("Weekly xp leaderboard rank retrieved", rankEntry);
|
||||
}
|
||||
|
|
|
@ -1,21 +1,22 @@
|
|||
import {
|
||||
GetSpeedHistogramQuery,
|
||||
GetSpeedHistogramResponse,
|
||||
GetTypingStatsResponse,
|
||||
} from "@monkeytype/contracts/public";
|
||||
import * as PublicDAL from "../../dal/public";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
|
||||
export async function getPublicSpeedHistogram(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
export async function getSpeedHistogram(
|
||||
req: MonkeyTypes.Request2<GetSpeedHistogramQuery>
|
||||
): Promise<GetSpeedHistogramResponse> {
|
||||
const { language, mode, mode2 } = req.query;
|
||||
const data = await PublicDAL.getSpeedHistogram(
|
||||
language as string,
|
||||
mode as string,
|
||||
mode2 as string
|
||||
);
|
||||
return new MonkeyResponse("Public speed histogram retrieved", data);
|
||||
const data = await PublicDAL.getSpeedHistogram(language, mode, mode2);
|
||||
return new MonkeyResponse2("Public speed histogram retrieved", data);
|
||||
}
|
||||
|
||||
export async function getPublicTypingStats(
|
||||
_req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
export async function getTypingStats(
|
||||
_req: MonkeyTypes.Request2
|
||||
): Promise<GetTypingStatsResponse> {
|
||||
const data = await PublicDAL.getTypingStats();
|
||||
return new MonkeyResponse("Public typing stats retrieved", data);
|
||||
return new MonkeyResponse2("Public typing stats retrieved", data);
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ const commonMiddleware = [
|
|||
}),
|
||||
checkUserPermissions({
|
||||
criteria: (user) => {
|
||||
return user.canManageApeKeys ?? false;
|
||||
return user.canManageApeKeys ?? true;
|
||||
},
|
||||
invalidMessage: "You have lost access to ape keys, please contact support",
|
||||
}),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Router } from "express";
|
||||
import { Response, Router } from "express";
|
||||
import * as swaggerUi from "swagger-ui-express";
|
||||
import publicSwaggerSpec from "../../documentation/public-swagger.json";
|
||||
|
||||
|
@ -12,6 +12,7 @@ const router = Router();
|
|||
const root = __dirname + "../../../static";
|
||||
|
||||
router.use("/v2/internal", (req, res) => {
|
||||
setCsp(res);
|
||||
res.sendFile("api/internal.html", { root });
|
||||
});
|
||||
|
||||
|
@ -21,6 +22,7 @@ router.use("/v2/internal.json", (req, res) => {
|
|||
});
|
||||
|
||||
router.use(["/v2/public", "/v2/"], (req, res) => {
|
||||
setCsp(res);
|
||||
res.sendFile("api/public.html", { root });
|
||||
});
|
||||
|
||||
|
@ -38,3 +40,10 @@ router.use(
|
|||
);
|
||||
|
||||
export default router;
|
||||
|
||||
function setCsp(res: Response): void {
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redoc.ly data:;object-src 'none';script-src 'self' cdn.redoc.ly 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@ import leaderboards from "./leaderboards";
|
|||
import addSwaggerMiddlewares from "./swagger";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { recordClientVersion } from "../../utils/prometheus";
|
||||
import {
|
||||
Application,
|
||||
IRouter,
|
||||
|
@ -45,8 +44,6 @@ const APP_START_TIME = Date.now();
|
|||
const API_ROUTE_MAP = {
|
||||
"/users": users,
|
||||
"/results": results,
|
||||
"/public": publicStats,
|
||||
"/leaderboards": leaderboards,
|
||||
"/quotes": quotes,
|
||||
"/webhooks": webhooks,
|
||||
"/docs": docs,
|
||||
|
@ -59,6 +56,8 @@ const router = s.router(contract, {
|
|||
configs,
|
||||
presets,
|
||||
psas,
|
||||
public: publicStats,
|
||||
leaderboards,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
|
@ -80,16 +79,29 @@ export function addApiRoutes(app: Application): void {
|
|||
function applyTsRestApiRoutes(app: IRouter): void {
|
||||
createExpressEndpoints(contract, router, app, {
|
||||
jsonQuery: true,
|
||||
requestValidationErrorHandler(err, req, res, next) {
|
||||
if (err.body?.issues === undefined) {
|
||||
requestValidationErrorHandler(err, _req, res, next) {
|
||||
let message: string | undefined = undefined;
|
||||
let validationErrors: string[] | undefined = undefined;
|
||||
|
||||
if (err.pathParams?.issues !== undefined) {
|
||||
message = "Invalid path parameter schema";
|
||||
validationErrors = err.pathParams.issues.map(prettyErrorMessage);
|
||||
} else if (err.query?.issues !== undefined) {
|
||||
message = "Invalid query schema";
|
||||
validationErrors = err.query.issues.map(prettyErrorMessage);
|
||||
} else if (err.body?.issues !== undefined) {
|
||||
message = "Invalid request data schema";
|
||||
validationErrors = err.body.issues.map(prettyErrorMessage);
|
||||
}
|
||||
|
||||
if (message !== undefined) {
|
||||
res
|
||||
.status(422)
|
||||
.json({ message, validationErrors } as MonkeyValidationError);
|
||||
} else {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const issues = err.body?.issues.map(prettyErrorMessage);
|
||||
res.status(422).json({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: issues,
|
||||
} as MonkeyValidationError);
|
||||
},
|
||||
globalMiddleware: [authenticateTsRestRequest()],
|
||||
});
|
||||
|
@ -113,7 +125,9 @@ function applyDevApiRoutes(app: Application): void {
|
|||
app.use(async (req, res, next) => {
|
||||
const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
|
||||
if (slowdown > 0) {
|
||||
Logger.info(`Simulating ${slowdown}ms delay for ${req.path}`);
|
||||
Logger.info(
|
||||
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, slowdown));
|
||||
}
|
||||
next();
|
||||
|
@ -141,20 +155,6 @@ function applyApiRoutes(app: Application): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (req.path === "/psas") {
|
||||
const clientVersion =
|
||||
(req.headers["x-client-version"] as string) ||
|
||||
req.headers["client-version"];
|
||||
recordClientVersion(clientVersion?.toString() ?? "unknown");
|
||||
}
|
||||
|
||||
if (req.path.startsWith("/docs")) {
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redoc.ly data:;object-src 'none';script-src 'self' cdn.redoc.ly 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests"
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,41 +1,11 @@
|
|||
import joi from "joi";
|
||||
import { Router } from "express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { withApeRateLimiter } from "../../middlewares/ape-rate-limit";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import * as LeaderboardController from "../controllers/leaderboard";
|
||||
import { validate } from "../../middlewares/configuration";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import * as LeaderboardController from "../controllers/leaderboard";
|
||||
|
||||
const BASE_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
language: joi
|
||||
.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_+]+$/)
|
||||
.required(),
|
||||
mode: joi
|
||||
.string()
|
||||
.valid("time", "words", "quote", "zen", "custom")
|
||||
.required(),
|
||||
mode2: joi
|
||||
.string()
|
||||
.regex(/^(\d)+|custom|zen/)
|
||||
.required(),
|
||||
};
|
||||
|
||||
const LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT = {
|
||||
...BASE_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
skip: joi.number().min(0),
|
||||
limit: joi.number().min(0).max(50),
|
||||
};
|
||||
|
||||
const DAILY_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
...LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
|
||||
daysBefore: joi.number().min(1).max(1),
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const requireDailyLeaderboardsEnabled = validate({
|
||||
criteria: (configuration) => {
|
||||
|
@ -44,58 +14,6 @@ const requireDailyLeaderboardsEnabled = validate({
|
|||
invalidMessage: "Daily leaderboards are not available at this time.",
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateRequest({ isPublic: true }),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
validateRequest({
|
||||
query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getLeaderboard)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rank",
|
||||
authenticateRequest({ acceptApeKeys: true }),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
validateRequest({
|
||||
query: BASE_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getRankFromLeaderboard)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/daily",
|
||||
requireDailyLeaderboardsEnabled,
|
||||
authenticateRequest({ isPublic: true }),
|
||||
RateLimit.leaderboardsGet,
|
||||
validateRequest({
|
||||
query: DAILY_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getDailyLeaderboard)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/daily/rank",
|
||||
requireDailyLeaderboardsEnabled,
|
||||
authenticateRequest(),
|
||||
RateLimit.leaderboardsGet,
|
||||
validateRequest({
|
||||
query: DAILY_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getDailyLeaderboardRank)
|
||||
);
|
||||
|
||||
const BASE_XP_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
skip: joi.number().min(0),
|
||||
limit: joi.number().min(0).max(50),
|
||||
};
|
||||
|
||||
const WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
...BASE_XP_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
weeksBefore: joi.number().min(1).max(1),
|
||||
};
|
||||
|
||||
const requireWeeklyXpLeaderboardEnabled = validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.leaderboards.weeklyXp.enabled;
|
||||
|
@ -103,23 +21,36 @@ const requireWeeklyXpLeaderboardEnabled = validate({
|
|||
invalidMessage: "Weekly XP leaderboards are not available at this time.",
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/xp/weekly",
|
||||
requireWeeklyXpLeaderboardEnabled,
|
||||
authenticateRequest({ isPublic: true }),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
validateRequest({
|
||||
query: WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getWeeklyXpLeaderboardResults)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/xp/weekly/rank",
|
||||
requireWeeklyXpLeaderboardEnabled,
|
||||
authenticateRequest(),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
asyncHandler(LeaderboardController.getWeeklyXpLeaderboardRank)
|
||||
);
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(leaderboardsContract, {
|
||||
get: {
|
||||
middleware: [RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getLeaderboard)(r),
|
||||
},
|
||||
getRank: {
|
||||
middleware: [withApeRateLimiter(RateLimit.leaderboardsGet)],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getRankFromLeaderboard)(r),
|
||||
},
|
||||
getDaily: {
|
||||
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboard)(r),
|
||||
},
|
||||
getDailyRank: {
|
||||
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboardRank)(r),
|
||||
},
|
||||
getWeeklyXp: {
|
||||
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r),
|
||||
},
|
||||
getWeeklyXpRank: {
|
||||
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -3,11 +3,12 @@ import { initServer } from "@ts-rest/express";
|
|||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import * as PsaController from "../controllers/psa";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
import { recordClientVersion } from "../../middlewares/utility";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(psasContract, {
|
||||
get: {
|
||||
middleware: [RateLimit.psaGet],
|
||||
middleware: [recordClientVersion(), RateLimit.psaGet],
|
||||
handler: async (r) => callController(PsaController.getPsas)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,41 +1,17 @@
|
|||
import { Router } from "express";
|
||||
import * as PublicController from "../controllers/public";
|
||||
import { publicContract } from "@monkeytype/contracts/public";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import joi from "joi";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
import * as PublicController from "../controllers/public";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const GET_MODE_STATS_VALIDATION_SCHEMA = {
|
||||
language: joi
|
||||
.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_+]+$/)
|
||||
.required(),
|
||||
mode: joi
|
||||
.string()
|
||||
.valid("time", "words", "quote", "zen", "custom")
|
||||
.required(),
|
||||
mode2: joi
|
||||
.string()
|
||||
.regex(/^(\d)+|custom|zen/)
|
||||
.required(),
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/speedHistogram",
|
||||
RateLimit.publicStatsGet,
|
||||
validateRequest({
|
||||
query: GET_MODE_STATS_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(PublicController.getPublicSpeedHistogram)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/typingStats",
|
||||
RateLimit.publicStatsGet,
|
||||
asyncHandler(PublicController.getPublicTypingStats)
|
||||
);
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(publicContract, {
|
||||
getSpeedHistogram: {
|
||||
middleware: [RateLimit.publicStatsGet],
|
||||
handler: async (r) => callController(PublicController.getSpeedHistogram)(r),
|
||||
},
|
||||
getTypingStats: {
|
||||
middleware: [RateLimit.publicStatsGet],
|
||||
handler: async (r) => callController(PublicController.getTypingStats)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -4,8 +4,27 @@ import { performance } from "perf_hooks";
|
|||
import { setLeaderboard } from "../utils/prometheus";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
|
||||
import { addLog } from "./logs";
|
||||
import { Collection } from "mongodb";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
LeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { omit } from "lodash";
|
||||
|
||||
export type DBLeaderboardEntry = LeaderboardEntry & {
|
||||
_id: ObjectId;
|
||||
};
|
||||
|
||||
export const getCollection = (key: {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
}): Collection<DBLeaderboardEntry> =>
|
||||
db.collection<DBLeaderboardEntry>(
|
||||
`leaderboards.${key.language}.${key.mode}.${key.mode2}`
|
||||
);
|
||||
|
||||
export async function get(
|
||||
mode: string,
|
||||
|
@ -13,14 +32,13 @@ export async function get(
|
|||
language: string,
|
||||
skip: number,
|
||||
limit = 50
|
||||
): Promise<LeaderboardEntry[] | false> {
|
||||
): Promise<DBLeaderboardEntry[] | false> {
|
||||
//if (leaderboardUpdating[`${language}_${mode}_${mode2}`]) return false;
|
||||
|
||||
if (limit > 50 || limit <= 0) limit = 50;
|
||||
if (skip < 0) skip = 0;
|
||||
try {
|
||||
const preset = await db
|
||||
.collection<LeaderboardEntry>(`leaderboards.${language}.${mode}.${mode2}`)
|
||||
const preset = await getCollection({ language, mode, mode2 })
|
||||
.find()
|
||||
.sort({ rank: 1 })
|
||||
.skip(skip)
|
||||
|
@ -31,8 +49,9 @@ export async function get(
|
|||
.premium.enabled;
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
preset.forEach((it) => (it.isPremium = undefined));
|
||||
return preset.map((it) => omit(it, "isPremium"));
|
||||
}
|
||||
|
||||
return preset;
|
||||
} catch (e) {
|
||||
if (e.error === 175) {
|
||||
|
@ -43,30 +62,26 @@ export async function get(
|
|||
}
|
||||
}
|
||||
|
||||
type GetRankResponse = {
|
||||
count: number;
|
||||
rank: number | null;
|
||||
entry: LeaderboardEntry | null;
|
||||
};
|
||||
|
||||
export async function getRank(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
uid: string
|
||||
): Promise<GetRankResponse | false> {
|
||||
): Promise<LeaderboardRank | false> {
|
||||
try {
|
||||
const entry = await db
|
||||
.collection<LeaderboardEntry>(`leaderboards.${language}.${mode}.${mode2}`)
|
||||
.findOne({ uid });
|
||||
const count = await db
|
||||
.collection(`leaderboards.${language}.${mode}.${mode2}`)
|
||||
.estimatedDocumentCount();
|
||||
const entry = await getCollection({ language, mode, mode2 }).findOne({
|
||||
uid,
|
||||
});
|
||||
const count = await getCollection({
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
}).estimatedDocumentCount();
|
||||
|
||||
return {
|
||||
count,
|
||||
rank: entry ? entry.rank : null,
|
||||
entry,
|
||||
rank: entry?.rank,
|
||||
entry: entry !== null ? entry : undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e.error === 175) {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import * as db from "../init/db";
|
||||
import { roundTo2 } from "../utils/misc";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
|
||||
import {
|
||||
TypingStats,
|
||||
SpeedHistogram,
|
||||
} from "@monkeytype/contracts/schemas/public";
|
||||
|
||||
type PublicTypingStatsDB = PublicTypingStats & { _id: "stats" };
|
||||
type PublicSpeedStatsDB = {
|
||||
export type PublicTypingStatsDB = TypingStats & { _id: "stats" };
|
||||
export type PublicSpeedStatsDB = {
|
||||
_id: "speedStatsHistogram";
|
||||
english_time_15: SpeedHistogram;
|
||||
english_time_60: SpeedHistogram;
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
CustomTheme,
|
||||
DBResult,
|
||||
MonkeyMail,
|
||||
ResultFilters,
|
||||
UserInventory,
|
||||
UserProfileDetails,
|
||||
UserQuoteRatings,
|
||||
|
@ -33,6 +32,7 @@ import {
|
|||
PersonalBest,
|
||||
} from "@monkeytype/contracts/schemas/shared";
|
||||
import { addImportantLog } from "./logs";
|
||||
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
|
||||
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
|
|
|
@ -23,10 +23,6 @@
|
|||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data"
|
||||
},
|
||||
{
|
||||
"name": "results",
|
||||
"description": "Result data and related operations"
|
||||
|
@ -412,78 +408,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "language",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode2",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "skip",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards/rank": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets a user's rank from a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "language",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode2",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/results": {
|
||||
"get": {
|
||||
"tags": ["results"],
|
||||
|
|
|
@ -20,10 +20,6 @@
|
|||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "results",
|
||||
"description": "User results data and related operations"
|
||||
|
@ -201,97 +197,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets global leaderboard data",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"description": "The leaderboard's language (i.e., english)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"in": "query",
|
||||
"description": "The primary mode (i.e., time)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode2",
|
||||
"in": "query",
|
||||
"description": "The secondary mode (i.e., 60)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"in": "query",
|
||||
"description": "How many leaderboard entries to skip",
|
||||
"required": false,
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "How many leaderboard entries to request",
|
||||
"required": false,
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 50
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LeaderboardEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards/rank": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets your qualifying rank from a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"description": "The leaderboard's language (i.e., english)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"in": "query",
|
||||
"description": "The primary mode (i.e., time)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "mode2",
|
||||
"in": "query",
|
||||
"description": "The secondary mode (i.e., 60)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LeaderboardEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
|
@ -606,70 +511,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"LeaderboardEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {
|
||||
"type": "string",
|
||||
"example": "6226b17aebc27a4a8d1ce04b"
|
||||
},
|
||||
"acc": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 97.96
|
||||
},
|
||||
"consistency": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 83.29
|
||||
},
|
||||
"lazyMode": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Miodec"
|
||||
},
|
||||
"punctuation": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"rank": {
|
||||
"type": "integer",
|
||||
"example": 3506
|
||||
},
|
||||
"raw": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 145.18
|
||||
},
|
||||
"wpm": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 141.18
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "integer",
|
||||
"example": 1644438189583
|
||||
},
|
||||
"discordId": {
|
||||
"type": "string",
|
||||
"example": "974761412044437307"
|
||||
},
|
||||
"discordAvatar": {
|
||||
"type": "string",
|
||||
"example": "6226b17aebc27a4a8d1ce04b"
|
||||
},
|
||||
"badgeIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
|
@ -2,20 +2,21 @@ import { CronJob } from "cron";
|
|||
import GeorgeQueue from "../queues/george-queue";
|
||||
import * as LeaderboardsDAL from "../dal/leaderboards";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
|
||||
const CRON_SCHEDULE = "30 14/15 * * * *";
|
||||
const RECENT_AGE_MINUTES = 10;
|
||||
const RECENT_AGE_MILLISECONDS = RECENT_AGE_MINUTES * 60 * 1000;
|
||||
|
||||
async function getTop10(leaderboardTime: string): Promise<LeaderboardEntry[]> {
|
||||
async function getTop10(
|
||||
leaderboardTime: string
|
||||
): Promise<LeaderboardsDAL.DBLeaderboardEntry[]> {
|
||||
return (await LeaderboardsDAL.get(
|
||||
"time",
|
||||
leaderboardTime,
|
||||
"english",
|
||||
0,
|
||||
10
|
||||
)) as LeaderboardEntry[]; //can do that because gettop10 will not be called during an update
|
||||
)) as LeaderboardsDAL.DBLeaderboardEntry[]; //can do that because gettop10 will not be called during an update
|
||||
}
|
||||
|
||||
async function updateLeaderboardAndNotifyChanges(
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import _ from "lodash";
|
||||
import type { Response, NextFunction, RequestHandler } from "express";
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus";
|
||||
|
||||
export const emptyMiddleware = (
|
||||
_req: MonkeyTypes.Request,
|
||||
|
@ -45,3 +46,18 @@ export function useInProduction(
|
|||
isDevEnvironment() ? emptyMiddleware : middleware
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* record the client version from the `x-client-version` or ` client-version` header to prometheus
|
||||
*/
|
||||
export function recordClientVersion(): RequestHandler {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
const clientVersion =
|
||||
(req.headers["x-client-version"] as string) ||
|
||||
req.headers["client-version"];
|
||||
|
||||
prometheusRecordClientVersion(clientVersion?.toString() ?? "unknown");
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
import { type LbEntryWithRank } from "../utils/daily-leaderboards";
|
||||
import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { MonkeyQueue } from "./monkey-queue";
|
||||
|
||||
const QUEUE_NAME = "george-tasks";
|
||||
|
@ -62,7 +61,7 @@ class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
|
|||
}
|
||||
|
||||
async announceLeaderboardUpdate(
|
||||
newRecords: LeaderboardEntry[],
|
||||
newRecords: Omit<LeaderboardEntry, "_id">[],
|
||||
leaderboardId: string
|
||||
): Promise<void> {
|
||||
const taskName = "announceLeaderboardUpdate";
|
||||
|
@ -90,7 +89,7 @@ class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
|
|||
async announceDailyLeaderboardTopResults(
|
||||
leaderboardId: string,
|
||||
leaderboardTimestamp: number,
|
||||
topResults: LbEntryWithRank[]
|
||||
topResults: LeaderboardEntry[]
|
||||
): Promise<void> {
|
||||
const taskName = "announceDailyLeaderboardTopResults";
|
||||
|
||||
|
|
|
@ -2,25 +2,21 @@ import { Configuration } from "@monkeytype/shared-types";
|
|||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import { getCurrentWeekTimestamp } from "../utils/misc";
|
||||
|
||||
type InternalWeeklyXpLeaderboardEntry = {
|
||||
uid: string;
|
||||
name: string;
|
||||
discordAvatar?: string;
|
||||
discordId?: string;
|
||||
badgeId?: number;
|
||||
lastActivityTimestamp: number;
|
||||
};
|
||||
|
||||
type WeeklyXpLeaderboardEntry = {
|
||||
totalXp: number;
|
||||
rank: number;
|
||||
count?: number;
|
||||
timeTypedSeconds: number;
|
||||
} & InternalWeeklyXpLeaderboardEntry;
|
||||
import {
|
||||
XpLeaderboardEntry,
|
||||
XpLeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
|
||||
type AddResultOpts = {
|
||||
entry: InternalWeeklyXpLeaderboardEntry;
|
||||
entry: Pick<
|
||||
XpLeaderboardEntry,
|
||||
| "uid"
|
||||
| "name"
|
||||
| "discordId"
|
||||
| "discordAvatar"
|
||||
| "badgeId"
|
||||
| "lastActivityTimestamp"
|
||||
>;
|
||||
xpGained: number;
|
||||
timeTypedSeconds: number;
|
||||
};
|
||||
|
@ -123,7 +119,7 @@ export class WeeklyXpLeaderboard {
|
|||
minRank: number,
|
||||
maxRank: number,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
|
||||
): Promise<WeeklyXpLeaderboardEntry[]> {
|
||||
): Promise<XpLeaderboardEntry[]> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return [];
|
||||
|
@ -154,10 +150,10 @@ export class WeeklyXpLeaderboard {
|
|||
);
|
||||
}
|
||||
|
||||
const resultsWithRanks: WeeklyXpLeaderboardEntry[] = results.map(
|
||||
const resultsWithRanks: XpLeaderboardEntry[] = results.map(
|
||||
(resultJSON: string, index: number) => {
|
||||
//TODO parse with zod?
|
||||
const parsed = JSON.parse(resultJSON) as WeeklyXpLeaderboardEntry;
|
||||
const parsed = JSON.parse(resultJSON) as XpLeaderboardEntry;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
|
@ -173,7 +169,7 @@ export class WeeklyXpLeaderboard {
|
|||
public async getRank(
|
||||
uid: string,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
|
||||
): Promise<WeeklyXpLeaderboardEntry | null> {
|
||||
): Promise<XpLeaderboardRank | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return null;
|
||||
|
@ -201,7 +197,7 @@ export class WeeklyXpLeaderboard {
|
|||
|
||||
//TODO parse with zod?
|
||||
const parsed = JSON.parse(result ?? "null") as Omit<
|
||||
WeeklyXpLeaderboardEntry,
|
||||
XpLeaderboardEntry,
|
||||
"rank" | "count" | "totalXp"
|
||||
>;
|
||||
|
||||
|
|
|
@ -1,33 +1,13 @@
|
|||
import _ from "lodash";
|
||||
import _, { omit } from "lodash";
|
||||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc";
|
||||
import { Configuration, ValidModeRule } from "@monkeytype/shared-types";
|
||||
|
||||
type DailyLeaderboardEntry = {
|
||||
uid: string;
|
||||
name: string;
|
||||
wpm: number;
|
||||
raw: number;
|
||||
acc: number;
|
||||
consistency: number;
|
||||
timestamp: number;
|
||||
discordAvatar?: string;
|
||||
discordId?: string;
|
||||
badgeId?: number;
|
||||
isPremium?: boolean;
|
||||
};
|
||||
|
||||
type GetRankResponse = {
|
||||
minWpm: number;
|
||||
count: number;
|
||||
rank: number | null;
|
||||
entry: DailyLeaderboardEntry | null;
|
||||
};
|
||||
|
||||
export type LbEntryWithRank = {
|
||||
rank: number;
|
||||
} & DailyLeaderboardEntry;
|
||||
import {
|
||||
DailyLeaderboardRank,
|
||||
LeaderboardEntry,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import MonkeyError from "./error";
|
||||
|
||||
const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard";
|
||||
const scoresNamespace = `${dailyLeaderboardNamespace}:scores`;
|
||||
|
@ -68,7 +48,7 @@ export class DailyLeaderboard {
|
|||
}
|
||||
|
||||
public async addResult(
|
||||
entry: DailyLeaderboardEntry,
|
||||
entry: Omit<LeaderboardEntry, "rank">,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
|
||||
): Promise<number> {
|
||||
const connection = RedisClient.getConnection();
|
||||
|
@ -127,7 +107,7 @@ export class DailyLeaderboard {
|
|||
maxRank: number,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
premiumFeaturesEnabled: boolean
|
||||
): Promise<LbEntryWithRank[]> {
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return [];
|
||||
|
@ -152,10 +132,10 @@ export class DailyLeaderboard {
|
|||
);
|
||||
}
|
||||
|
||||
const resultsWithRanks: LbEntryWithRank[] = results.map(
|
||||
const resultsWithRanks: LeaderboardEntry[] = results.map(
|
||||
(resultJSON, index) => {
|
||||
// TODO: parse with zod?
|
||||
const parsed = JSON.parse(resultJSON) as LbEntryWithRank;
|
||||
const parsed = JSON.parse(resultJSON) as LeaderboardEntry;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
|
@ -165,7 +145,7 @@ export class DailyLeaderboard {
|
|||
);
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
resultsWithRanks.forEach((it) => (it.isPremium = undefined));
|
||||
return resultsWithRanks.map((it) => omit(it, "isPremium"));
|
||||
}
|
||||
|
||||
return resultsWithRanks;
|
||||
|
@ -174,10 +154,10 @@ export class DailyLeaderboard {
|
|||
public async getRank(
|
||||
uid: string,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
|
||||
): Promise<GetRankResponse | null> {
|
||||
): Promise<DailyLeaderboardRank> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return null;
|
||||
throw new MonkeyError(500, "Redis connnection is unavailable");
|
||||
}
|
||||
|
||||
const { leaderboardScoresKey, leaderboardResultsKey } =
|
||||
|
@ -198,8 +178,6 @@ export class DailyLeaderboard {
|
|||
return {
|
||||
minWpm,
|
||||
count: count ?? 0,
|
||||
rank: null,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ import FunboxList from "../constants/funbox-list";
|
|||
import { DBResult } from "@monkeytype/shared-types";
|
||||
import {
|
||||
Mode,
|
||||
Mode2,
|
||||
PersonalBest,
|
||||
PersonalBests,
|
||||
} from "@monkeytype/contracts/schemas/shared";
|
||||
|
@ -39,7 +38,7 @@ export function checkAndUpdatePb(
|
|||
result: Result
|
||||
): CheckAndUpdatePbResult {
|
||||
const mode = result.mode;
|
||||
const mode2 = result.mode2 as Mode2<"time">;
|
||||
const mode2 = result.mode2;
|
||||
|
||||
const userPb = userPersonalBests ?? {};
|
||||
userPb[mode] ??= {};
|
||||
|
@ -175,7 +174,7 @@ function updateLeaderboardPersonalBests(
|
|||
}
|
||||
|
||||
const mode = result.mode;
|
||||
const mode2 = result.mode2 as Mode2<"time">;
|
||||
const mode2 = result.mode2;
|
||||
|
||||
lbPersonalBests[mode] = lbPersonalBests[mode] ?? {};
|
||||
const lbMode2 = lbPersonalBests[mode][mode2] as MonkeyTypes.LbPersonalBests;
|
||||
|
|
|
@ -105,7 +105,7 @@ export function incrementResult(res: Result<Mode>): void {
|
|||
punctuation,
|
||||
} = res;
|
||||
|
||||
let m2 = mode2 as string;
|
||||
let m2 = mode2;
|
||||
if (mode === "time" && !["15", "30", "60", "120"].includes(mode2)) {
|
||||
m2 = "custom";
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ export default defineConfig({
|
|||
environment: "node",
|
||||
globalSetup: "__tests__/global-setup.ts",
|
||||
setupFiles: ["__tests__/setup-tests.ts"],
|
||||
pool: "forks",
|
||||
|
||||
coverage: {
|
||||
include: ["**/*.ts"],
|
||||
|
|
73
frontend/__tests__/elements/account/result-filters.spec.ts
Normal file
73
frontend/__tests__/elements/account/result-filters.spec.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import defaultResultFilters from "../../../src/ts/constants/default-result-filters";
|
||||
import { mergeWithDefaultFilters } from "../../../src/ts/elements/account/result-filters";
|
||||
|
||||
describe("result-filters.ts", () => {
|
||||
describe("mergeWithDefaultFilters", () => {
|
||||
it("should merge with default filters correctly", () => {
|
||||
const tests = [
|
||||
{
|
||||
input: {
|
||||
pb: {
|
||||
no: false,
|
||||
yes: false,
|
||||
},
|
||||
},
|
||||
expected: () => {
|
||||
const expected = defaultResultFilters;
|
||||
expected.pb.no = false;
|
||||
expected.pb.yes = false;
|
||||
return expected;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
words: {
|
||||
"10": false,
|
||||
},
|
||||
},
|
||||
expected: () => {
|
||||
const expected = defaultResultFilters;
|
||||
expected.words["10"] = false;
|
||||
return expected;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
blah: true,
|
||||
},
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: null,
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
expected: () => {
|
||||
return defaultResultFilters;
|
||||
},
|
||||
},
|
||||
];
|
||||
tests.forEach((test) => {
|
||||
const merged = mergeWithDefaultFilters(test.input as any);
|
||||
expect(merged).toEqual(test.expected());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,8 +1,11 @@
|
|||
import { isObject } from "../../src/ts/utils/misc";
|
||||
import {
|
||||
getLanguageDisplayString,
|
||||
removeLanguageSize,
|
||||
} from "../../src/ts/utils/strings";
|
||||
|
||||
//todo this file is in the wrong place
|
||||
|
||||
describe("misc.ts", () => {
|
||||
describe("getLanguageDisplayString", () => {
|
||||
it("should return correctly formatted strings", () => {
|
||||
|
@ -72,4 +75,47 @@ describe("misc.ts", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
describe("isObject", () => {
|
||||
it("should correctly identify objects", () => {
|
||||
const tests = [
|
||||
{
|
||||
input: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
input: { a: 1 },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
input: [],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
input: [1, 2, 3],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
input: "string",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
input: null,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
tests.forEach((test) => {
|
||||
const result = isObject(test.input);
|
||||
expect(result).toBe(test.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
126
frontend/__tests__/utils/local-storage-with-schema.spec.ts
Normal file
126
frontend/__tests__/utils/local-storage-with-schema.spec.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "../../src/ts/utils/local-storage-with-schema";
|
||||
|
||||
describe("local-storage-with-schema.ts", () => {
|
||||
describe("LocalStorageWithSchema", () => {
|
||||
const objectSchema = z.object({
|
||||
punctuation: z.boolean(),
|
||||
mode: z.enum(["words", "time"]),
|
||||
fontSize: z.number(),
|
||||
});
|
||||
|
||||
const defaultObject: z.infer<typeof objectSchema> = {
|
||||
punctuation: true,
|
||||
mode: "words",
|
||||
fontSize: 16,
|
||||
};
|
||||
|
||||
const ls = new LocalStorageWithSchema({
|
||||
key: "config",
|
||||
schema: objectSchema,
|
||||
fallback: defaultObject,
|
||||
});
|
||||
|
||||
const getItemMock = vi.fn();
|
||||
const setItemMock = vi.fn();
|
||||
const removeItemMock = vi.fn();
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: getItemMock,
|
||||
setItem: setItemMock,
|
||||
removeItem: removeItemMock,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getItemMock.mockReset();
|
||||
setItemMock.mockReset();
|
||||
removeItemMock.mockReset();
|
||||
});
|
||||
|
||||
it("should save to localStorage if schema is correct and return true", () => {
|
||||
const res = ls.set(defaultObject);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"config",
|
||||
JSON.stringify(defaultObject)
|
||||
);
|
||||
expect(res).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail to save to localStorage if schema is incorrect and return false", () => {
|
||||
const obj = {
|
||||
hi: "hello",
|
||||
};
|
||||
|
||||
const res = ls.set(obj as any);
|
||||
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(res).toBe(false);
|
||||
});
|
||||
|
||||
it("should revert to the fallback value if localstorage is null", () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
|
||||
const res = ls.get();
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("config");
|
||||
expect(res).toEqual(defaultObject);
|
||||
});
|
||||
|
||||
it("should revert to the fallback value and remove if localstorage json is malformed", () => {
|
||||
getItemMock.mockReturnValue("badjson");
|
||||
|
||||
const res = ls.get();
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("config");
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith("config");
|
||||
expect(res).toEqual(defaultObject);
|
||||
});
|
||||
|
||||
it("should get from localStorage", () => {
|
||||
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
|
||||
|
||||
const res = ls.get();
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("config");
|
||||
expect(res).toEqual(defaultObject);
|
||||
});
|
||||
|
||||
it("should revert to fallback value if no migrate function and schema failed", () => {
|
||||
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
|
||||
const ls = new LocalStorageWithSchema({
|
||||
key: "config",
|
||||
schema: objectSchema,
|
||||
fallback: defaultObject,
|
||||
});
|
||||
|
||||
const res = ls.get();
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("config");
|
||||
expect(res).toEqual(defaultObject);
|
||||
});
|
||||
|
||||
it("should migrate (when function is provided) if schema failed", () => {
|
||||
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
|
||||
|
||||
const migrated = {
|
||||
punctuation: false,
|
||||
mode: "time",
|
||||
fontSize: 1,
|
||||
};
|
||||
const ls = new LocalStorageWithSchema({
|
||||
key: "config",
|
||||
schema: objectSchema,
|
||||
fallback: defaultObject,
|
||||
migrate: () => {
|
||||
return migrated;
|
||||
},
|
||||
});
|
||||
|
||||
const res = ls.get();
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("config");
|
||||
expect(res).toEqual(migrated);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -44,7 +44,7 @@
|
|||
"@types/seedrandom": "3.0.2",
|
||||
"@types/subset-font": "1.4.3",
|
||||
"@types/throttle-debounce": "2.1.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"ajv": "8.12.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"concurrently": "8.2.2",
|
||||
|
@ -68,7 +68,7 @@
|
|||
"vite-plugin-html-inject": "1.1.2",
|
||||
"vite-plugin-inspect": "0.8.3",
|
||||
"vite-plugin-pwa": "0.20.0",
|
||||
"vitest": "1.6.0"
|
||||
"vitest": "2.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@date-fns/utc": "1.2.0",
|
||||
|
|
|
@ -1720,7 +1720,7 @@
|
|||
<span style="display: inline-flex">
|
||||
(
|
||||
<a
|
||||
href="https://api.monkeytype.com/documentation"
|
||||
href="https://api.monkeytype.com/docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
|
|
|
@ -262,7 +262,7 @@
|
|||
}
|
||||
|
||||
&.tape .word {
|
||||
margin: 0.25em 0.5em 0.75em 0;
|
||||
margin: 0.25em 0.6em 0.75em 0;
|
||||
}
|
||||
|
||||
/* a little hack for right-to-left languages */
|
||||
|
@ -451,7 +451,7 @@
|
|||
position: relative;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
margin: 0.25em;
|
||||
margin: 0.25em 0.3em;
|
||||
font-variant: no-common-ligatures;
|
||||
border-bottom: 2px solid transparent;
|
||||
letter {
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
import Leaderboards from "./leaderboards";
|
||||
import Quotes from "./quotes";
|
||||
import Results from "./results";
|
||||
import Users from "./users";
|
||||
import Public from "./public";
|
||||
import Configuration from "./configuration";
|
||||
import Dev from "./dev";
|
||||
|
||||
export default {
|
||||
Leaderboards,
|
||||
Public,
|
||||
Quotes,
|
||||
Results,
|
||||
Users,
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
const BASE_PATH = "/leaderboards";
|
||||
|
||||
export default class Leaderboards {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async get(
|
||||
query: Ape.Leaderboards.QueryWithPagination
|
||||
): Ape.EndpointResponse<Ape.Leaderboards.GetLeaderboard> {
|
||||
const {
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
isDaily,
|
||||
skip = 0,
|
||||
limit = 50,
|
||||
daysBefore,
|
||||
} = query;
|
||||
const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0;
|
||||
|
||||
const searchQuery = {
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
skip: Math.max(skip, 0),
|
||||
limit: Math.max(Math.min(limit, 50), 0),
|
||||
...(includeDaysBefore && { daysBefore }),
|
||||
};
|
||||
|
||||
const endpointPath = `${BASE_PATH}/${isDaily ? "daily" : ""}`;
|
||||
|
||||
return await this.httpClient.get(endpointPath, { searchQuery });
|
||||
}
|
||||
|
||||
async getRank(
|
||||
query: Ape.Leaderboards.Query
|
||||
): Ape.EndpointResponse<Ape.Leaderboards.GetRank> {
|
||||
const { language, mode, mode2, isDaily, daysBefore } = query;
|
||||
const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0;
|
||||
|
||||
const searchQuery = {
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
...(includeDaysBefore && { daysBefore }),
|
||||
};
|
||||
|
||||
const endpointPath = `${BASE_PATH}${isDaily ? "/daily" : ""}/rank`;
|
||||
|
||||
return await this.httpClient.get(endpointPath, { searchQuery });
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
|
||||
|
||||
const BASE_PATH = "/public";
|
||||
|
||||
type SpeedStatsQuery = {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
};
|
||||
|
||||
export default class Public {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async getSpeedHistogram(
|
||||
searchQuery: SpeedStatsQuery
|
||||
): Ape.EndpointResponse<SpeedHistogram> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/speedHistogram`, {
|
||||
searchQuery,
|
||||
});
|
||||
}
|
||||
|
||||
async getTypingStats(): Ape.EndpointResponse<PublicTypingStats> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/typingStats`);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import {
|
||||
CountByYearAndDay,
|
||||
CustomTheme,
|
||||
ResultFilters,
|
||||
UserProfile,
|
||||
UserProfileDetails,
|
||||
UserTag,
|
||||
} from "@monkeytype/shared-types";
|
||||
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
|
||||
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
|
||||
|
||||
const BASE_PATH = "/users";
|
||||
|
||||
|
|
|
@ -2,28 +2,21 @@ import endpoints from "./endpoints";
|
|||
import { buildHttpClient } from "./adapters/axios-adapter";
|
||||
import { envConfig } from "../constants/env-config";
|
||||
import { buildClient } from "./adapters/ts-rest-adapter";
|
||||
import { configsContract } from "@monkeytype/contracts/configs";
|
||||
import { presetsContract } from "@monkeytype/contracts/presets";
|
||||
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
|
||||
import { psasContract } from "@monkeytype/contracts/psas";
|
||||
import { contract } from "@monkeytype/contracts";
|
||||
|
||||
const API_PATH = "";
|
||||
const BASE_URL = envConfig.backendUrl;
|
||||
const API_URL = `${BASE_URL}${API_PATH}`;
|
||||
|
||||
const httpClient = buildHttpClient(API_URL, 10_000);
|
||||
const tsRestClient = buildClient(contract, BASE_URL, 10_000);
|
||||
|
||||
// API Endpoints
|
||||
const Ape = {
|
||||
...tsRestClient,
|
||||
users: new endpoints.Users(httpClient),
|
||||
configs: buildClient(configsContract, BASE_URL, 10_000),
|
||||
results: new endpoints.Results(httpClient),
|
||||
psas: buildClient(psasContract, BASE_URL, 10_000),
|
||||
quotes: new endpoints.Quotes(httpClient),
|
||||
leaderboards: new endpoints.Leaderboards(httpClient),
|
||||
presets: buildClient(presetsContract, BASE_URL, 10_000),
|
||||
publicStats: new endpoints.Public(httpClient),
|
||||
apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000),
|
||||
configuration: new endpoints.Configuration(httpClient),
|
||||
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
|
||||
};
|
||||
|
|
25
frontend/src/ts/ape/types/leaderboards.d.ts
vendored
25
frontend/src/ts/ape/types/leaderboards.d.ts
vendored
|
@ -1,25 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// for some reason when using the dot notaion, the types are not being recognized as used
|
||||
declare namespace Ape.Leaderboards {
|
||||
type Query = {
|
||||
language: string;
|
||||
mode: Config.Mode;
|
||||
mode2: string;
|
||||
isDaily?: boolean;
|
||||
daysBefore?: number;
|
||||
};
|
||||
|
||||
type QueryWithPagination = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
} & Query;
|
||||
|
||||
type GetLeaderboard = LeaderboardEntry[];
|
||||
|
||||
type GetRank = {
|
||||
minWpm: number;
|
||||
count: number;
|
||||
rank: number | null;
|
||||
entry: import("@monkeytype/shared-types").LeaderboardEntry | null;
|
||||
};
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { navigate } from "../../controllers/route-controller";
|
||||
import { isAuthenticated } from "../../firebase";
|
||||
import { toggleFullscreen } from "../../utils/misc";
|
||||
|
||||
const commands: MonkeyTypes.Command[] = [
|
||||
|
@ -45,10 +46,7 @@ const commands: MonkeyTypes.Command[] = [
|
|||
alias: "navigate go to stats",
|
||||
icon: "fa-user",
|
||||
exec: (): void => {
|
||||
//todo probably base this on some state instead of the dom
|
||||
$("header nav .textButton.view-account").hasClass("hidden")
|
||||
? navigate("/login")
|
||||
: navigate("/account");
|
||||
isAuthenticated() ? navigate("/account") : navigate("/login");
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -17,6 +17,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
|
|||
id: "setPlaySoundOnError1",
|
||||
display: "damage",
|
||||
configValue: "1",
|
||||
hover: (): void => {
|
||||
void SoundController.previewError("1");
|
||||
},
|
||||
exec: (): void => {
|
||||
UpdateConfig.setPlaySoundOnError("1");
|
||||
void SoundController.playError();
|
||||
|
@ -26,6 +29,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
|
|||
id: "setPlaySoundOnError2",
|
||||
display: "triangle",
|
||||
configValue: "2",
|
||||
hover: (): void => {
|
||||
void SoundController.previewError("2");
|
||||
},
|
||||
exec: (): void => {
|
||||
UpdateConfig.setPlaySoundOnError("2");
|
||||
void SoundController.playError();
|
||||
|
@ -35,6 +41,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
|
|||
id: "setPlaySoundOnError3",
|
||||
display: "square",
|
||||
configValue: "3",
|
||||
hover: (): void => {
|
||||
void SoundController.previewError("3");
|
||||
},
|
||||
exec: (): void => {
|
||||
UpdateConfig.setPlaySoundOnError("3");
|
||||
void SoundController.playError();
|
||||
|
@ -44,6 +53,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
|
|||
id: "setPlaySoundOnError3",
|
||||
display: "punch miss",
|
||||
configValue: "4",
|
||||
hover: (): void => {
|
||||
void SoundController.previewError("4");
|
||||
},
|
||||
exec: (): void => {
|
||||
UpdateConfig.setPlaySoundOnError("4");
|
||||
void SoundController.playError();
|
||||
|
|
|
@ -18,13 +18,35 @@ import {
|
|||
} from "./test/funbox/funbox-validation";
|
||||
import * as TribeState from "./tribe/tribe-state";
|
||||
import * as TribeConfigSyncEvent from "./observables/tribe-config-sync-event";
|
||||
import { isDevEnvironment, reloadAfter, typedKeys } from "./utils/misc";
|
||||
import {
|
||||
isDevEnvironment,
|
||||
isObject,
|
||||
reloadAfter,
|
||||
typedKeys,
|
||||
} from "./utils/misc";
|
||||
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
|
||||
import { Config } from "@monkeytype/contracts/schemas/configs";
|
||||
import { roundTo1 } from "./utils/numbers";
|
||||
import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared";
|
||||
import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util";
|
||||
import { LocalStorageWithSchema } from "./utils/local-storage-with-schema";
|
||||
import { mergeWithDefaultConfig } from "./utils/config";
|
||||
|
||||
export let localStorageConfig: Config;
|
||||
const configLS = new LocalStorageWithSchema({
|
||||
key: "config",
|
||||
schema: ConfigSchemas.ConfigSchema,
|
||||
fallback: DefaultConfig,
|
||||
migrate: (value, _issues) => {
|
||||
if (!isObject(value)) {
|
||||
return DefaultConfig;
|
||||
}
|
||||
|
||||
const configWithoutLegacyValues = replaceLegacyValues(value);
|
||||
const merged = mergeWithDefaultConfig(configWithoutLegacyValues);
|
||||
|
||||
return merged;
|
||||
},
|
||||
});
|
||||
|
||||
let loadDone: (value?: unknown) => void;
|
||||
|
||||
|
@ -49,29 +71,25 @@ function saveToLocalStorage(
|
|||
noDbCheck = false
|
||||
): void {
|
||||
if (nosave) return;
|
||||
|
||||
const localToSave = config;
|
||||
|
||||
const localToSaveStringified = JSON.stringify(localToSave);
|
||||
window.localStorage.setItem("config", localToSaveStringified);
|
||||
configLS.set(config);
|
||||
if (!noDbCheck) {
|
||||
//@ts-expect-error this is fine
|
||||
configToSend[key] = config[key];
|
||||
saveToDatabase();
|
||||
}
|
||||
const localToSaveStringified = JSON.stringify(config);
|
||||
ConfigEvent.dispatch("saveToLocalStorage", localToSaveStringified);
|
||||
}
|
||||
|
||||
export function saveFullConfigToLocalStorage(noDbCheck = false): void {
|
||||
console.log("saving full config to localStorage");
|
||||
const save = config;
|
||||
const stringified = JSON.stringify(save);
|
||||
window.localStorage.setItem("config", stringified);
|
||||
configLS.set(config);
|
||||
if (!noDbCheck) {
|
||||
AccountButton.loading(true);
|
||||
void DB.saveConfig(save);
|
||||
void DB.saveConfig(config);
|
||||
AccountButton.loading(false);
|
||||
}
|
||||
const stringified = JSON.stringify(config);
|
||||
ConfigEvent.dispatch("saveToLocalStorage", stringified);
|
||||
}
|
||||
|
||||
|
@ -1631,12 +1649,11 @@ export function setCustomThemeColors(
|
|||
}
|
||||
|
||||
export function setLanguage(
|
||||
language: ConfigSchemas.Language,
|
||||
language: Language,
|
||||
nosave?: boolean,
|
||||
tribeOverride = false
|
||||
): boolean {
|
||||
if (!isConfigValueValid("language", language, ConfigSchemas.LanguageSchema))
|
||||
return false;
|
||||
if (!isConfigValueValid("language", language, LanguageSchema)) return false;
|
||||
if (!TribeState.canChangeConfig(tribeOverride)) return false;
|
||||
|
||||
config.language = language;
|
||||
|
@ -2085,8 +2102,6 @@ export async function apply(
|
|||
|
||||
ConfigEvent.dispatch("fullConfigChange");
|
||||
|
||||
configToApply = replaceLegacyValues(configToApply);
|
||||
|
||||
const configObj = configToApply as Config;
|
||||
(Object.keys(DefaultConfig) as (keyof Config)[]).forEach((configKey) => {
|
||||
if (configObj[configKey] === undefined) {
|
||||
|
@ -2206,33 +2221,19 @@ export async function reset(): Promise<void> {
|
|||
|
||||
export async function loadFromLocalStorage(): Promise<void> {
|
||||
console.log("loading localStorage config");
|
||||
const newConfigString = window.localStorage.getItem("config");
|
||||
let newConfig: Config;
|
||||
if (
|
||||
newConfigString !== undefined &&
|
||||
newConfigString !== null &&
|
||||
newConfigString !== ""
|
||||
) {
|
||||
try {
|
||||
newConfig = JSON.parse(newConfigString);
|
||||
} catch (e) {
|
||||
newConfig = {} as Config;
|
||||
}
|
||||
await apply(newConfig);
|
||||
localStorageConfig = newConfig;
|
||||
saveFullConfigToLocalStorage(true);
|
||||
} else {
|
||||
const newConfig = configLS.get();
|
||||
if (newConfig === undefined) {
|
||||
await reset();
|
||||
} else {
|
||||
await apply(newConfig);
|
||||
saveFullConfigToLocalStorage(true);
|
||||
}
|
||||
// TestLogic.restart(false, true);
|
||||
loadDone();
|
||||
}
|
||||
|
||||
function replaceLegacyValues(
|
||||
configToApply: ConfigSchemas.PartialConfig | MonkeyTypes.ConfigChanges
|
||||
): ConfigSchemas.Config | MonkeyTypes.ConfigChanges {
|
||||
const configObj = configToApply as ConfigSchemas.Config;
|
||||
|
||||
export function replaceLegacyValues(
|
||||
configObj: ConfigSchemas.PartialConfig
|
||||
): ConfigSchemas.PartialConfig {
|
||||
//@ts-expect-error
|
||||
if (configObj.quickTab === true) {
|
||||
configObj.quickRestart = "tab";
|
||||
|
@ -2270,7 +2271,7 @@ function replaceLegacyValues(
|
|||
if (configObj.showLiveWpm === true) {
|
||||
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
|
||||
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
|
||||
val = configObj.timerStyle;
|
||||
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
|
||||
}
|
||||
configObj.liveSpeedStyle = val;
|
||||
}
|
||||
|
@ -2279,7 +2280,7 @@ function replaceLegacyValues(
|
|||
if (configObj.showLiveBurst === true) {
|
||||
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
|
||||
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
|
||||
val = configObj.timerStyle;
|
||||
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
|
||||
}
|
||||
configObj.liveBurstStyle = val;
|
||||
}
|
||||
|
@ -2288,7 +2289,7 @@ function replaceLegacyValues(
|
|||
if (configObj.showLiveAcc === true) {
|
||||
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
|
||||
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
|
||||
val = configObj.timerStyle;
|
||||
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
|
||||
}
|
||||
configObj.liveAccStyle = val;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
CustomThemeColors,
|
||||
} from "@monkeytype/contracts/schemas/configs";
|
||||
|
||||
export default {
|
||||
const obj = {
|
||||
theme: "serika_dark",
|
||||
themeLight: "serika",
|
||||
themeDark: "serika_dark",
|
||||
|
@ -103,3 +103,5 @@ export default {
|
|||
tribeDelta: "bar",
|
||||
tribeCarets: "on",
|
||||
} as Config;
|
||||
|
||||
export default JSON.parse(JSON.stringify(obj)) as Config;
|
||||
|
|
66
frontend/src/ts/constants/default-result-filters.ts
Normal file
66
frontend/src/ts/constants/default-result-filters.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
|
||||
|
||||
const object: ResultFilters = {
|
||||
_id: "default-result-filters-id",
|
||||
name: "default result filters",
|
||||
pb: {
|
||||
no: true,
|
||||
yes: true,
|
||||
},
|
||||
difficulty: {
|
||||
normal: true,
|
||||
expert: true,
|
||||
master: true,
|
||||
},
|
||||
mode: {
|
||||
words: true,
|
||||
time: true,
|
||||
quote: true,
|
||||
zen: true,
|
||||
custom: true,
|
||||
},
|
||||
words: {
|
||||
"10": true,
|
||||
"25": true,
|
||||
"50": true,
|
||||
"100": true,
|
||||
custom: true,
|
||||
},
|
||||
time: {
|
||||
"15": true,
|
||||
"30": true,
|
||||
"60": true,
|
||||
"120": true,
|
||||
custom: true,
|
||||
},
|
||||
quoteLength: {
|
||||
short: true,
|
||||
medium: true,
|
||||
long: true,
|
||||
thicc: true,
|
||||
},
|
||||
punctuation: {
|
||||
on: true,
|
||||
off: true,
|
||||
},
|
||||
numbers: {
|
||||
on: true,
|
||||
off: true,
|
||||
},
|
||||
date: {
|
||||
last_day: false,
|
||||
last_week: false,
|
||||
last_month: false,
|
||||
last_3months: false,
|
||||
all: true,
|
||||
},
|
||||
tags: {
|
||||
none: true,
|
||||
},
|
||||
language: {},
|
||||
funbox: {
|
||||
none: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default JSON.parse(JSON.stringify(object)) as ResultFilters;
|
|
@ -47,6 +47,7 @@ import { navigate } from "./route-controller";
|
|||
import { getHtmlByUserFlags } from "./user-flag-controller";
|
||||
import { FirebaseError } from "firebase/app";
|
||||
import * as PSA from "../elements/psa";
|
||||
import defaultResultFilters from "../constants/default-result-filters";
|
||||
|
||||
export const gmailProvider = new GoogleAuthProvider();
|
||||
export const githubProvider = new GithubAuthProvider();
|
||||
|
@ -135,10 +136,10 @@ async function getDataAndInit(): Promise<boolean> {
|
|||
.then((values) => {
|
||||
const [languages, funboxes] = values;
|
||||
languages.forEach((language) => {
|
||||
ResultFilters.defaultResultFilters.language[language] = true;
|
||||
defaultResultFilters.language[language] = true;
|
||||
});
|
||||
funboxes.forEach((funbox) => {
|
||||
ResultFilters.defaultResultFilters.funbox[funbox.name] = true;
|
||||
defaultResultFilters.funbox[funbox.name] = true;
|
||||
});
|
||||
// filters = defaultResultFilters;
|
||||
void ResultFilters.load();
|
||||
|
@ -166,7 +167,7 @@ async function getDataAndInit(): Promise<boolean> {
|
|||
const areConfigsEqual =
|
||||
JSON.stringify(Config) === JSON.stringify(snapshot.config);
|
||||
|
||||
if (UpdateConfig.localStorageConfig === undefined || !areConfigsEqual) {
|
||||
if (Config === undefined || !areConfigsEqual) {
|
||||
console.log(
|
||||
"no local config or local and db configs are different - applying db"
|
||||
);
|
||||
|
|
|
@ -404,6 +404,20 @@ export async function previewClick(val: string): Promise<void> {
|
|||
safeClickSounds[val][0].sounds[0].play();
|
||||
}
|
||||
|
||||
export async function previewError(val: string): Promise<void> {
|
||||
if (errorSounds === null) await initErrorSound();
|
||||
|
||||
const safeErrorSounds = errorSounds as ErrorSounds;
|
||||
|
||||
const errorSoundIds = Object.keys(safeErrorSounds);
|
||||
if (!errorSoundIds.includes(val)) return;
|
||||
|
||||
//@ts-expect-error
|
||||
errorClickSounds[val][0].sounds[0].seek(0);
|
||||
//@ts-expect-error
|
||||
errorClickSounds[val][0].sounds[0].play();
|
||||
}
|
||||
|
||||
let currentCode = "KeyA";
|
||||
|
||||
$(document).on("keydown", (event) => {
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
import { z } from "zod";
|
||||
import * as DB from "../db";
|
||||
import * as ModesNotice from "../elements/modes-notice";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
import { IdSchema } from "@monkeytype/contracts/schemas/util";
|
||||
|
||||
const activeTagsLS = new LocalStorageWithSchema({
|
||||
key: "activeTags",
|
||||
schema: z.array(IdSchema),
|
||||
fallback: [],
|
||||
});
|
||||
|
||||
export function saveActiveToLocalStorage(): void {
|
||||
const tags: string[] = [];
|
||||
|
||||
try {
|
||||
DB.getSnapshot()?.tags?.forEach((tag) => {
|
||||
if (tag.active === true) {
|
||||
tags.push(tag._id);
|
||||
}
|
||||
});
|
||||
window.localStorage.setItem("activeTags", JSON.stringify(tags));
|
||||
} catch (e) {}
|
||||
DB.getSnapshot()?.tags?.forEach((tag) => {
|
||||
if (tag.active === true) {
|
||||
tags.push(tag._id);
|
||||
}
|
||||
});
|
||||
|
||||
activeTagsLS.set(tags);
|
||||
}
|
||||
|
||||
export function clear(nosave = false): void {
|
||||
|
@ -61,18 +69,9 @@ export function toggle(tagid: string, nosave = false): void {
|
|||
}
|
||||
|
||||
export function loadActiveFromLocalStorage(): void {
|
||||
let newTags: string[] | string = window.localStorage.getItem(
|
||||
"activeTags"
|
||||
) as string;
|
||||
if (newTags != undefined && newTags !== "") {
|
||||
try {
|
||||
newTags = JSON.parse(newTags) ?? [];
|
||||
} catch (e) {
|
||||
newTags = [];
|
||||
}
|
||||
(newTags as string[]).forEach((ntag) => {
|
||||
toggle(ntag, true);
|
||||
});
|
||||
saveActiveToLocalStorage();
|
||||
const newTags = activeTagsLS.get();
|
||||
for (const tag of newTags) {
|
||||
toggle(tag, true);
|
||||
}
|
||||
saveActiveToLocalStorage();
|
||||
}
|
||||
|
|
|
@ -623,29 +623,26 @@ export async function getLocalPB<M extends Mode>(
|
|||
difficulty: Difficulty,
|
||||
lazyMode: boolean,
|
||||
funbox: string
|
||||
): Promise<number> {
|
||||
): Promise<PersonalBest | undefined> {
|
||||
const funboxes = (await getFunboxList()).filter((fb) => {
|
||||
return funbox?.split("#").includes(fb.name);
|
||||
});
|
||||
|
||||
if (!funboxes.every((f) => f.canGetPb)) {
|
||||
return 0;
|
||||
return undefined;
|
||||
}
|
||||
if (dbSnapshot === null || dbSnapshot?.personalBests === null) return 0;
|
||||
|
||||
const bestsByMode = dbSnapshot?.personalBests[mode][mode2] as PersonalBest[];
|
||||
const pbs = dbSnapshot?.personalBests?.[mode]?.[mode2] as
|
||||
| PersonalBest[]
|
||||
| undefined;
|
||||
|
||||
if (bestsByMode === undefined) return 0;
|
||||
|
||||
return (
|
||||
bestsByMode.find(
|
||||
(pb) =>
|
||||
(pb.punctuation ?? false) === punctuation &&
|
||||
(pb.numbers ?? false) === numbers &&
|
||||
pb.difficulty === difficulty &&
|
||||
pb.language === language &&
|
||||
(pb.lazyMode ?? false) === lazyMode
|
||||
)?.wpm ?? 0
|
||||
return pbs?.find(
|
||||
(pb) =>
|
||||
(pb.punctuation ?? false) === punctuation &&
|
||||
(pb.numbers ?? false) === numbers &&
|
||||
pb.difficulty === difficulty &&
|
||||
pb.language === language &&
|
||||
(pb.lazyMode ?? false) === lazyMode
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -8,8 +8,49 @@ import Ape from "../../ape/index";
|
|||
import * as Loader from "../loader";
|
||||
// @ts-expect-error TODO: update slim-select
|
||||
import SlimSelect from "slim-select";
|
||||
import { ResultFilters } from "@monkeytype/shared-types";
|
||||
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
|
||||
import {
|
||||
ResultFilters,
|
||||
ResultFiltersSchema,
|
||||
ResultFiltersGroup,
|
||||
ResultFiltersGroupItem,
|
||||
} from "@monkeytype/contracts/schemas/users";
|
||||
import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema";
|
||||
import defaultResultFilters from "../../constants/default-result-filters";
|
||||
|
||||
export function mergeWithDefaultFilters(
|
||||
filters: Partial<ResultFilters>
|
||||
): ResultFilters {
|
||||
try {
|
||||
const merged = {} as ResultFilters;
|
||||
for (const groupKey of Misc.typedKeys(defaultResultFilters)) {
|
||||
if (groupKey === "_id" || groupKey === "name") {
|
||||
merged[groupKey] = filters[groupKey] ?? defaultResultFilters[groupKey];
|
||||
} else {
|
||||
// @ts-expect-error i cant figure this out
|
||||
merged[groupKey] = {
|
||||
...defaultResultFilters[groupKey],
|
||||
...filters[groupKey],
|
||||
};
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
} catch (e) {
|
||||
return defaultResultFilters;
|
||||
}
|
||||
}
|
||||
|
||||
const resultFiltersLS = new LocalStorageWithSchema({
|
||||
key: "resultFilters",
|
||||
schema: ResultFiltersSchema,
|
||||
fallback: defaultResultFilters,
|
||||
migrate: (unknown, _issues) => {
|
||||
if (!Misc.isObject(unknown)) {
|
||||
return defaultResultFilters;
|
||||
}
|
||||
return mergeWithDefaultFilters(unknown as ResultFilters);
|
||||
},
|
||||
});
|
||||
|
||||
type Option = {
|
||||
id: string;
|
||||
|
@ -31,118 +72,18 @@ type Option = {
|
|||
const groupsUsingSelect = ["language", "funbox", "tags"];
|
||||
const groupSelects: Partial<Record<keyof ResultFilters, SlimSelect>> = {};
|
||||
|
||||
export const defaultResultFilters: ResultFilters = {
|
||||
_id: "default-result-filters-id",
|
||||
name: "default result filters",
|
||||
pb: {
|
||||
no: true,
|
||||
yes: true,
|
||||
},
|
||||
difficulty: {
|
||||
normal: true,
|
||||
expert: true,
|
||||
master: true,
|
||||
},
|
||||
mode: {
|
||||
words: true,
|
||||
time: true,
|
||||
quote: true,
|
||||
zen: true,
|
||||
custom: true,
|
||||
},
|
||||
words: {
|
||||
"10": true,
|
||||
"25": true,
|
||||
"50": true,
|
||||
"100": true,
|
||||
custom: true,
|
||||
},
|
||||
time: {
|
||||
"15": true,
|
||||
"30": true,
|
||||
"60": true,
|
||||
"120": true,
|
||||
custom: true,
|
||||
},
|
||||
quoteLength: {
|
||||
short: true,
|
||||
medium: true,
|
||||
long: true,
|
||||
thicc: true,
|
||||
},
|
||||
punctuation: {
|
||||
on: true,
|
||||
off: true,
|
||||
},
|
||||
numbers: {
|
||||
on: true,
|
||||
off: true,
|
||||
},
|
||||
date: {
|
||||
last_day: false,
|
||||
last_week: false,
|
||||
last_month: false,
|
||||
last_3months: false,
|
||||
all: true,
|
||||
},
|
||||
tags: {
|
||||
none: true,
|
||||
},
|
||||
language: {},
|
||||
funbox: {
|
||||
none: true,
|
||||
},
|
||||
};
|
||||
|
||||
// current activated filter
|
||||
let filters = defaultResultFilters;
|
||||
|
||||
function save(): void {
|
||||
window.localStorage.setItem("resultFilters", JSON.stringify(filters));
|
||||
resultFiltersLS.set(filters);
|
||||
}
|
||||
|
||||
export async function load(): Promise<void> {
|
||||
try {
|
||||
const newResultFilters = window.localStorage.getItem("resultFilters") ?? "";
|
||||
|
||||
if (!newResultFilters) {
|
||||
filters = defaultResultFilters;
|
||||
} else {
|
||||
const newFiltersObject = JSON.parse(newResultFilters);
|
||||
|
||||
let reset = false;
|
||||
for (const key of Object.keys(defaultResultFilters)) {
|
||||
if (reset) break;
|
||||
if (newFiltersObject[key] === undefined) {
|
||||
reset = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof defaultResultFilters[
|
||||
key as keyof typeof defaultResultFilters
|
||||
] === "object"
|
||||
) {
|
||||
for (const subKey of Object.keys(
|
||||
defaultResultFilters[key as keyof typeof defaultResultFilters]
|
||||
)) {
|
||||
if (newFiltersObject[key][subKey] === undefined) {
|
||||
reset = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
filters = defaultResultFilters;
|
||||
} else {
|
||||
filters = newFiltersObject;
|
||||
}
|
||||
}
|
||||
const filters = resultFiltersLS.get();
|
||||
|
||||
const newTags: Record<string, boolean> = { none: false };
|
||||
|
||||
Object.keys(defaultResultFilters.tags).forEach((tag) => {
|
||||
if (filters.tags[tag] !== undefined) {
|
||||
newTags[tag] = filters.tags[tag];
|
||||
|
@ -152,7 +93,6 @@ export async function load(): Promise<void> {
|
|||
});
|
||||
|
||||
filters.tags = newTags;
|
||||
// await updateFilterPresets();
|
||||
save();
|
||||
} catch {
|
||||
console.log("error in loading result filters");
|
||||
|
@ -288,7 +228,7 @@ function getFilters(): ResultFilters {
|
|||
return filters;
|
||||
}
|
||||
|
||||
function getGroup<G extends keyof ResultFilters>(group: G): ResultFilters[G] {
|
||||
function getGroup<G extends ResultFiltersGroup>(group: G): ResultFilters[G] {
|
||||
return filters[group];
|
||||
}
|
||||
|
||||
|
@ -296,22 +236,22 @@ function getGroup<G extends keyof ResultFilters>(group: G): ResultFilters[G] {
|
|||
// filters[group][filter] = value;
|
||||
// }
|
||||
|
||||
export function getFilter<G extends keyof ResultFilters>(
|
||||
export function getFilter<G extends ResultFiltersGroup>(
|
||||
group: G,
|
||||
filter: MonkeyTypes.Filter<G>
|
||||
): ResultFilters[G][MonkeyTypes.Filter<G>] {
|
||||
filter: ResultFiltersGroupItem<G>
|
||||
): ResultFilters[G][ResultFiltersGroupItem<G>] {
|
||||
return filters[group][filter];
|
||||
}
|
||||
|
||||
function setFilter(
|
||||
group: keyof ResultFilters,
|
||||
filter: MonkeyTypes.Filter<typeof group>,
|
||||
function setFilter<G extends ResultFiltersGroup>(
|
||||
group: G,
|
||||
filter: ResultFiltersGroupItem<G>,
|
||||
value: boolean
|
||||
): void {
|
||||
filters[group][filter as keyof typeof filters[typeof group]] = value as never;
|
||||
filters[group][filter] = value as typeof filters[G][typeof filter];
|
||||
}
|
||||
|
||||
function setAllFilters(group: keyof ResultFilters, value: boolean): void {
|
||||
function setAllFilters(group: ResultFiltersGroup, value: boolean): void {
|
||||
Object.keys(getGroup(group)).forEach((filter) => {
|
||||
filters[group][filter as keyof typeof filters[typeof group]] =
|
||||
value as never;
|
||||
|
@ -330,7 +270,7 @@ export function reset(): void {
|
|||
}
|
||||
|
||||
type AboveChartDisplay = Partial<
|
||||
Record<keyof ResultFilters, { all: boolean; array?: string[] }>
|
||||
Record<ResultFiltersGroup, { all: boolean; array?: string[] }>
|
||||
>;
|
||||
|
||||
export function updateActive(): void {
|
||||
|
@ -352,7 +292,10 @@ export function updateActive(): void {
|
|||
|
||||
if (groupAboveChartDisplay === undefined) continue;
|
||||
|
||||
const filterValue = getFilter(group, filter);
|
||||
const filterValue = getFilter(
|
||||
group,
|
||||
filter as ResultFiltersGroupItem<typeof group>
|
||||
);
|
||||
if (filterValue === true) {
|
||||
groupAboveChartDisplay.array?.push(filter);
|
||||
} else {
|
||||
|
@ -392,7 +335,7 @@ export function updateActive(): void {
|
|||
|
||||
for (const [id, select] of Object.entries(groupSelects)) {
|
||||
const ss = select;
|
||||
const group = getGroup(id as keyof ResultFilters);
|
||||
const group = getGroup(id as ResultFiltersGroup);
|
||||
const everythingSelected = Object.values(group).every((v) => v === true);
|
||||
|
||||
const newData = ss.store.getData();
|
||||
|
@ -436,7 +379,7 @@ export function updateActive(): void {
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function addText(group: keyof ResultFilters): string {
|
||||
function addText(group: ResultFiltersGroup): string {
|
||||
let ret = "";
|
||||
ret += "<div class='group'>";
|
||||
if (group === "difficulty") {
|
||||
|
@ -534,9 +477,9 @@ export function updateActive(): void {
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function toggle<G extends keyof ResultFilters>(
|
||||
function toggle<G extends ResultFiltersGroup>(
|
||||
group: G,
|
||||
filter: MonkeyTypes.Filter<G>
|
||||
filter: ResultFiltersGroupItem<G>
|
||||
): void {
|
||||
// user is changing the filters -> current filter is no longer a filter preset
|
||||
deSelectFilterPreset();
|
||||
|
@ -548,7 +491,7 @@ function toggle<G extends keyof ResultFilters>(
|
|||
const currentValue = filters[group][filter] as unknown as boolean;
|
||||
const newValue = !currentValue;
|
||||
filters[group][filter] =
|
||||
newValue as unknown as ResultFilters[G][MonkeyTypes.Filter<G>];
|
||||
newValue as ResultFilters[G][ResultFiltersGroupItem<G>];
|
||||
save();
|
||||
} catch (e) {
|
||||
Notifications.add(
|
||||
|
@ -567,8 +510,10 @@ $(
|
|||
).on("click", "button", (e) => {
|
||||
const group = $(e.target)
|
||||
.parents(".buttons")
|
||||
.attr("group") as keyof ResultFilters;
|
||||
const filter = $(e.target).attr("filter") as MonkeyTypes.Filter<typeof group>;
|
||||
.attr("group") as ResultFiltersGroup;
|
||||
const filter = $(e.target).attr("filter") as ResultFiltersGroupItem<
|
||||
typeof group
|
||||
>;
|
||||
if ($(e.target).hasClass("allFilters")) {
|
||||
Misc.typedKeys(getFilters()).forEach((group) => {
|
||||
// id and name field do not correspond to any ui elements, no need to update
|
||||
|
@ -594,8 +539,8 @@ $(
|
|||
} else if ($(e.target).is("button")) {
|
||||
if (e.shiftKey) {
|
||||
setAllFilters(group, false);
|
||||
filters[group][filter as keyof typeof filters[typeof group]] =
|
||||
true as never;
|
||||
filters[group][filter] =
|
||||
true as ResultFilters[typeof group][typeof filter];
|
||||
} else {
|
||||
toggle(group, filter);
|
||||
// filters[group][filter] = !filters[group][filter];
|
||||
|
@ -658,7 +603,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
|
|||
filters.words.custom = true;
|
||||
}
|
||||
} else if (Config.mode === "quote") {
|
||||
const filterName: MonkeyTypes.Filter<"quoteLength">[] = [
|
||||
const filterName: ResultFiltersGroupItem<"quoteLength">[] = [
|
||||
"short",
|
||||
"medium",
|
||||
"long",
|
||||
|
@ -689,7 +634,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
|
|||
}
|
||||
|
||||
if (Config.funbox === "none") {
|
||||
filters.funbox.none = true;
|
||||
filters.funbox["none"] = true;
|
||||
} else {
|
||||
for (const f of Config.funbox.split("#")) {
|
||||
filters.funbox[f] = true;
|
||||
|
@ -718,7 +663,7 @@ $(".pageAccount .topFilters button.toggleAdvancedFilters").on("click", () => {
|
|||
});
|
||||
|
||||
function adjustScrollposition(
|
||||
group: keyof ResultFilters,
|
||||
group: ResultFiltersGroup,
|
||||
topItem: number = 0
|
||||
): void {
|
||||
const slimSelect = groupSelects[group];
|
||||
|
@ -730,7 +675,7 @@ function adjustScrollposition(
|
|||
}
|
||||
|
||||
function selectBeforeChangeFn(
|
||||
group: keyof ResultFilters,
|
||||
group: ResultFiltersGroup,
|
||||
selectedOptions: Option[],
|
||||
oldSelectedOptions: Option[]
|
||||
): void | boolean {
|
||||
|
@ -767,7 +712,11 @@ function selectBeforeChangeFn(
|
|||
break;
|
||||
}
|
||||
|
||||
setFilter(group, selectedOption.value, true);
|
||||
setFilter(
|
||||
group,
|
||||
selectedOption.value as ResultFiltersGroupItem<typeof group>,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
updateActive();
|
||||
|
@ -987,7 +936,7 @@ $(".group.presetFilterButtons .filterBtns").on(
|
|||
function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
|
||||
const filter = deepCopyFilter(filterIn);
|
||||
Object.entries(defaultResultFilters).forEach((entry) => {
|
||||
const key = entry[0] as keyof ResultFilters;
|
||||
const key = entry[0] as ResultFiltersGroup;
|
||||
const value = entry[1];
|
||||
if (filter[key] === undefined) {
|
||||
// @ts-expect-error key and value is based on default filter so this is safe to ignore
|
||||
|
|
|
@ -17,7 +17,11 @@ import Format from "../utils/format";
|
|||
// @ts-expect-error TODO: update slim-select
|
||||
import SlimSelect from "slim-select";
|
||||
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
|
||||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
LeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { Mode } from "@monkeytype/contracts/schemas/shared";
|
||||
|
||||
const wrapperId = "leaderboardsWrapper";
|
||||
|
||||
|
@ -35,7 +39,9 @@ let currentData: {
|
|||
};
|
||||
|
||||
let currentRank: {
|
||||
[_key in LbKey]: Ape.Leaderboards.GetRank | Record<string, never>;
|
||||
[_key in LbKey]:
|
||||
| (LeaderboardRank & { minWpm?: number }) //Daily LB rank has minWpm
|
||||
| Record<string, never>;
|
||||
} = {
|
||||
"15": {},
|
||||
"60": {},
|
||||
|
@ -425,10 +431,10 @@ function updateYesterdayButton(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore: number } {
|
||||
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore?: 1 } {
|
||||
const isDaily = currentTimeRange === "daily";
|
||||
const isViewingDailyAndButtonIsActive = isDaily && showingYesterday;
|
||||
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : 0;
|
||||
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : undefined;
|
||||
|
||||
return {
|
||||
isDaily,
|
||||
|
@ -443,57 +449,59 @@ async function update(): Promise<void> {
|
|||
showLoader("15");
|
||||
showLoader("60");
|
||||
|
||||
const timeModes = ["15", "60"];
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
const requestRank = isDaily
|
||||
? Ape.leaderboards.getDailyRank
|
||||
: Ape.leaderboards.getRank;
|
||||
|
||||
const lbDataRequests = timeModes.map(async (mode2) => {
|
||||
return Ape.leaderboards.get({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2,
|
||||
...getDailyLeaderboardQuery(),
|
||||
});
|
||||
});
|
||||
const baseQuery = {
|
||||
language: currentLanguage,
|
||||
mode: "time" as Mode,
|
||||
daysBefore,
|
||||
};
|
||||
|
||||
const lbRankRequests: Promise<
|
||||
Ape.HttpClientResponse<Ape.Leaderboards.GetRank>
|
||||
>[] = [];
|
||||
if (isAuthenticated()) {
|
||||
lbRankRequests.push(
|
||||
...timeModes.map(async (mode2) => {
|
||||
return Ape.leaderboards.getRank({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2,
|
||||
...getDailyLeaderboardQuery(),
|
||||
});
|
||||
})
|
||||
const fallbackResponse = { status: 200, body: { message: "", data: null } };
|
||||
|
||||
const lbRank15Request = isAuthenticated()
|
||||
? requestRank({ query: { ...baseQuery, mode2: "15" } })
|
||||
: fallbackResponse;
|
||||
|
||||
const lbRank60Request = isAuthenticated()
|
||||
? requestRank({ query: { ...baseQuery, mode2: "60" } })
|
||||
: fallbackResponse;
|
||||
const [lb15Data, lb60Data, lb15Rank, lb60Rank] = await Promise.all([
|
||||
requestData({ query: { ...baseQuery, mode2: "15" } }),
|
||||
requestData({ query: { ...baseQuery, mode2: "60" } }),
|
||||
lbRank15Request,
|
||||
lbRank60Request,
|
||||
]);
|
||||
|
||||
if (
|
||||
lb15Data.status !== 200 ||
|
||||
lb60Data.status !== 200 ||
|
||||
lb15Rank.status !== 200 ||
|
||||
lb60Rank.status !== 200
|
||||
) {
|
||||
const failedResponses = [lb15Data, lb60Data, lb15Rank, lb60Rank].filter(
|
||||
(it) => it.status !== 200
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(lbDataRequests);
|
||||
const rankResponses = await Promise.all(lbRankRequests);
|
||||
|
||||
const failedResponses = [
|
||||
...(responses.filter((response) => response.status !== 200) ?? []),
|
||||
...(rankResponses.filter((response) => response.status !== 200) ?? []),
|
||||
];
|
||||
if (failedResponses.length > 0) {
|
||||
hideLoader("15");
|
||||
hideLoader("60");
|
||||
Notifications.add(
|
||||
"Failed to load leaderboards: " + failedResponses[0]?.message,
|
||||
"Failed to load leaderboards: " + failedResponses[0]?.body.message,
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [lb15Data, lb60Data] = responses.map((response) => response.data);
|
||||
const [lb15Rank, lb60Rank] = rankResponses.map((response) => response.data);
|
||||
|
||||
if (lb15Data !== undefined && lb15Data !== null) currentData["15"] = lb15Data;
|
||||
if (lb60Data !== undefined && lb60Data !== null) currentData["60"] = lb60Data;
|
||||
if (lb15Rank !== undefined && lb15Rank !== null) currentRank["15"] = lb15Rank;
|
||||
if (lb60Rank !== undefined && lb60Rank !== null) currentRank["60"] = lb60Rank;
|
||||
if (lb15Data.body.data !== null) currentData["15"] = lb15Data.body.data;
|
||||
if (lb60Data.body.data !== null) currentData["60"] = lb60Data.body.data;
|
||||
if (lb15Rank.body.data !== null) currentRank["15"] = lb15Rank.body.data;
|
||||
if (lb60Rank.body.data !== null) currentRank["60"] = lb60Rank.body.data;
|
||||
|
||||
const leaderboardKeys: LbKey[] = ["15", "60"];
|
||||
|
||||
|
@ -541,21 +549,34 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
|
|||
skipVal = 0;
|
||||
}
|
||||
|
||||
const response = await Ape.leaderboards.get({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip: skipVal,
|
||||
limit: limitVal,
|
||||
...getDailyLeaderboardQuery(),
|
||||
});
|
||||
const data = response.data;
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
|
||||
if (response.status !== 200 || data === null || data.length === 0) {
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
|
||||
const response = await requestData({
|
||||
query: {
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip: skipVal,
|
||||
limit: limitVal,
|
||||
daysBefore,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
response.body.data === null ||
|
||||
response.body.data.length === 0
|
||||
) {
|
||||
hideLoader(lb);
|
||||
requesting[lb] = false;
|
||||
return;
|
||||
}
|
||||
const data = response.body.data;
|
||||
|
||||
if (prepend) {
|
||||
currentData[lb].unshift(...data);
|
||||
} else {
|
||||
|
@ -582,14 +603,21 @@ 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({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip,
|
||||
...getDailyLeaderboardQuery(),
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
|
||||
const response = await requestData({
|
||||
query: {
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip,
|
||||
daysBefore,
|
||||
},
|
||||
});
|
||||
const data = response.data;
|
||||
|
||||
if (response.status === 503) {
|
||||
Notifications.add(
|
||||
|
@ -602,10 +630,16 @@ async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
|||
clearBody(lb);
|
||||
currentData[lb] = [];
|
||||
currentAvatars[lb] = [];
|
||||
if (response.status !== 200 || data === null || data.length === 0) {
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
response.body.data === null ||
|
||||
response.body.data.length === 0
|
||||
) {
|
||||
hideLoader(lb);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.body.data;
|
||||
currentData[lb] = data;
|
||||
await fillTable(lb);
|
||||
|
||||
|
@ -618,7 +652,7 @@ async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
|||
}
|
||||
|
||||
async function getAvatarUrls(
|
||||
data: Ape.Leaderboards.GetLeaderboard
|
||||
data: LeaderboardEntry[]
|
||||
): Promise<(string | null)[]> {
|
||||
return Promise.allSettled(
|
||||
data.map(async (entry) =>
|
||||
|
|
24
frontend/src/ts/elements/merch-banner.ts
Normal file
24
frontend/src/ts/elements/merch-banner.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
import * as Notifications from "./notifications";
|
||||
|
||||
const closed = new LocalStorageWithSchema({
|
||||
key: "merchBannerClosed",
|
||||
schema: z.boolean(),
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
export function showIfNotClosedBefore(): void {
|
||||
if (!closed.get()) {
|
||||
Notifications.addBanner(
|
||||
`Check out our merchandise, available at <a target="_blank" rel="noopener" href="https://monkeytype.store/">monkeytype.store</a>`,
|
||||
1,
|
||||
"./images/merch2.png",
|
||||
false,
|
||||
() => {
|
||||
closed.set(true);
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
|
@ -5,24 +5,28 @@ import * as Notifications from "./notifications";
|
|||
import { format } from "date-fns/format";
|
||||
import * as Alerts from "./alerts";
|
||||
import { PSA } from "@monkeytype/contracts/schemas/psas";
|
||||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
import { IdSchema } from "@monkeytype/contracts/schemas/util";
|
||||
|
||||
const confirmedPSAs = new LocalStorageWithSchema({
|
||||
key: "confirmedPSAs",
|
||||
schema: z.array(IdSchema),
|
||||
fallback: [],
|
||||
});
|
||||
|
||||
function clearMemory(): void {
|
||||
window.localStorage.setItem("confirmedPSAs", JSON.stringify([]));
|
||||
confirmedPSAs.set([]);
|
||||
}
|
||||
|
||||
function getMemory(): string[] {
|
||||
//TODO verify with zod?
|
||||
return (
|
||||
(JSON.parse(
|
||||
window.localStorage.getItem("confirmedPSAs") ?? "[]"
|
||||
) as string[]) ?? []
|
||||
);
|
||||
return confirmedPSAs.get();
|
||||
}
|
||||
|
||||
function setMemory(id: string): void {
|
||||
const list = getMemory();
|
||||
list.push(id);
|
||||
window.localStorage.setItem("confirmedPSAs", JSON.stringify(list));
|
||||
confirmedPSAs.set(list);
|
||||
}
|
||||
|
||||
async function getLatest(): Promise<PSA[] | null> {
|
||||
|
|
|
@ -4,29 +4,30 @@ import { isPopupVisible } from "../utils/misc";
|
|||
import * as AdController from "../controllers/ad-controller";
|
||||
import AnimatedModal from "../utils/animated-modal";
|
||||
import { focusWords } from "../test/test-ui";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
type Accepted = {
|
||||
security: boolean;
|
||||
analytics: boolean;
|
||||
};
|
||||
const AcceptedSchema = z.object({
|
||||
security: z.boolean(),
|
||||
analytics: z.boolean(),
|
||||
});
|
||||
type Accepted = z.infer<typeof AcceptedSchema>;
|
||||
|
||||
function getAcceptedObject(): Accepted | null {
|
||||
const acceptedCookies = localStorage.getItem("acceptedCookies") ?? "";
|
||||
if (acceptedCookies) {
|
||||
//TODO verify with zod?
|
||||
return JSON.parse(acceptedCookies) as Accepted;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const acceptedCookiesLS = new LocalStorageWithSchema({
|
||||
key: "acceptedCookies",
|
||||
schema: AcceptedSchema,
|
||||
fallback: {
|
||||
security: false,
|
||||
analytics: false,
|
||||
},
|
||||
});
|
||||
|
||||
function setAcceptedObject(obj: Accepted): void {
|
||||
localStorage.setItem("acceptedCookies", JSON.stringify(obj));
|
||||
acceptedCookiesLS.set(obj);
|
||||
}
|
||||
|
||||
export function check(): void {
|
||||
const accepted = getAcceptedObject();
|
||||
if (accepted === null) {
|
||||
if (acceptedCookiesLS.get() === undefined) {
|
||||
show();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ function update(mode: Mode): void {
|
|||
if (allmode2 === undefined) return;
|
||||
|
||||
const list: PBWithMode2[] = [];
|
||||
(Object.keys(allmode2) as Mode2<Mode>[]).forEach(function (key) {
|
||||
Object.keys(allmode2).forEach(function (key) {
|
||||
let pbs = allmode2[key] ?? [];
|
||||
pbs = pbs.sort(function (a, b) {
|
||||
return b.wpm - a.wpm;
|
||||
|
|
|
@ -58,7 +58,7 @@ function updateIndicatorAndButton(): void {
|
|||
|
||||
if (!val) {
|
||||
indicator?.hide();
|
||||
$("#saveCustomTextModal button.save").addClass("disabled");
|
||||
$("#saveCustomTextModal button.save").prop("disabled", true);
|
||||
} else {
|
||||
const names = CustomText.getCustomTextNames(checkbox);
|
||||
if (names.includes(val)) {
|
||||
|
|
|
@ -43,7 +43,7 @@ function updateURL(): void {
|
|||
}
|
||||
|
||||
if (getCheckboxValue("mode2")) {
|
||||
settings[1] = getMode2(Config, currentQuote) as Mode2<Mode>;
|
||||
settings[1] = getMode2(Config, currentQuote);
|
||||
}
|
||||
|
||||
if (getCheckboxValue("customText")) {
|
||||
|
|
|
@ -8,7 +8,10 @@ import * as ChartController from "../controllers/chart-controller";
|
|||
import * as ConnectionState from "../states/connection";
|
||||
import { intervalToDuration } from "date-fns/intervalToDuration";
|
||||
import * as Skeleton from "../utils/skeleton";
|
||||
import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
|
||||
import {
|
||||
TypingStats,
|
||||
SpeedHistogram,
|
||||
} from "@monkeytype/contracts/schemas/public";
|
||||
|
||||
function reset(): void {
|
||||
$(".pageAbout .contributors").empty();
|
||||
|
@ -19,7 +22,7 @@ function reset(): void {
|
|||
}
|
||||
|
||||
let speedHistogramResponseData: SpeedHistogram | null;
|
||||
let typingStatsResponseData: PublicTypingStats | null;
|
||||
let typingStatsResponseData: TypingStats | null;
|
||||
|
||||
function updateStatsAndHistogram(): void {
|
||||
if (speedHistogramResponseData) {
|
||||
|
@ -97,25 +100,27 @@ async function getStatsAndHistogramData(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const speedStats = await Ape.publicStats.getSpeedHistogram({
|
||||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
const speedStats = await Ape.public.getSpeedHistogram({
|
||||
query: {
|
||||
language: "english",
|
||||
mode: "time",
|
||||
mode2: "60",
|
||||
},
|
||||
});
|
||||
if (speedStats.status >= 200 && speedStats.status < 300) {
|
||||
speedHistogramResponseData = speedStats.data;
|
||||
if (speedStats.status === 200) {
|
||||
speedHistogramResponseData = speedStats.body.data;
|
||||
} else {
|
||||
Notifications.add(
|
||||
`Failed to get global speed stats for histogram: ${speedStats.message}`,
|
||||
`Failed to get global speed stats for histogram: ${speedStats.body.message}`,
|
||||
-1
|
||||
);
|
||||
}
|
||||
const typingStats = await Ape.publicStats.getTypingStats();
|
||||
if (typingStats.status >= 200 && typingStats.status < 300) {
|
||||
typingStatsResponseData = typingStats.data;
|
||||
const typingStats = await Ape.public.getTypingStats();
|
||||
if (typingStats.status === 200) {
|
||||
typingStatsResponseData = typingStats.body.data;
|
||||
} else {
|
||||
Notifications.add(
|
||||
`Failed to get global typing stats: ${speedStats.message}`,
|
||||
`Failed to get global typing stats: ${speedStats.body.message}`,
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
Mode2Custom,
|
||||
PersonalBests,
|
||||
} from "@monkeytype/contracts/schemas/shared";
|
||||
import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users";
|
||||
|
||||
let filterDebug = false;
|
||||
//toggle filterdebug
|
||||
|
@ -386,7 +387,7 @@ async function fillContent(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
let puncfilter: MonkeyTypes.Filter<"punctuation"> = "off";
|
||||
let puncfilter: ResultFiltersGroupItem<"punctuation"> = "off";
|
||||
if (result.punctuation) {
|
||||
puncfilter = "on";
|
||||
}
|
||||
|
@ -397,7 +398,7 @@ async function fillContent(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
let numfilter: MonkeyTypes.Filter<"numbers"> = "off";
|
||||
let numfilter: ResultFiltersGroupItem<"numbers"> = "off";
|
||||
if (result.numbers) {
|
||||
numfilter = "on";
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Config from "./config";
|
||||
import * as Misc from "./utils/misc";
|
||||
import * as MonkeyPower from "./elements/monkey-power";
|
||||
import * as Notifications from "./elements/notifications";
|
||||
import * as MerchBanner from "./elements/merch-banner";
|
||||
import * as CookiesModal from "./modals/cookies";
|
||||
import * as ConnectionState from "./states/connection";
|
||||
import * as FunboxList from "./test/funbox/funbox-list";
|
||||
|
@ -18,21 +18,7 @@ $((): void => {
|
|||
//this line goes back to pretty much the beginning of the project and im pretty sure its here
|
||||
//to make sure the initial theme application doesnt animate the background color
|
||||
$("body").css("transition", "background .25s, transform .05s");
|
||||
const merchBannerClosed =
|
||||
window.localStorage.getItem("merchbannerclosed") === "true";
|
||||
if (!merchBannerClosed) {
|
||||
Notifications.addBanner(
|
||||
`Check out our merchandise, available at <a target="_blank" rel="noopener" href="https://monkeytype.store/">monkeytype.store</a>`,
|
||||
1,
|
||||
"./images/merch2.png",
|
||||
false,
|
||||
() => {
|
||||
window.localStorage.setItem("merchbannerclosed", "true");
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
MerchBanner.showIfNotClosedBefore();
|
||||
setTimeout(() => {
|
||||
FunboxList.get(Config.funbox).forEach((it) =>
|
||||
it.functions?.applyGlobalCSS?.()
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
|
||||
const ls = new LocalStorageWithSchema({
|
||||
key: "prefersArabicLazyMode",
|
||||
schema: z.boolean(),
|
||||
fallback: true,
|
||||
});
|
||||
|
||||
export function get(): boolean {
|
||||
return (localStorage.getItem("prefersArabicLazyMode") ?? "true") === "true";
|
||||
return ls.get();
|
||||
}
|
||||
|
||||
export function set(value: boolean): void {
|
||||
localStorage.setItem("prefersArabicLazyMode", value ? "true" : "false");
|
||||
ls.set(value);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import { z } from "zod";
|
||||
import { getLatestReleaseFromGitHub } from "../utils/json-data";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
|
||||
const LOCALSTORAGE_KEY = "lastSeenVersion";
|
||||
const memoryLS = new LocalStorageWithSchema({
|
||||
key: "lastSeenVersion",
|
||||
schema: z.string(),
|
||||
fallback: "",
|
||||
});
|
||||
|
||||
let version: null | string = null;
|
||||
let isVersionNew: null | boolean = null;
|
||||
|
||||
function setMemory(v: string): void {
|
||||
window.localStorage.setItem(LOCALSTORAGE_KEY, v);
|
||||
memoryLS.set(v);
|
||||
}
|
||||
|
||||
function getMemory(): string {
|
||||
return window.localStorage.getItem(LOCALSTORAGE_KEY) ?? "";
|
||||
return memoryLS.get();
|
||||
}
|
||||
|
||||
async function check(): Promise<void> {
|
||||
|
|
|
@ -63,8 +63,12 @@ function getTargetPositionLeft(
|
|||
const currentLetter = currentWordNodeList[inputLen] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const lastWordLetter = currentWordNodeList[wordLen - 1] as HTMLElement;
|
||||
const lastInputLetter = currentWordNodeList[inputLen - 1] as HTMLElement;
|
||||
const lastWordLetter = currentWordNodeList[wordLen - 1] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const lastInputLetter = currentWordNodeList[inputLen - 1] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
if (isLanguageRightToLeft) {
|
||||
if (inputLen < wordLen && currentLetter) {
|
||||
|
@ -73,11 +77,11 @@ function getTargetPositionLeft(
|
|||
(fullWidthCaret ? 0 : fullWidthCaretWidth);
|
||||
} else if (!invisibleExtraLetters) {
|
||||
positionOffsetToWord =
|
||||
lastInputLetter.offsetLeft -
|
||||
(lastInputLetter?.offsetLeft ?? 0) -
|
||||
(fullWidthCaret ? fullWidthCaretWidth : 0);
|
||||
} else {
|
||||
positionOffsetToWord =
|
||||
lastWordLetter.offsetLeft -
|
||||
(lastWordLetter?.offsetLeft ?? 0) -
|
||||
(fullWidthCaret ? fullWidthCaretWidth : 0);
|
||||
}
|
||||
} else {
|
||||
|
@ -85,10 +89,12 @@ function getTargetPositionLeft(
|
|||
positionOffsetToWord = currentLetter?.offsetLeft;
|
||||
} else if (!invisibleExtraLetters) {
|
||||
positionOffsetToWord =
|
||||
lastInputLetter.offsetLeft + lastInputLetter.offsetWidth;
|
||||
(lastInputLetter?.offsetLeft ?? 0) +
|
||||
(lastInputLetter?.offsetWidth ?? 0);
|
||||
} else {
|
||||
positionOffsetToWord =
|
||||
lastWordLetter.offsetLeft + lastWordLetter.offsetWidth;
|
||||
(lastWordLetter?.offsetLeft ?? 0) +
|
||||
(lastWordLetter?.offsetWidth ?? 0);
|
||||
}
|
||||
}
|
||||
result = activeWordElement.offsetLeft + positionOffsetToWord;
|
||||
|
@ -143,7 +149,9 @@ export async function updatePosition(noAnim = false): Promise<void> {
|
|||
const currentLetter = currentWordNodeList[inputLen] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const lastWordLetter = currentWordNodeList[wordLen - 1] as HTMLElement;
|
||||
const lastWordLetter = currentWordNodeList[wordLen - 1] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
const spaceWidth = getSpaceWidth(activeWordEl);
|
||||
|
||||
|
@ -156,7 +164,7 @@ export async function updatePosition(noAnim = false): Promise<void> {
|
|||
lastWordLetter?.offsetHeight ||
|
||||
Config.fontSize * Numbers.convertRemToPixels(1);
|
||||
|
||||
const letterPosTop = lastWordLetter.offsetTop;
|
||||
const letterPosTop = lastWordLetter?.offsetTop ?? 0;
|
||||
const diff = letterHeight - caret.offsetHeight;
|
||||
let newTop = activeWordEl.offsetTop + letterPosTop + diff / 2;
|
||||
if (Config.caretStyle === "underline") {
|
||||
|
|
|
@ -7,6 +7,36 @@ import {
|
|||
CustomTextLimitMode,
|
||||
CustomTextMode,
|
||||
} from "@monkeytype/shared-types";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
//zod schema for an object with string keys and string values
|
||||
const CustomTextObjectSchema = z.record(z.string(), z.string());
|
||||
type CustomTextObject = z.infer<typeof CustomTextObjectSchema>;
|
||||
|
||||
const CustomTextLongObjectSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ text: z.string(), progress: z.number() })
|
||||
);
|
||||
type CustomTextLongObject = z.infer<typeof CustomTextLongObjectSchema>;
|
||||
|
||||
const customTextLS = new LocalStorageWithSchema({
|
||||
key: "customText",
|
||||
schema: CustomTextObjectSchema,
|
||||
fallback: {},
|
||||
});
|
||||
//todo maybe add migrations here?
|
||||
const customTextLongLS = new LocalStorageWithSchema({
|
||||
key: "customTextLong",
|
||||
schema: CustomTextLongObjectSchema,
|
||||
fallback: {},
|
||||
});
|
||||
|
||||
// function setLocalStorage(data: CustomTextObject): void {
|
||||
// window.localStorage.setItem("customText", JSON.stringify(data));
|
||||
// }
|
||||
|
||||
// function setLocalStorageLong(data: CustomTextLongObject): void {
|
||||
|
||||
let text: string[] = [
|
||||
"The",
|
||||
|
@ -95,10 +125,6 @@ export function getData(): CustomTextData {
|
|||
};
|
||||
}
|
||||
|
||||
type CustomTextObject = Record<string, string>;
|
||||
|
||||
type CustomTextLongObject = Record<string, { text: string; progress: number }>;
|
||||
|
||||
export function getCustomText(name: string, long = false): string[] {
|
||||
if (long) {
|
||||
const customTextLong = getLocalStorageLong();
|
||||
|
@ -185,23 +211,19 @@ export function setCustomTextLongProgress(
|
|||
}
|
||||
|
||||
function getLocalStorage(): CustomTextObject {
|
||||
return JSON.parse(
|
||||
window.localStorage.getItem("customText") ?? "{}"
|
||||
) as CustomTextObject;
|
||||
return customTextLS.get();
|
||||
}
|
||||
|
||||
function getLocalStorageLong(): CustomTextLongObject {
|
||||
return JSON.parse(
|
||||
window.localStorage.getItem("customTextLong") ?? "{}"
|
||||
) as CustomTextLongObject;
|
||||
return customTextLongLS.get();
|
||||
}
|
||||
|
||||
function setLocalStorage(data: CustomTextObject): void {
|
||||
window.localStorage.setItem("customText", JSON.stringify(data));
|
||||
customTextLS.set(data);
|
||||
}
|
||||
|
||||
function setLocalStorageLong(data: CustomTextLongObject): void {
|
||||
window.localStorage.setItem("customTextLong", JSON.stringify(data));
|
||||
customTextLongLS.set(data);
|
||||
}
|
||||
|
||||
export function getCustomTextNames(long = false): string[] {
|
||||
|
|
|
@ -8,7 +8,6 @@ import * as Numbers from "../utils/numbers";
|
|||
import * as JSONData from "../utils/json-data";
|
||||
import * as TestState from "./test-state";
|
||||
import * as ConfigEvent from "../observables/config-event";
|
||||
import { Mode2 } from "@monkeytype/contracts/schemas/shared";
|
||||
|
||||
type Settings = {
|
||||
wpm: number;
|
||||
|
@ -67,21 +66,22 @@ async function resetCaretPosition(): Promise<void> {
|
|||
|
||||
export async function init(): Promise<void> {
|
||||
$("#paceCaret").addClass("hidden");
|
||||
const mode2 = Misc.getMode2(Config, TestWords.currentQuote) as Mode2<
|
||||
typeof Config.mode
|
||||
>;
|
||||
let wpm;
|
||||
const mode2 = Misc.getMode2(Config, TestWords.currentQuote);
|
||||
let wpm = 0;
|
||||
if (Config.paceCaret === "pb") {
|
||||
wpm = await DB.getLocalPB(
|
||||
Config.mode,
|
||||
mode2,
|
||||
Config.punctuation,
|
||||
Config.numbers,
|
||||
Config.language,
|
||||
Config.difficulty,
|
||||
Config.lazyMode,
|
||||
Config.funbox
|
||||
);
|
||||
wpm =
|
||||
(
|
||||
await DB.getLocalPB(
|
||||
Config.mode,
|
||||
mode2,
|
||||
Config.punctuation,
|
||||
Config.numbers,
|
||||
Config.language,
|
||||
Config.difficulty,
|
||||
Config.lazyMode,
|
||||
Config.funbox
|
||||
)
|
||||
)?.wpm ?? 0;
|
||||
} else if (Config.paceCaret === "tagPb") {
|
||||
wpm = await DB.getActiveTagsPB(
|
||||
Config.mode,
|
||||
|
|
|
@ -176,7 +176,7 @@ async function updateGraph(): Promise<void> {
|
|||
|
||||
export async function updateGraphPBLine(): Promise<void> {
|
||||
const themecolors = await ThemeColors.getAll();
|
||||
const lpb = await DB.getLocalPB(
|
||||
const localPb = await DB.getLocalPB(
|
||||
result.mode,
|
||||
result.mode2,
|
||||
result.punctuation ?? false,
|
||||
|
@ -186,9 +186,12 @@ export async function updateGraphPBLine(): Promise<void> {
|
|||
result.lazyMode ?? false,
|
||||
result.funbox ?? "none"
|
||||
);
|
||||
if (lpb === 0) return;
|
||||
const localPbWpm = localPb?.wpm ?? 0;
|
||||
if (localPbWpm === 0) return;
|
||||
const typingSpeedUnit = getTypingSpeedUnit(Config.typingSpeedUnit);
|
||||
const chartlpb = Numbers.roundTo2(typingSpeedUnit.fromWpm(lpb)).toFixed(2);
|
||||
const chartlpb = Numbers.roundTo2(
|
||||
typingSpeedUnit.fromWpm(localPbWpm)
|
||||
).toFixed(2);
|
||||
resultAnnotation.push({
|
||||
display: true,
|
||||
type: "line",
|
||||
|
@ -394,7 +397,7 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
|
|||
const canGetPb = await resultCanGetPb();
|
||||
|
||||
if (canGetPb.value) {
|
||||
const lpb = await DB.getLocalPB(
|
||||
const localPb = await DB.getLocalPB(
|
||||
Config.mode,
|
||||
result.mode2,
|
||||
Config.punctuation,
|
||||
|
@ -404,7 +407,8 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
|
|||
Config.lazyMode,
|
||||
Config.funbox
|
||||
);
|
||||
pbDiff = result.wpm - lpb;
|
||||
const localPbWpm = localPb?.wpm ?? 0;
|
||||
pbDiff = result.wpm - localPbWpm;
|
||||
if (pbDiff <= 0) {
|
||||
hideCrown();
|
||||
} else {
|
||||
|
@ -415,7 +419,7 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
const lpb = await DB.getLocalPB(
|
||||
const localPb = await DB.getLocalPB(
|
||||
Config.mode,
|
||||
result.mode2,
|
||||
Config.punctuation,
|
||||
|
@ -425,7 +429,8 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
|
|||
Config.lazyMode,
|
||||
"none"
|
||||
);
|
||||
pbDiff = result.wpm - lpb;
|
||||
const localPbWpm = localPb?.wpm ?? 0;
|
||||
pbDiff = result.wpm - localPbWpm;
|
||||
if (pbDiff <= 0) {
|
||||
// hideCrown();
|
||||
showCrown("warning");
|
||||
|
|
|
@ -74,6 +74,7 @@ import {
|
|||
} from "@monkeytype/shared-types";
|
||||
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
|
||||
import { Mode } from "@monkeytype/contracts/schemas/shared";
|
||||
|
||||
let failReason = "";
|
||||
const koInputVisual = document.getElementById("koInputVisual") as HTMLElement;
|
||||
|
||||
|
@ -1345,22 +1346,30 @@ async function saveResult(
|
|||
|
||||
if (response?.data?.isPb !== undefined && response.data.isPb) {
|
||||
//new pb
|
||||
if (
|
||||
//@ts-expect-error TODO fix this
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
DB.getSnapshot()?.personalBests?.[Config.mode]?.[completedEvent.mode2]
|
||||
) {
|
||||
const localPb = await DB.getLocalPB(
|
||||
completedEvent.mode,
|
||||
completedEvent.mode2,
|
||||
completedEvent.punctuation,
|
||||
completedEvent.numbers,
|
||||
completedEvent.language,
|
||||
completedEvent.difficulty,
|
||||
completedEvent.lazyMode,
|
||||
completedEvent.funbox
|
||||
);
|
||||
|
||||
if (localPb !== undefined) {
|
||||
Result.showConfetti();
|
||||
}
|
||||
Result.showCrown("normal");
|
||||
|
||||
await DB.saveLocalPB(
|
||||
Config.mode,
|
||||
completedEvent.mode,
|
||||
completedEvent.mode2,
|
||||
Config.punctuation,
|
||||
Config.numbers,
|
||||
Config.language,
|
||||
Config.difficulty,
|
||||
Config.lazyMode,
|
||||
completedEvent.punctuation,
|
||||
completedEvent.numbers,
|
||||
completedEvent.language,
|
||||
completedEvent.difficulty,
|
||||
completedEvent.lazyMode,
|
||||
completedEvent.wpm,
|
||||
completedEvent.acc,
|
||||
completedEvent.rawWpm,
|
||||
|
|
|
@ -912,14 +912,6 @@ export async function updateWordElement(inputOverride?: string): Promise<void> {
|
|||
ret += `<letter>` + currentWord[i] + "</letter>";
|
||||
}
|
||||
}
|
||||
|
||||
if (Config.highlightMode === "letter") {
|
||||
if (input.length > currentWord.length && !Config.blindMode) {
|
||||
wordAtIndex.classList.add("error");
|
||||
} else if (input.length === currentWord.length) {
|
||||
wordAtIndex.classList.remove("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wordAtIndex.innerHTML = ret;
|
||||
|
|
17
frontend/src/ts/types/types.d.ts
vendored
17
frontend/src/ts/types/types.d.ts
vendored
|
@ -203,12 +203,6 @@ declare namespace MonkeyTypes {
|
|||
};
|
||||
};
|
||||
|
||||
type Leaderboards = {
|
||||
time: {
|
||||
[_key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
type QuoteRatings = Record<string, Record<number, number>>;
|
||||
|
||||
type UserTag = import("@monkeytype/shared-types").UserTag & {
|
||||
|
@ -237,7 +231,7 @@ declare namespace MonkeyTypes {
|
|||
inboxUnreadSize: number;
|
||||
streak: number;
|
||||
maxStreak: number;
|
||||
filterPresets: import("@monkeytype/shared-types").ResultFilters[];
|
||||
filterPresets: import("@monkeytype/contracts/schemas/users").ResultFilters[];
|
||||
isPremium: boolean;
|
||||
streakHourOffset?: number;
|
||||
config: import("@monkeytype/contracts/schemas/configs").Config;
|
||||
|
@ -251,15 +245,6 @@ declare namespace MonkeyTypes {
|
|||
testActivityByYear?: { [key: string]: TestActivityCalendar };
|
||||
};
|
||||
|
||||
type Group<
|
||||
G extends keyof import("@monkeytype/shared-types").ResultFilters = keyof import("@monkeytype/shared-types").ResultFilters
|
||||
> = G extends G ? import("@monkeytype/shared-types").ResultFilters[G] : never;
|
||||
|
||||
type Filter<G extends Group = Group> =
|
||||
G extends keyof import("@monkeytype/shared-types").ResultFilters
|
||||
? keyof import("@monkeytype/shared-types").ResultFilters[G]
|
||||
: never;
|
||||
|
||||
type TimerStats = {
|
||||
dateNow: number;
|
||||
now: number;
|
||||
|
|
66
frontend/src/ts/utils/local-storage-with-schema.ts
Normal file
66
frontend/src/ts/utils/local-storage-with-schema.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { ZodIssue } from "zod";
|
||||
|
||||
export class LocalStorageWithSchema<T> {
|
||||
private key: string;
|
||||
private schema: Zod.Schema<T>;
|
||||
private fallback: T;
|
||||
private migrate?: (value: unknown, zodIssues: ZodIssue[]) => T;
|
||||
|
||||
constructor(options: {
|
||||
key: string;
|
||||
schema: Zod.Schema<T>;
|
||||
fallback: T;
|
||||
migrate?: (value: unknown, zodIssues: ZodIssue[]) => T;
|
||||
}) {
|
||||
this.key = options.key;
|
||||
this.schema = options.schema;
|
||||
this.fallback = options.fallback;
|
||||
this.migrate = options.migrate;
|
||||
}
|
||||
|
||||
public get(): T {
|
||||
const value = window.localStorage.getItem(this.key);
|
||||
|
||||
if (value === null) {
|
||||
return this.fallback;
|
||||
}
|
||||
|
||||
let jsonParsed;
|
||||
try {
|
||||
jsonParsed = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Value from localStorage ${this.key} was not a valid JSON, using fallback`,
|
||||
e
|
||||
);
|
||||
window.localStorage.removeItem(this.key);
|
||||
return this.fallback;
|
||||
}
|
||||
|
||||
const schemaParsed = this.schema.safeParse(jsonParsed);
|
||||
|
||||
if (schemaParsed.success) {
|
||||
return schemaParsed.data;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Value from localStorage ${this.key} failed schema validation, migrating`,
|
||||
schemaParsed.error
|
||||
);
|
||||
const newValue =
|
||||
this.migrate?.(jsonParsed, schemaParsed.error.issues) ?? this.fallback;
|
||||
window.localStorage.setItem(this.key, JSON.stringify(newValue));
|
||||
return newValue;
|
||||
}
|
||||
|
||||
public set(data: T): boolean {
|
||||
try {
|
||||
const parsed = this.schema.parse(data);
|
||||
window.localStorage.setItem(this.key, JSON.stringify(parsed));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to set ${this.key} in localStorage`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +1,18 @@
|
|||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "./local-storage-with-schema";
|
||||
import { isDevEnvironment } from "./misc";
|
||||
|
||||
const nativeLog = console.log;
|
||||
const nativeWarn = console.warn;
|
||||
const nativeError = console.error;
|
||||
|
||||
let debugLogs = localStorage.getItem("debugLogs") === "true";
|
||||
const debugLogsLS = new LocalStorageWithSchema({
|
||||
key: "debugLogs",
|
||||
schema: z.boolean(),
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
let debugLogs = debugLogsLS.get();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
debugLogs = true;
|
||||
|
@ -14,7 +22,7 @@ if (isDevEnvironment()) {
|
|||
export function toggleDebugLogs(): void {
|
||||
debugLogs = !debugLogs;
|
||||
info(`Debug logs ${debugLogs ? "enabled" : "disabled"}`);
|
||||
localStorage.setItem("debugLogs", debugLogs.toString());
|
||||
debugLogsLS.set(debugLogs);
|
||||
}
|
||||
|
||||
function info(...args: unknown[]): void {
|
||||
|
|
|
@ -673,4 +673,8 @@ export function updateTitle(title?: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function isObject(obj: unknown): obj is Record<string, unknown> {
|
||||
return typeof obj === "object" && !Array.isArray(obj) && obj !== null;
|
||||
}
|
||||
|
||||
// DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES
|
||||
|
|
|
@ -1001,6 +1001,7 @@
|
|||
"shell",
|
||||
"neck",
|
||||
"program",
|
||||
"public"
|
||||
"public",
|
||||
"universe"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
"kasama",
|
||||
"taon",
|
||||
"mahal",
|
||||
"makita",
|
||||
"kita",
|
||||
"ninyo",
|
||||
"ngunit",
|
||||
"marami",
|
||||
|
@ -107,7 +107,7 @@
|
|||
"panahon",
|
||||
"ayaw",
|
||||
"buong",
|
||||
"bayan",
|
||||
"pasok",
|
||||
"ulit",
|
||||
"tungkol",
|
||||
"tama",
|
||||
|
@ -125,8 +125,8 @@
|
|||
"totoo",
|
||||
"tunay",
|
||||
"amin",
|
||||
"laging",
|
||||
"gawin",
|
||||
"lagi",
|
||||
"gawa",
|
||||
"kanina",
|
||||
"mundo",
|
||||
"dati",
|
||||
|
@ -139,7 +139,7 @@
|
|||
"sina",
|
||||
"daan",
|
||||
"ganito",
|
||||
"sige",
|
||||
"diyan",
|
||||
"lugar",
|
||||
"laban",
|
||||
"bukas",
|
||||
|
@ -160,7 +160,7 @@
|
|||
"labas",
|
||||
"umalis",
|
||||
"rin",
|
||||
"unti",
|
||||
"kaunti",
|
||||
"ayon",
|
||||
"uri",
|
||||
"kaysa",
|
||||
|
@ -168,30 +168,30 @@
|
|||
"maliit",
|
||||
"malaki",
|
||||
"dulo",
|
||||
"sumunod",
|
||||
"sinabi",
|
||||
"kamay",
|
||||
"luma",
|
||||
"tulong",
|
||||
"saka",
|
||||
"tingnan",
|
||||
"kabila",
|
||||
"gamitin",
|
||||
"gamit",
|
||||
"kunin",
|
||||
"pagitan",
|
||||
"tumayo",
|
||||
"ilagay",
|
||||
"mukha",
|
||||
"pangkat",
|
||||
"maayos",
|
||||
"subalit",
|
||||
"malapit",
|
||||
"salita",
|
||||
"susunod",
|
||||
"simulan",
|
||||
"buksan",
|
||||
"mahaba",
|
||||
"simula",
|
||||
"isip",
|
||||
"tagal",
|
||||
"kay",
|
||||
"mataas",
|
||||
"takbo",
|
||||
"higit",
|
||||
"sobra",
|
||||
"likod",
|
||||
"ilalim",
|
||||
"naging",
|
||||
|
|
|
@ -3300,6 +3300,24 @@
|
|||
"source": "Oscar Wilde",
|
||||
"length": 124,
|
||||
"id": 575
|
||||
},
|
||||
{
|
||||
"text": "Die Welt wird von deinem Beispiel verändert, nicht von deiner Meinung.",
|
||||
"source": "Paulo Coelho",
|
||||
"length": 70,
|
||||
"id": 576
|
||||
},
|
||||
{
|
||||
"text": "Die Einzige Macht, die du auf diesem Planeten hast, ist die Macht deiner Entscheidungen.",
|
||||
"source": "Paulo Coelho",
|
||||
"length": 88,
|
||||
"id": 577
|
||||
},
|
||||
{
|
||||
"text": "Eines Tages wirdst du erwachen, und es wird keine Zeit dafür geben, zu machen, was du schon immer machen wolltest. Mach es lieber jetzt.",
|
||||
"source": "Paulo Coelho",
|
||||
"length": 136,
|
||||
"id": 578
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -33,6 +33,11 @@ const BASE_CONFIG = {
|
|||
open: process.env.SERVER_OPEN !== "false",
|
||||
port: 3000,
|
||||
host: process.env.BACKEND_URL !== undefined,
|
||||
watch: {
|
||||
//we rebuild the whole contracts package when a file changes
|
||||
//so we only want to watch one file
|
||||
ignored: [/.*\/packages\/contracts\/dist\/(?!configs).*/],
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
root: "src",
|
||||
|
|
13
package.json
13
package.json
|
@ -8,6 +8,7 @@
|
|||
"full-check": "turbo lint ts-check build test validate-json",
|
||||
"prepare": "husky install",
|
||||
"pre-commit": "lint-staged",
|
||||
"ts-check": "turbo run ts-check",
|
||||
"lint": "turbo run lint",
|
||||
"lint-be": "turbo run lint --filter @monkeytype/backend",
|
||||
"lint-fe": "turbo run lint --filter @monkeytype/frontend",
|
||||
|
@ -56,24 +57,18 @@
|
|||
"node": "20.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@monkeytype/release": "workspace:*",
|
||||
"@commitlint/cli": "17.7.1",
|
||||
"@commitlint/config-conventional": "17.7.0",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"@monkeytype/release": "workspace:*",
|
||||
"conventional-changelog": "4.0.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"eslint-plugin-json": "2.1.2",
|
||||
"eslint-plugin-require-path-exists": "1.1.9",
|
||||
"husky": "8.0.1",
|
||||
"knip": "2.19.2",
|
||||
"lint-staged": "13.2.3",
|
||||
"only-allow": "1.2.1",
|
||||
"prettier": "2.5.1",
|
||||
"turbo": "2.0.9",
|
||||
"typescript": "5.5.4",
|
||||
"wait-for-localhost-cli": "3.2.0"
|
||||
"vitest": "2.0.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{json,scss,css,html}": [
|
||||
|
|
|
@ -81,9 +81,10 @@ if (isWatch) {
|
|||
console.log("Starting watch mode...");
|
||||
chokidar.watch("./src/**/*.ts").on(
|
||||
"change",
|
||||
(path) => {
|
||||
(_path) => {
|
||||
console.log("File change detected...");
|
||||
build(path, false, false);
|
||||
// build(path, false, false);
|
||||
buildAll(false, false);
|
||||
},
|
||||
{
|
||||
ignoreInitial: true,
|
||||
|
|
|
@ -4,6 +4,8 @@ import { apeKeysContract } from "./ape-keys";
|
|||
import { configsContract } from "./configs";
|
||||
import { presetsContract } from "./presets";
|
||||
import { psasContract } from "./psas";
|
||||
import { publicContract } from "./public";
|
||||
import { leaderboardsContract } from "./leaderboards";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
@ -13,4 +15,6 @@ export const contract = c.router({
|
|||
configs: configsContract,
|
||||
presets: presetsContract,
|
||||
psas: psasContract,
|
||||
public: publicContract,
|
||||
leaderboards: leaderboardsContract,
|
||||
});
|
||||
|
|
174
packages/contracts/src/leaderboards.ts
Normal file
174
packages/contracts/src/leaderboards.ts
Normal file
|
@ -0,0 +1,174 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
responseWithData,
|
||||
responseWithNullableData,
|
||||
} from "./schemas/api";
|
||||
import {
|
||||
DailyLeaderboardRankSchema,
|
||||
LeaderboardEntrySchema,
|
||||
LeaderboardRankSchema,
|
||||
XpLeaderboardEntrySchema,
|
||||
XpLeaderboardRankSchema,
|
||||
} from "./schemas/leaderboards";
|
||||
import { LanguageSchema } from "./schemas/util";
|
||||
import { Mode2Schema, ModeSchema } from "./schemas/shared";
|
||||
import { initContract } from "@ts-rest/core";
|
||||
|
||||
export const LanguageAndModeQuerySchema = z.object({
|
||||
language: LanguageSchema,
|
||||
mode: ModeSchema,
|
||||
mode2: Mode2Schema,
|
||||
});
|
||||
export type LanguageAndModeQuery = z.infer<typeof LanguageAndModeQuerySchema>;
|
||||
const PaginationQuerySchema = z.object({
|
||||
skip: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().nonnegative().max(50).optional(),
|
||||
});
|
||||
|
||||
export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
|
||||
PaginationQuerySchema
|
||||
);
|
||||
export type GetLeaderboardQuery = z.infer<typeof GetLeaderboardQuerySchema>;
|
||||
export const GetLeaderboardResponseSchema = responseWithData(
|
||||
z.array(LeaderboardEntrySchema)
|
||||
);
|
||||
export type GetLeaderboardResponse = z.infer<
|
||||
typeof GetLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const GetLeaderboardRankResponseSchema = responseWithData(
|
||||
LeaderboardRankSchema
|
||||
);
|
||||
export type GetLeaderboardRankResponse = z.infer<
|
||||
typeof GetLeaderboardRankResponseSchema
|
||||
>;
|
||||
|
||||
export const GetDailyLeaderboardRankQuerySchema =
|
||||
LanguageAndModeQuerySchema.extend({
|
||||
daysBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetDailyLeaderboardRankQuery = z.infer<
|
||||
typeof GetDailyLeaderboardRankQuerySchema
|
||||
>;
|
||||
|
||||
export const GetDailyLeaderboardQuerySchema =
|
||||
GetDailyLeaderboardRankQuerySchema.merge(PaginationQuerySchema);
|
||||
export type GetDailyLeaderboardQuery = z.infer<
|
||||
typeof GetDailyLeaderboardQuerySchema
|
||||
>;
|
||||
|
||||
export const GetLeaderboardDailyRankResponseSchema = responseWithData(
|
||||
DailyLeaderboardRankSchema
|
||||
);
|
||||
export type GetLeaderboardDailyRankResponse = z.infer<
|
||||
typeof GetLeaderboardDailyRankResponseSchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardQuerySchema = PaginationQuerySchema.extend({
|
||||
weeksBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetWeeklyXpLeaderboardQuery = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardQuerySchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardResponseSchema = responseWithData(
|
||||
z.array(XpLeaderboardEntrySchema)
|
||||
);
|
||||
export type GetWeeklyXpLeaderboardResponse = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardRankResponseSchema =
|
||||
responseWithNullableData(XpLeaderboardRankSchema.partial());
|
||||
export type GetWeeklyXpLeaderboardRankResponse = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardRankResponseSchema
|
||||
>;
|
||||
|
||||
const c = initContract();
|
||||
export const leaderboardsContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get leaderboard",
|
||||
description: "Get all-time leaderboard.",
|
||||
method: "GET",
|
||||
path: "",
|
||||
query: GetLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getRank: {
|
||||
summary: "get leaderboard rank",
|
||||
description:
|
||||
"Get the rank of the current user on the all-time leaderboard",
|
||||
method: "GET",
|
||||
path: "/rank",
|
||||
query: LanguageAndModeQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardRankResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { acceptApeKeys: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getDaily: {
|
||||
summary: "get daily leaderboard",
|
||||
description: "Get daily leaderboard.",
|
||||
method: "GET",
|
||||
path: "/daily",
|
||||
query: GetDailyLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getDailyRank: {
|
||||
summary: "get daily leaderboard rank",
|
||||
description: "Get the rank of the current user on the daily leaderboard",
|
||||
method: "GET",
|
||||
path: "/daily/rank",
|
||||
query: GetDailyLeaderboardRankQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardDailyRankResponseSchema,
|
||||
},
|
||||
},
|
||||
getWeeklyXp: {
|
||||
summary: "get weekly xp leaderboard",
|
||||
description: "Get weekly xp leaderboard",
|
||||
method: "GET",
|
||||
path: "/xp/weekly",
|
||||
query: GetWeeklyXpLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetWeeklyXpLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getWeeklyXpRank: {
|
||||
summary: "get weekly xp leaderboard rank",
|
||||
description:
|
||||
"Get the rank of the current user on the weekly xp leaderboard",
|
||||
method: "GET",
|
||||
path: "/xp/weekly/rank",
|
||||
responses: {
|
||||
200: GetWeeklyXpLeaderboardRankResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/leaderboards",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "leaderboards",
|
||||
} as EndpointMetadata,
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
70
packages/contracts/src/public.ts
Normal file
70
packages/contracts/src/public.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
responseWithData,
|
||||
} from "./schemas/api";
|
||||
import { SpeedHistogramSchema, TypingStatsSchema } from "./schemas/public";
|
||||
import { Mode2Schema, ModeSchema } from "./schemas/shared";
|
||||
import { LanguageSchema } from "./schemas/util";
|
||||
|
||||
export const GetSpeedHistogramQuerySchema = z
|
||||
.object({
|
||||
language: LanguageSchema,
|
||||
mode: ModeSchema,
|
||||
mode2: Mode2Schema,
|
||||
})
|
||||
.strict();
|
||||
export type GetSpeedHistogramQuery = z.infer<
|
||||
typeof GetSpeedHistogramQuerySchema
|
||||
>;
|
||||
|
||||
export const GetSpeedHistogramResponseSchema =
|
||||
responseWithData(SpeedHistogramSchema);
|
||||
export type GetSpeedHistogramResponse = z.infer<
|
||||
typeof GetSpeedHistogramResponseSchema
|
||||
>;
|
||||
|
||||
export const GetTypingStatsResponseSchema = responseWithData(TypingStatsSchema);
|
||||
export type GetTypingStatsResponse = z.infer<
|
||||
typeof GetTypingStatsResponseSchema
|
||||
>;
|
||||
|
||||
const c = initContract();
|
||||
export const publicContract = c.router(
|
||||
{
|
||||
getSpeedHistogram: {
|
||||
summary: "get speed histogram",
|
||||
description:
|
||||
"get number of users personal bests grouped by wpm level (multiples of ten)",
|
||||
method: "GET",
|
||||
path: "/speedHistogram",
|
||||
query: GetSpeedHistogramQuerySchema,
|
||||
responses: {
|
||||
200: GetSpeedHistogramResponseSchema,
|
||||
},
|
||||
},
|
||||
|
||||
getTypingStats: {
|
||||
summary: "get typing stats",
|
||||
description: "get number of tests and time users spend typing.",
|
||||
method: "GET",
|
||||
path: "/typingStats",
|
||||
responses: {
|
||||
200: GetTypingStatsResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/public",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "public",
|
||||
authenticationOptions: {
|
||||
isPublic: true,
|
||||
},
|
||||
} as EndpointMetadata,
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
|
@ -1,6 +1,13 @@
|
|||
import { z, ZodSchema } from "zod";
|
||||
|
||||
export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin" | "psas";
|
||||
export type OpenApiTag =
|
||||
| "configs"
|
||||
| "presets"
|
||||
| "ape-keys"
|
||||
| "admin"
|
||||
| "psas"
|
||||
| "public"
|
||||
| "leaderboards";
|
||||
|
||||
export type EndpointMetadata = {
|
||||
/** Authentication options, by default a bearer token is required. */
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { token } from "./util";
|
||||
import { LanguageSchema, token } from "./util";
|
||||
import * as Shared from "./shared";
|
||||
|
||||
export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]);
|
||||
|
@ -262,12 +262,6 @@ export type FontFamily = z.infer<typeof FontFamilySchema>;
|
|||
export const ThemeNameSchema = token().max(50);
|
||||
export type ThemeName = z.infer<typeof ThemeNameSchema>;
|
||||
|
||||
export const LanguageSchema = z
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(/^[a-zA-Z0-9_+]+$/);
|
||||
export type Language = z.infer<typeof LanguageSchema>;
|
||||
|
||||
export const KeymapLayoutSchema = z
|
||||
.string()
|
||||
.max(50)
|
||||
|
|
47
packages/contracts/src/schemas/leaderboards.ts
Normal file
47
packages/contracts/src/schemas/leaderboards.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const LeaderboardEntrySchema = z.object({
|
||||
wpm: z.number().nonnegative(),
|
||||
acc: z.number().nonnegative().min(0).max(100),
|
||||
timestamp: z.number().int().nonnegative(),
|
||||
raw: z.number().nonnegative(),
|
||||
consistency: z.number().nonnegative().optional(),
|
||||
uid: z.string(),
|
||||
name: z.string(),
|
||||
discordId: z.string().optional(),
|
||||
discordAvatar: z.string().optional(),
|
||||
rank: z.number().nonnegative().int(),
|
||||
badgeId: z.number().int().optional(),
|
||||
isPremium: z.boolean().optional(),
|
||||
});
|
||||
export type LeaderboardEntry = z.infer<typeof LeaderboardEntrySchema>;
|
||||
|
||||
export const LeaderboardRankSchema = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
rank: z.number().int().nonnegative().optional(),
|
||||
entry: LeaderboardEntrySchema.optional(),
|
||||
});
|
||||
export type LeaderboardRank = z.infer<typeof LeaderboardRankSchema>;
|
||||
|
||||
export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({
|
||||
minWpm: z.number().nonnegative(),
|
||||
});
|
||||
export type DailyLeaderboardRank = z.infer<typeof DailyLeaderboardRankSchema>;
|
||||
|
||||
export const XpLeaderboardEntrySchema = z.object({
|
||||
uid: z.string(),
|
||||
name: z.string(),
|
||||
discordId: z.string().optional(),
|
||||
discordAvatar: z.string().optional(),
|
||||
badgeId: z.number().int().optional(),
|
||||
lastActivityTimestamp: z.number().int().nonnegative(),
|
||||
timeTypedSeconds: z.number().nonnegative(),
|
||||
rank: z.number().nonnegative().int(),
|
||||
totalXp: z.number().nonnegative().int(),
|
||||
});
|
||||
export type XpLeaderboardEntry = z.infer<typeof XpLeaderboardEntrySchema>;
|
||||
|
||||
export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({
|
||||
count: z.number().int().nonnegative(),
|
||||
});
|
||||
export type XpLeaderboardRank = z.infer<typeof XpLeaderboardRankSchema>;
|
15
packages/contracts/src/schemas/public.ts
Normal file
15
packages/contracts/src/schemas/public.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
import { StringNumberSchema } from "./util";
|
||||
|
||||
export const SpeedHistogramSchema = z.record(
|
||||
StringNumberSchema,
|
||||
z.number().int()
|
||||
);
|
||||
export type SpeedHistogram = z.infer<typeof SpeedHistogramSchema>;
|
||||
|
||||
export const TypingStatsSchema = z.object({
|
||||
timeTyping: z.number().nonnegative(),
|
||||
testsCompleted: z.number().int().nonnegative(),
|
||||
testsStarted: z.number().int().nonnegative(),
|
||||
});
|
||||
export type TypingStats = z.infer<typeof TypingStatsSchema>;
|
|
@ -1,4 +1,4 @@
|
|||
import { z } from "zod";
|
||||
import { literal, z } from "zod";
|
||||
import { StringNumberSchema } from "./util";
|
||||
|
||||
//used by config and shared
|
||||
|
@ -33,9 +33,19 @@ export const PersonalBestsSchema = z.object({
|
|||
});
|
||||
export type PersonalBests = z.infer<typeof PersonalBestsSchema>;
|
||||
|
||||
//used by user and config
|
||||
//used by user, config, public
|
||||
export const ModeSchema = PersonalBestsSchema.keyof();
|
||||
export type Mode = z.infer<typeof ModeSchema>;
|
||||
|
||||
export const Mode2Schema = z.union(
|
||||
[StringNumberSchema, literal("zen"), literal("custom")],
|
||||
{
|
||||
errorMap: () => ({
|
||||
message: 'Needs to be either a number, "zen" or "custom."',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export type Mode2<M extends Mode> = M extends M
|
||||
? keyof PersonalBests[M]
|
||||
: never;
|
||||
|
|
|
@ -1 +1,62 @@
|
|||
//tbd
|
||||
import { z } from "zod";
|
||||
import { IdSchema } from "./util";
|
||||
import { ModeSchema } from "./shared";
|
||||
|
||||
export const ResultFiltersSchema = z.object({
|
||||
_id: IdSchema,
|
||||
name: z.string(),
|
||||
pb: z.object({
|
||||
no: z.boolean(),
|
||||
yes: z.boolean(),
|
||||
}),
|
||||
difficulty: z.object({
|
||||
normal: z.boolean(),
|
||||
expert: z.boolean(),
|
||||
master: z.boolean(),
|
||||
}),
|
||||
mode: z.record(ModeSchema, z.boolean()),
|
||||
words: z.object({
|
||||
"10": z.boolean(),
|
||||
"25": z.boolean(),
|
||||
"50": z.boolean(),
|
||||
"100": z.boolean(),
|
||||
custom: z.boolean(),
|
||||
}),
|
||||
time: z.object({
|
||||
"15": z.boolean(),
|
||||
"30": z.boolean(),
|
||||
"60": z.boolean(),
|
||||
"120": z.boolean(),
|
||||
custom: z.boolean(),
|
||||
}),
|
||||
quoteLength: z.object({
|
||||
short: z.boolean(),
|
||||
medium: z.boolean(),
|
||||
long: z.boolean(),
|
||||
thicc: z.boolean(),
|
||||
}),
|
||||
punctuation: z.object({
|
||||
on: z.boolean(),
|
||||
off: z.boolean(),
|
||||
}),
|
||||
numbers: z.object({
|
||||
on: z.boolean(),
|
||||
off: z.boolean(),
|
||||
}),
|
||||
date: z.object({
|
||||
last_day: z.boolean(),
|
||||
last_week: z.boolean(),
|
||||
last_month: z.boolean(),
|
||||
last_3months: z.boolean(),
|
||||
all: z.boolean(),
|
||||
}),
|
||||
tags: z.record(z.boolean()),
|
||||
language: z.record(z.boolean()),
|
||||
funbox: z.record(z.boolean()),
|
||||
});
|
||||
export type ResultFilters = z.infer<typeof ResultFiltersSchema>;
|
||||
|
||||
export type ResultFiltersGroup = keyof ResultFilters;
|
||||
|
||||
export type ResultFiltersGroupItem<T extends ResultFiltersGroup> =
|
||||
keyof ResultFilters[T];
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { z, ZodString } from "zod";
|
||||
|
||||
export const StringNumberSchema = z.custom<`${number}`>((val) => {
|
||||
return typeof val === "string" ? /^\d+$/.test(val) : false;
|
||||
});
|
||||
|
||||
export const StringNumberSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^\d+$/,
|
||||
'Needs to be a number or a number represented as a string e.g. "10".'
|
||||
)
|
||||
.or(z.number().transform(String));
|
||||
export type StringNumber = z.infer<typeof StringNumberSchema>;
|
||||
|
||||
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);
|
||||
|
@ -13,3 +16,9 @@ export type Id = z.infer<typeof IdSchema>;
|
|||
|
||||
export const TagSchema = token().max(50);
|
||||
export type Tag = z.infer<typeof TagSchema>;
|
||||
|
||||
export const LanguageSchema = z
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]");
|
||||
export type Language = z.infer<typeof LanguageSchema>;
|
||||
|
|
|
@ -32,7 +32,7 @@ module.exports = {
|
|||
"no-constant-condition": ["error"],
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^(_|e|event)",
|
||||
caughtErrorsIgnorePattern: "^(_|e|error)",
|
||||
|
@ -91,11 +91,16 @@ module.exports = {
|
|||
"error",
|
||||
{ ignoreArrowShorthand: true },
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": ["error"],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"error",
|
||||
{
|
||||
allowExpressions: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/no-empty-function": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^(_|e|event)",
|
||||
caughtErrorsIgnorePattern: "^(_|e|error)",
|
||||
|
@ -116,7 +121,7 @@ module.exports = {
|
|||
checksVoidReturn: false,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/promise-function-async": "warn",
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/strict-boolean-expressions": [
|
||||
"error",
|
||||
|
@ -124,7 +129,7 @@ module.exports = {
|
|||
],
|
||||
"@typescript-eslint/non-nullable-type-assertion-style": "off",
|
||||
"@typescript-eslint/no-unnecessary-condition": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": ["warn", "type"],
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"import/namespace": "off",
|
||||
},
|
||||
|
|
|
@ -4,6 +4,10 @@
|
|||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.0.1",
|
||||
"@typescript-eslint/parser": "8.0.1",
|
||||
"eslint-config-prettier": "9.1.0"
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"eslint-plugin-json": "3.1.0",
|
||||
"eslint-plugin-require-path-exists": "1.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -240,94 +240,6 @@ export type CustomTextDataWithTextLen = Omit<CustomTextData, "text"> & {
|
|||
textLen: number;
|
||||
};
|
||||
|
||||
export type ResultFilters = {
|
||||
_id: string;
|
||||
name: string;
|
||||
pb: {
|
||||
no: boolean;
|
||||
yes: boolean;
|
||||
};
|
||||
difficulty: {
|
||||
normal: boolean;
|
||||
expert: boolean;
|
||||
master: boolean;
|
||||
};
|
||||
mode: {
|
||||
words: boolean;
|
||||
time: boolean;
|
||||
quote: boolean;
|
||||
zen: boolean;
|
||||
custom: boolean;
|
||||
};
|
||||
words: {
|
||||
"10": boolean;
|
||||
"25": boolean;
|
||||
"50": boolean;
|
||||
"100": boolean;
|
||||
custom: boolean;
|
||||
};
|
||||
time: {
|
||||
"15": boolean;
|
||||
"30": boolean;
|
||||
"60": boolean;
|
||||
"120": boolean;
|
||||
custom: boolean;
|
||||
};
|
||||
quoteLength: {
|
||||
short: boolean;
|
||||
medium: boolean;
|
||||
long: boolean;
|
||||
thicc: boolean;
|
||||
};
|
||||
punctuation: {
|
||||
on: boolean;
|
||||
off: boolean;
|
||||
};
|
||||
numbers: {
|
||||
on: boolean;
|
||||
off: boolean;
|
||||
};
|
||||
date: {
|
||||
last_day: boolean;
|
||||
last_week: boolean;
|
||||
last_month: boolean;
|
||||
last_3months: boolean;
|
||||
all: boolean;
|
||||
};
|
||||
tags: Record<string, boolean>;
|
||||
language: Record<string, boolean>;
|
||||
funbox: {
|
||||
none?: boolean;
|
||||
} & Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type SpeedHistogram = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
export type PublicTypingStats = {
|
||||
type: string;
|
||||
timeTyping: number;
|
||||
testsCompleted: number;
|
||||
testsStarted: number;
|
||||
};
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
_id: string;
|
||||
wpm: number;
|
||||
acc: number;
|
||||
timestamp: number;
|
||||
raw: number;
|
||||
consistency?: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
discordId?: string;
|
||||
discordAvatar?: string;
|
||||
rank: number;
|
||||
badgeId?: number;
|
||||
isPremium?: boolean;
|
||||
};
|
||||
|
||||
export type PostResultResponse = {
|
||||
isPb: boolean;
|
||||
tagPbs: string[];
|
||||
|
@ -419,7 +331,7 @@ export type User = {
|
|||
verified?: boolean;
|
||||
needsToChangeName?: boolean;
|
||||
quoteMod?: boolean | string;
|
||||
resultFilterPresets?: ResultFilters[];
|
||||
resultFilterPresets?: import("@monkeytype/contracts/schemas/users").ResultFilters[];
|
||||
testActivity?: TestActivity;
|
||||
};
|
||||
|
||||
|
|
633
pnpm-lock.yaml
633
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
11
vitest.config.js
Normal file
11
vitest.config.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
enabled: true,
|
||||
include: ["**/*.ts"],
|
||||
reporter: ["json"],
|
||||
},
|
||||
},
|
||||
});
|
1
vitest.workspace.json
Normal file
1
vitest.workspace.json
Normal file
|
@ -0,0 +1 @@
|
|||
["packages/*", "frontend", "backend"]
|
Loading…
Reference in a new issue