From 6c9148624e4014f4093233df063979fb872d40ad Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 11:55:54 +0200 Subject: [PATCH 01/32] impr: use authentication state instead of the dom !nuf --- frontend/src/ts/commandline/lists/navigation.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/commandline/lists/navigation.ts b/frontend/src/ts/commandline/lists/navigation.ts index 71b2c1123..d9d7e1460 100644 --- a/frontend/src/ts/commandline/lists/navigation.ts +++ b/frontend/src/ts/commandline/lists/navigation.ts @@ -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"); }, }, { From c50535cd0f2c801550ffeafca5a1de142ec341b1 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 9 Aug 2024 12:39:27 +0200 Subject: [PATCH 02/32] impr: use tsrest for public endpoints (@fehmer) (#5716) !nuf --- .../__tests__/api/controllers/public.spec.ts | 144 ++++++++++++++++++ backend/scripts/openapi.ts | 5 + backend/src/api/controllers/public.ts | 29 ++-- backend/src/api/routes/index.ts | 29 +++- backend/src/api/routes/public.ts | 54 ++----- backend/src/dal/public.ts | 9 +- backend/src/utils/pb.ts | 5 +- backend/src/utils/prometheus.ts | 2 +- frontend/src/ts/ape/endpoints/index.ts | 2 - frontend/src/ts/ape/endpoints/public.ts | 27 ---- frontend/src/ts/ape/index.ts | 3 +- frontend/src/ts/modals/pb-tables.ts | 2 +- frontend/src/ts/modals/share-test-settings.ts | 2 +- frontend/src/ts/pages/about.ts | 27 ++-- frontend/src/ts/test/pace-caret.ts | 5 +- packages/contracts/src/index.ts | 2 + packages/contracts/src/public.ts | 70 +++++++++ packages/contracts/src/schemas/api.ts | 8 +- packages/contracts/src/schemas/public.ts | 15 ++ packages/contracts/src/schemas/shared.ts | 14 +- packages/contracts/src/schemas/util.ts | 16 +- packages/shared-types/src/index.ts | 11 -- 22 files changed, 349 insertions(+), 132 deletions(-) create mode 100644 backend/__tests__/api/controllers/public.spec.ts delete mode 100644 frontend/src/ts/ape/endpoints/public.ts create mode 100644 packages/contracts/src/public.ts create mode 100644 packages/contracts/src/schemas/public.ts diff --git a/backend/__tests__/api/controllers/public.spec.ts b/backend/__tests__/api/controllers/public.spec.ts new file mode 100644 index 000000000..accc90a9a --- /dev/null +++ b/backend/__tests__/api/controllers/public.spec.ts @@ -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" Invalid', + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + 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, + }, + }); + }); + }); +}); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 4b444caa1..b5140de6c 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -66,6 +66,11 @@ 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: "psas", description: "Public service announcements.", diff --git a/backend/src/api/controllers/public.ts b/backend/src/api/controllers/public.ts index f49dca031..505f5caa9 100644 --- a/backend/src/api/controllers/public.ts +++ b/backend/src/api/controllers/public.ts @@ -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 { +export async function getSpeedHistogram( + req: MonkeyTypes.Request2 +): Promise { 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 { +export async function getTypingStats( + _req: MonkeyTypes.Request2 +): Promise { const data = await PublicDAL.getTypingStats(); - return new MonkeyResponse("Public typing stats retrieved", data); + return new MonkeyResponse2("Public typing stats retrieved", data); } diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index c64ee8cfd..453a4c128 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -43,7 +43,6 @@ const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { "/users": users, "/results": results, - "/public": publicStats, "/leaderboards": leaderboards, "/quotes": quotes, "/webhooks": webhooks, @@ -57,6 +56,7 @@ const router = s.router(contract, { configs, presets, psas, + public: publicStats, }); export function addApiRoutes(app: Application): void { @@ -78,16 +78,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()], }); diff --git a/backend/src/api/routes/public.ts b/backend/src/api/routes/public.ts index 86491c6b3..b5e310fb7 100644 --- a/backend/src/api/routes/public.ts +++ b/backend/src/api/routes/public.ts @@ -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), + }, +}); diff --git a/backend/src/dal/public.ts b/backend/src/dal/public.ts index 08b72edd0..50a230a78 100644 --- a/backend/src/dal/public.ts +++ b/backend/src/dal/public.ts @@ -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; diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts index 76041605d..b00d747a5 100644 --- a/backend/src/utils/pb.ts +++ b/backend/src/utils/pb.ts @@ -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; diff --git a/backend/src/utils/prometheus.ts b/backend/src/utils/prometheus.ts index f98fdc90c..040e1492e 100644 --- a/backend/src/utils/prometheus.ts +++ b/backend/src/utils/prometheus.ts @@ -105,7 +105,7 @@ export function incrementResult(res: Result): void { punctuation, } = res; - let m2 = mode2 as string; + let m2 = mode2; if (mode === "time" && !["15", "30", "60", "120"].includes(mode2)) { m2 = "custom"; } diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 2dae7057b..609c791b9 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -2,13 +2,11 @@ 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, diff --git a/frontend/src/ts/ape/endpoints/public.ts b/frontend/src/ts/ape/endpoints/public.ts deleted file mode 100644 index 5f4b417d3..000000000 --- a/frontend/src/ts/ape/endpoints/public.ts +++ /dev/null @@ -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 { - return await this.httpClient.get(`${BASE_PATH}/speedHistogram`, { - searchQuery, - }); - } - - async getTypingStats(): Ape.EndpointResponse { - return await this.httpClient.get(`${BASE_PATH}/typingStats`); - } -} diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 6bebc1c3c..1d14030a0 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -6,6 +6,7 @@ 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 { publicContract } from "@monkeytype/contracts/public"; const API_PATH = ""; const BASE_URL = envConfig.backendUrl; @@ -22,7 +23,7 @@ const Ape = { quotes: new endpoints.Quotes(httpClient), leaderboards: new endpoints.Leaderboards(httpClient), presets: buildClient(presetsContract, BASE_URL, 10_000), - publicStats: new endpoints.Public(httpClient), + publicStats: buildClient(publicContract, BASE_URL, 10_000), apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000), configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), diff --git a/frontend/src/ts/modals/pb-tables.ts b/frontend/src/ts/modals/pb-tables.ts index 736d87666..a78a8c9c8 100644 --- a/frontend/src/ts/modals/pb-tables.ts +++ b/frontend/src/ts/modals/pb-tables.ts @@ -34,7 +34,7 @@ function update(mode: Mode): void { if (allmode2 === undefined) return; const list: PBWithMode2[] = []; - (Object.keys(allmode2) as Mode2[]).forEach(function (key) { + Object.keys(allmode2).forEach(function (key) { let pbs = allmode2[key] ?? []; pbs = pbs.sort(function (a, b) { return b.wpm - a.wpm; diff --git a/frontend/src/ts/modals/share-test-settings.ts b/frontend/src/ts/modals/share-test-settings.ts index 14bed5c51..ca6285fe4 100644 --- a/frontend/src/ts/modals/share-test-settings.ts +++ b/frontend/src/ts/modals/share-test-settings.ts @@ -43,7 +43,7 @@ function updateURL(): void { } if (getCheckboxValue("mode2")) { - settings[1] = getMode2(Config, currentQuote) as Mode2; + settings[1] = getMode2(Config, currentQuote); } if (getCheckboxValue("customText")) { diff --git a/frontend/src/ts/pages/about.ts b/frontend/src/ts/pages/about.ts index 6e93ebf05..af3328ca7 100644 --- a/frontend/src/ts/pages/about.ts +++ b/frontend/src/ts/pages/about.ts @@ -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) { @@ -98,24 +101,26 @@ async function getStatsAndHistogramData(): Promise { } const speedStats = await Ape.publicStats.getSpeedHistogram({ - language: "english", - mode: "time", - mode2: "60", + 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; + 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 ); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 29a6fab97..76533d8dd 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -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,9 +66,7 @@ async function resetCaretPosition(): Promise { export async function init(): Promise { $("#paceCaret").addClass("hidden"); - const mode2 = Misc.getMode2(Config, TestWords.currentQuote) as Mode2< - typeof Config.mode - >; + const mode2 = Misc.getMode2(Config, TestWords.currentQuote); let wpm; if (Config.paceCaret === "pb") { wpm = await DB.getLocalPB( diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e7f383639..1224a0e19 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ import { apeKeysContract } from "./ape-keys"; import { configsContract } from "./configs"; import { presetsContract } from "./presets"; import { psasContract } from "./psas"; +import { publicContract } from "./public"; const c = initContract(); @@ -13,4 +14,5 @@ export const contract = c.router({ configs: configsContract, presets: presetsContract, psas: psasContract, + public: publicContract, }); diff --git a/packages/contracts/src/public.ts b/packages/contracts/src/public.ts new file mode 100644 index 000000000..93b2b81f9 --- /dev/null +++ b/packages/contracts/src/public.ts @@ -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, + } +); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index f90e47dbd..305d6100a 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -1,6 +1,12 @@ import { z, ZodSchema } from "zod"; -export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin" | "psas"; +export type OpenApiTag = + | "configs" + | "presets" + | "ape-keys" + | "admin" + | "psas" + | "public"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ diff --git a/packages/contracts/src/schemas/public.ts b/packages/contracts/src/schemas/public.ts new file mode 100644 index 000000000..5a89122eb --- /dev/null +++ b/packages/contracts/src/schemas/public.ts @@ -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; + +export const TypingStatsSchema = z.object({ + timeTyping: z.number().nonnegative(), + testsCompleted: z.number().int().nonnegative(), + testsStarted: z.number().int().nonnegative(), +}); +export type TypingStats = z.infer; diff --git a/packages/contracts/src/schemas/shared.ts b/packages/contracts/src/schemas/shared.ts index 0826839a8..5eb97b5d4 100644 --- a/packages/contracts/src/schemas/shared.ts +++ b/packages/contracts/src/schemas/shared.ts @@ -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; -//used by user and config +//used by user, config, public export const ModeSchema = PersonalBestsSchema.keyof(); export type Mode = z.infer; + +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 M ? keyof PersonalBests[M] : never; diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts index 74e5ed963..25d9a4b77 100644 --- a/packages/contracts/src/schemas/util.ts +++ b/packages/contracts/src/schemas/util.ts @@ -1,8 +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 + + .custom<`${number}`>((val) => { + if (typeof val === "number") val = val.toString(); + return typeof val === "string" ? /^\d+$/.test(val) : false; + }, 'Needs to be a number or a number represented as a string e.g. "10".') + .transform(String); export type StringNumber = z.infer; @@ -13,3 +17,9 @@ export type Id = z.infer; export const TagSchema = token().max(50); export type Tag = z.infer; + +export const LanguageSchema = z + .string() + .max(50) + .regex(/^[a-zA-Z0-9_+]+$/); +export type Language = z.infer; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index fe8d50ea6..d069ecd3c 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -301,17 +301,6 @@ export type ResultFilters = { } & Record; }; -export type SpeedHistogram = { - [key: string]: number; -}; - -export type PublicTypingStats = { - type: string; - timeTyping: number; - testsCompleted: number; - testsStarted: number; -}; - export type LeaderboardEntry = { _id: string; wpm: number; From f32846de32802d13e998afed038896eb2154623f Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 12:24:21 +0200 Subject: [PATCH 03/32] refactor: getLocalPb returns pb object instead of just wpm also use this function in test logic also fixes todo --- frontend/src/ts/db.ts | 27 ++++++++++++-------------- frontend/src/ts/test/pace-caret.ts | 25 +++++++++++++----------- frontend/src/ts/test/result.ts | 19 +++++++++++------- frontend/src/ts/test/test-logic.ts | 31 +++++++++++++++++++----------- 4 files changed, 58 insertions(+), 44 deletions(-) diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 39356c8f0..10c8fcb59 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -623,29 +623,26 @@ export async function getLocalPB( difficulty: Difficulty, lazyMode: boolean, funbox: string -): Promise { +): Promise { 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 ); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 76533d8dd..2ff540e87 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -67,18 +67,21 @@ async function resetCaretPosition(): Promise { export async function init(): Promise { $("#paceCaret").addClass("hidden"); const mode2 = Misc.getMode2(Config, TestWords.currentQuote); - let wpm; + 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, diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 2193e6850..c6f6ea649 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -170,7 +170,7 @@ async function updateGraph(): Promise { export async function updateGraphPBLine(): Promise { const themecolors = await ThemeColors.getAll(); - const lpb = await DB.getLocalPB( + const localPb = await DB.getLocalPB( result.mode, result.mode2, result.punctuation ?? false, @@ -180,9 +180,12 @@ export async function updateGraphPBLine(): Promise { 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", @@ -388,7 +391,7 @@ export async function updateCrown(dontSave: boolean): Promise { const canGetPb = await resultCanGetPb(); if (canGetPb.value) { - const lpb = await DB.getLocalPB( + const localPb = await DB.getLocalPB( Config.mode, result.mode2, Config.punctuation, @@ -398,7 +401,8 @@ export async function updateCrown(dontSave: boolean): Promise { Config.lazyMode, Config.funbox ); - pbDiff = result.wpm - lpb; + const localPbWpm = localPb?.wpm ?? 0; + pbDiff = result.wpm - localPbWpm; if (pbDiff <= 0) { hideCrown(); } else { @@ -409,7 +413,7 @@ export async function updateCrown(dontSave: boolean): Promise { ); } } else { - const lpb = await DB.getLocalPB( + const localPb = await DB.getLocalPB( Config.mode, result.mode2, Config.punctuation, @@ -419,7 +423,8 @@ export async function updateCrown(dontSave: boolean): Promise { Config.lazyMode, "none" ); - pbDiff = result.wpm - lpb; + const localPbWpm = localPb?.wpm ?? 0; + pbDiff = result.wpm - localPbWpm; if (pbDiff <= 0) { // hideCrown(); showCrown("warning"); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 398d02be5..a53a14b33 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -64,6 +64,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; @@ -1196,22 +1197,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, From 24f9a6c0799a279d3a6b1c18dc7cabd6cb20be05 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 12:42:02 +0200 Subject: [PATCH 04/32] chore: add ts-check script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a1fcb9a6f..e88a55fd4 100644 --- a/package.json +++ b/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", From d27c622943985de4975ff88bd37e4bb676537126 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 12:51:17 +0200 Subject: [PATCH 05/32] chore: move example.env to release package --- example.env => packages/release/example.env | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename example.env => packages/release/example.env (100%) diff --git a/example.env b/packages/release/example.env similarity index 100% rename from example.env rename to packages/release/example.env From 782eea643b5596556aa6224938dc4ded3c2e9ee2 Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 9 Aug 2024 13:19:53 +0200 Subject: [PATCH 06/32] chore: remove some unnecessary root dependencies, move to correct packages (@miodec) (#5751) * remove some, move some * version --- package.json | 12 +- packages/eslint-config/package.json | 6 +- pnpm-lock.yaml | 207 +++++----------------------- 3 files changed, 39 insertions(+), 186 deletions(-) diff --git a/package.json b/package.json index e88a55fd4..9c90297e3 100644 --- a/package.json +++ b/package.json @@ -56,24 +56,16 @@ "node": "20.16.0" }, "devDependencies": { - "@monkeytype/release": "workspace:*", "@commitlint/cli": "17.7.1", "@commitlint/config-conventional": "17.7.0", + "@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" + "turbo": "2.0.9" }, "lint-staged": { "*.{json,scss,css,html}": [ diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 52745214f..43411d45c 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84e7440d9..b44f21b3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,24 +20,6 @@ importers: conventional-changelog: specifier: 4.0.0 version: 4.0.0 - eslint: - specifier: 8.57.0 - version: 8.57.0 - eslint-config-prettier: - specifier: 9.0.0 - version: 9.0.0(eslint@8.57.0) - eslint-import-resolver-typescript: - specifier: 3.6.1 - version: 3.6.1(eslint-plugin-import@2.29.0)(eslint@8.57.0) - eslint-plugin-import: - specifier: 2.29.0 - version: 2.29.0(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-json: - specifier: 2.1.2 - version: 2.1.2 - eslint-plugin-require-path-exists: - specifier: 1.1.9 - version: 1.1.9 husky: specifier: 8.0.1 version: 8.0.1 @@ -56,12 +38,6 @@ importers: turbo: specifier: 2.0.9 version: 2.0.9 - typescript: - specifier: 5.5.4 - version: 5.5.4 - wait-for-localhost-cli: - specifier: 3.2.0 - version: 3.2.0 backend: dependencies: @@ -527,6 +503,18 @@ importers: eslint-config-prettier: specifier: 9.1.0 version: 9.1.0(eslint@8.57.0) + eslint-import-resolver-typescript: + specifier: 3.6.1 + version: 3.6.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.0)(eslint@8.57.0) + eslint-plugin-import: + specifier: 2.29.0 + version: 2.29.0(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-json: + specifier: 3.1.0 + version: 3.1.0 + eslint-plugin-require-path-exists: + specifier: 1.1.9 + version: 1.1.9 packages/release: dependencies: @@ -3386,10 +3374,6 @@ packages: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} engines: {node: '>=8'} - camelcase-keys@7.0.2: - resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} - engines: {node: '>=12'} - camelcase@3.0.0: resolution: {integrity: sha512-4nhGqUkc4BqbBBB4Q6zLuD7lzzrHYrjKGeYaEji/3tFR5VdJu9v+LilhGIVe8wxEJPPOeWo7eg8dwY13TZ1BNg==} engines: {node: '>=0.10.0'} @@ -3979,10 +3963,6 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - decamelize@5.0.1: - resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} - engines: {node: '>=10'} - decko@1.2.0: resolution: {integrity: sha512-m8FnyHXV1QX+S1cl+KPFDIl6NMkxtKsy6+U/aYyjrOqWMuwAwYWu7ePqrsUHtDR5Y8Yk2pi/KIDSgF+vT4cPOQ==} @@ -4421,12 +4401,6 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-prettier@9.0.0: - resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - eslint-config-prettier@9.1.0: resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} hasBin: true @@ -4474,9 +4448,9 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-json@2.1.2: - resolution: {integrity: sha512-isM/fsUxS4wN1+nLsWoV5T4gLgBQnsql3nMTr8u+cEls1bL8rRQO5CP5GtxJxaOfbcKqnz401styw+H/P+e78Q==} - engines: {node: '>=8.10.0'} + eslint-plugin-json@3.1.0: + resolution: {integrity: sha512-MrlG2ynFEHe7wDGwbUuFPsaT2b1uhuEFhJ+W1f1u+1C2EkXmTYJp4B1aAdQQ8M+CC3t//N/oRKiIVw14L2HR1g==} + engines: {node: '>=12.0'} eslint-plugin-require-path-exists@1.1.9: resolution: {integrity: sha512-moZRfrPr4GFyT/W8PHzjzC7D4Hnj7Us+GYj0fbVKQoPvP4xIF8VG702L1jzyhqE8eIYkcs8p1CoqSfjk9WkxBg==} @@ -5427,10 +5401,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - indexes-of@1.0.1: resolution: {integrity: sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA==} @@ -6389,10 +6359,6 @@ packages: mensch@0.3.4: resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} - meow@10.1.5: - resolution: {integrity: sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - meow@8.1.2: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} @@ -7630,10 +7596,6 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - quote-unquote@1.0.0: resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} @@ -7709,10 +7671,6 @@ packages: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} - read-pkg-up@8.0.0: - resolution: {integrity: sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==} - engines: {node: '>=12'} - read-pkg@1.1.0: resolution: {integrity: sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==} engines: {node: '>=0.10.0'} @@ -7725,10 +7683,6 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} - read-pkg@6.0.0: - resolution: {integrity: sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==} - engines: {node: '>=12'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -7763,10 +7717,6 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - redent@4.0.0: - resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} - engines: {node: '>=12'} - redeyed@2.1.1: resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==} @@ -8496,10 +8446,6 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - strip-indent@4.0.0: - resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==} - engines: {node: '>=12'} - strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -8787,10 +8733,6 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} - trim-newlines@4.1.1: - resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} - engines: {node: '>=12'} - triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -8916,10 +8858,6 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} - type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -9314,8 +9252,8 @@ packages: vlq@0.2.3: resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==} - vscode-json-languageservice@3.11.0: - resolution: {integrity: sha512-QxI+qV97uD7HHOCjh3MrM1TfbdwmTXrMckri5Tus1/FQiG3baDZb2C9Y0y8QThs7PwHYBIQXcAc59ZveCRZKPA==} + vscode-json-languageservice@4.2.1: + resolution: {integrity: sha512-xGmv9QIWs2H8obGbWg+sIPI/3/pFgj/5OWBhNzs00BkYQ9UaB2F6JJaGB/2/YOZJ3BvLXQTC4Q7muqU25QgAhA==} vscode-jsonrpc@6.0.0: resolution: {integrity: sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==} @@ -9334,9 +9272,6 @@ packages: vscode-languageserver-types@3.16.0: resolution: {integrity: sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==} - vscode-languageserver-types@3.16.0-next.2: - resolution: {integrity: sha512-QjXB7CKIfFzKbiCJC4OWC8xUncLsxo19FzGVp/ADFvvi87PlmBSCAtZI5xwGjF5qE0xkLf0jjKUn3DzmpDP52Q==} - vscode-languageserver@7.0.0: resolution: {integrity: sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==} hasBin: true @@ -9344,21 +9279,9 @@ packages: vscode-nls@5.2.0: resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} - vscode-uri@2.1.2: - resolution: {integrity: sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==} - vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - wait-for-localhost-cli@3.2.0: - resolution: {integrity: sha512-Qeb/137B3PSiQSddXMGS40i7w2G2pWJ/01bJLQ4pk+WZ7V3+i7fIbKajf6/MPcBqYPJvNdYWAuTJ53ahhqZBog==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - wait-for-localhost@4.1.0: - resolution: {integrity: sha512-i3yX7qgAnxFXWOS6Om7SNi19HRygFvCGh0iy0nOsrYOSiIlhUqoUuzLoW4jHVYS4dzNGzRU100uwTyluOMWcjw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - walkdir@0.4.1: resolution: {integrity: sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==} engines: {node: '>=6.0.0'} @@ -12922,13 +12845,6 @@ snapshots: map-obj: 4.3.0 quick-lru: 4.0.1 - camelcase-keys@7.0.2: - dependencies: - camelcase: 6.3.0 - map-obj: 4.3.0 - quick-lru: 5.1.1 - type-fest: 1.4.0 - camelcase@3.0.0: {} camelcase@5.3.1: {} @@ -13548,8 +13464,6 @@ snapshots: decamelize@1.2.0: {} - decamelize@5.0.1: {} - decko@1.2.0: {} decode-uri-component@0.2.2: {} @@ -14142,10 +14056,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@9.0.0(eslint@8.57.0): - dependencies: - eslint: 8.57.0 - eslint-config-prettier@9.1.0(eslint@8.57.0): dependencies: eslint: 8.57.0 @@ -14158,13 +14068,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(eslint-plugin-import@2.29.0)(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.0)(eslint@8.57.0): dependencies: debug: 4.3.6(supports-color@5.5.0) enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.8.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(eslint-plugin-import@2.29.0)(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.0(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.6 is-core-module: 2.15.0 @@ -14175,17 +14085,18 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(eslint-plugin-import@2.29.0)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.0)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.0.1(eslint@8.57.0)(typescript@5.5.4) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(eslint-plugin-import@2.29.0)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.0)(eslint@8.57.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.0(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-plugin-import@2.29.0(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -14195,7 +14106,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(eslint-plugin-import@2.29.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.0.1(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.0)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.0 is-glob: 4.0.3 @@ -14205,15 +14116,17 @@ snapshots: object.values: 1.2.0 semver: 6.3.1 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.0.1(eslint@8.57.0)(typescript@5.5.4) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-json@2.1.2: + eslint-plugin-json@3.1.0: dependencies: lodash: 4.17.21 - vscode-json-languageservice: 3.11.0 + vscode-json-languageservice: 4.2.1 eslint-plugin-require-path-exists@1.1.9: dependencies: @@ -15625,8 +15538,6 @@ snapshots: indent-string@4.0.0: {} - indent-string@5.0.0: {} - indexes-of@1.0.1: {} inflight@1.0.6: @@ -16619,21 +16530,6 @@ snapshots: mensch@0.3.4: {} - meow@10.1.5: - dependencies: - '@types/minimist': 1.2.5 - camelcase-keys: 7.0.2 - decamelize: 5.0.1 - decamelize-keys: 1.1.1 - hard-rejection: 2.1.0 - minimist-options: 4.1.0 - normalize-package-data: 3.0.3 - read-pkg-up: 8.0.0 - redent: 4.0.0 - trim-newlines: 4.1.1 - type-fest: 1.4.0 - yargs-parser: 20.2.9 - meow@8.1.2: dependencies: '@types/minimist': 1.2.5 @@ -18134,8 +18030,6 @@ snapshots: quick-lru@4.0.1: {} - quick-lru@5.1.1: {} - quote-unquote@1.0.0: {} raf@3.4.1: @@ -18227,12 +18121,6 @@ snapshots: read-pkg: 5.2.0 type-fest: 0.8.1 - read-pkg-up@8.0.0: - dependencies: - find-up: 5.0.0 - read-pkg: 6.0.0 - type-fest: 1.4.0 - read-pkg@1.1.0: dependencies: load-json-file: 1.1.0 @@ -18252,13 +18140,6 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 - read-pkg@6.0.0: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 3.0.3 - parse-json: 5.2.0 - type-fest: 1.4.0 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -18310,11 +18191,6 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - redent@4.0.0: - dependencies: - indent-string: 5.0.0 - strip-indent: 4.0.0 - redeyed@2.1.1: dependencies: esprima: 4.0.1 @@ -19132,10 +19008,6 @@ snapshots: dependencies: min-indent: 1.0.1 - strip-indent@4.0.0: - dependencies: - min-indent: 1.0.1 - strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -19496,8 +19368,6 @@ snapshots: trim-newlines@3.0.1: {} - trim-newlines@4.1.1: {} - triple-beam@1.4.1: {} ts-api-utils@1.3.0(typescript@5.5.4): @@ -19603,8 +19473,6 @@ snapshots: type-fest@0.8.1: {} - type-fest@1.4.0: {} - type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -20065,13 +19933,13 @@ snapshots: vlq@0.2.3: {} - vscode-json-languageservice@3.11.0: + vscode-json-languageservice@4.2.1: dependencies: jsonc-parser: 3.3.1 vscode-languageserver-textdocument: 1.0.11 - vscode-languageserver-types: 3.16.0-next.2 + vscode-languageserver-types: 3.16.0 vscode-nls: 5.2.0 - vscode-uri: 2.1.2 + vscode-uri: 3.0.8 vscode-jsonrpc@6.0.0: {} @@ -20090,25 +19958,14 @@ snapshots: vscode-languageserver-types@3.16.0: {} - vscode-languageserver-types@3.16.0-next.2: {} - vscode-languageserver@7.0.0: dependencies: vscode-languageserver-protocol: 3.16.0 vscode-nls@5.2.0: {} - vscode-uri@2.1.2: {} - vscode-uri@3.0.8: {} - wait-for-localhost-cli@3.2.0: - dependencies: - meow: 10.1.5 - wait-for-localhost: 4.1.0 - - wait-for-localhost@4.1.0: {} - walkdir@0.4.1: {} wawoff2@2.0.1: From 8e343bc39066d724d6a039e3d549a3c3f2d18627 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 13:20:59 +0200 Subject: [PATCH 07/32] chore: replace all eslint warnings with errors --- packages/eslint-config/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index 46af1081d..37ffb2691 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -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)", @@ -93,9 +93,9 @@ module.exports = { ], "@typescript-eslint/explicit-function-return-type": ["error"], "@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 +116,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 +124,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", }, From 02505750ad98f3d5f3ac525458716a0c550c5993 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 9 Aug 2024 14:20:16 +0200 Subject: [PATCH 08/32] refactor: implement recordClientVersion as middleware (@fehmer) (#5750) * refactor: implement recordClientVersion as middleware (@fehmer) * move csp for docs into docs route * fix * review comments --- backend/src/api/routes/docs.ts | 11 ++++++++++- backend/src/api/routes/index.ts | 15 --------------- backend/src/api/routes/psas.ts | 3 ++- backend/src/middlewares/utility.ts | 18 +++++++++++++++++- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/backend/src/api/routes/docs.ts b/backend/src/api/routes/docs.ts index 6c02825a0..5892e1eda 100644 --- a/backend/src/api/routes/docs.ts +++ b/backend/src/api/routes/docs.ts @@ -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" + ); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 453a4c128..659a54180 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -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, @@ -152,20 +151,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(); } ); diff --git a/backend/src/api/routes/psas.ts b/backend/src/api/routes/psas.ts index 4a283009a..093b4c870 100644 --- a/backend/src/api/routes/psas.ts +++ b/backend/src/api/routes/psas.ts @@ -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), }, }); diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index bbfa5e87c..ff443dc3e 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -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(); + }; +} From 295e47638e48edb3fe1bf94800fcd1e5e9e8f3a8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 16:59:56 +0200 Subject: [PATCH 09/32] chore: rebuild all files instead of just what changed --- frontend/vite.config.js | 5 +++++ packages/contracts/esbuild.config.js | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c08904049..1360fc8f7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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", diff --git a/packages/contracts/esbuild.config.js b/packages/contracts/esbuild.config.js index 67701bcd4..6da919b4a 100644 --- a/packages/contracts/esbuild.config.js +++ b/packages/contracts/esbuild.config.js @@ -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, From 3eca42218212bde696c2ceb9cf2d9e1189d0595b Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 21:50:11 +0200 Subject: [PATCH 10/32] chore: add more recommended extensions --- .vscode/extensions.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index de1f33f44..c354a1411 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -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" ] } From 4995f042ac862e2489a7d361f2cffd8e31ab017e Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 9 Aug 2024 22:03:01 +0200 Subject: [PATCH 11/32] chore: fix eslint in dev script --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index d668b5ef5..cf0d4edea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", From cfea8eef19f925a2d918e9ce3cd1eb0aadf3a955 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 9 Aug 2024 22:07:09 +0200 Subject: [PATCH 12/32] refactor: use single client for whole contract on frontend (@fehmer) (#5752) * refactor: use single client for whole contract on frontend (@fehmer) * review comments --- frontend/src/ts/ape/index.ts | 13 +++---------- frontend/src/ts/pages/about.ts | 4 ++-- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 1d14030a0..46ddac7b3 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -2,29 +2,22 @@ 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 { publicContract } from "@monkeytype/contracts/public"; +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: buildClient(publicContract, BASE_URL, 10_000), - apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000), configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), }; diff --git a/frontend/src/ts/pages/about.ts b/frontend/src/ts/pages/about.ts index af3328ca7..df85802de 100644 --- a/frontend/src/ts/pages/about.ts +++ b/frontend/src/ts/pages/about.ts @@ -100,7 +100,7 @@ async function getStatsAndHistogramData(): Promise { return; } - const speedStats = await Ape.publicStats.getSpeedHistogram({ + const speedStats = await Ape.public.getSpeedHistogram({ query: { language: "english", mode: "time", @@ -115,7 +115,7 @@ async function getStatsAndHistogramData(): Promise { -1 ); } - const typingStats = await Ape.publicStats.getTypingStats(); + const typingStats = await Ape.public.getTypingStats(); if (typingStats.status === 200) { typingStatsResponseData = typingStats.body.data; } else { From a9caf24427153f5679f096e40b13f2418fc1bdcb Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 9 Aug 2024 22:21:18 +0200 Subject: [PATCH 13/32] fix: documentation link in settings (@fehmer) (#5755) --- frontend/src/html/pages/settings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index db8520dca..a4d38b440 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1566,7 +1566,7 @@ ( From 002ef8f6bf4b367ed3f6c9042e523c1dbae5ca5f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 10 Aug 2024 14:42:02 +0200 Subject: [PATCH 14/32] fix(caret): not working in zen mode --- frontend/src/ts/test/caret.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index 1feb1766d..3be4dfb4f 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -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 { 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 { 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") { From 142b51cc260f1929c2f064519da405f039a273a7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 10 Aug 2024 14:45:38 +0200 Subject: [PATCH 15/32] style: sliiiightly increase the horizontal word margin --- frontend/src/styles/test.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 3a677f3e8..dfdaf1149 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -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 { From 24e94479d3cc1f95e56e7d98c29d4c28a37ed75f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 10 Aug 2024 21:45:47 +0200 Subject: [PATCH 16/32] style: only apply error underline when moving to the next word --- frontend/src/ts/test/test-ui.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 1c77cd17a..73a03b357 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -899,14 +899,6 @@ export async function updateWordElement(inputOverride?: string): Promise { ret += `` + currentWord[i] + ""; } } - - 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; From 77c9cc423f288aec08d729a56c41edceef16ff7f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 10 Aug 2024 23:59:59 +0200 Subject: [PATCH 17/32] fix(server): incorrect apekeys permission check --- backend/src/api/routes/ape-keys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/api/routes/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index e6f85650d..2038c887a 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -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", }), From 61c9134030d6b7ce3b1bec7a9e4da07f7cb21206 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 11 Aug 2024 00:14:39 +0200 Subject: [PATCH 18/32] chore: add vitest workspace configuration file --- package.json | 3 +- pnpm-lock.yaml | 65 +++++++++++++++++++++++++++++++++++++++++++ vitest.workspace.json | 1 + 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 vitest.workspace.json diff --git a/package.json b/package.json index 9c90297e3..2eb6ee6d4 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "lint-staged": "13.2.3", "only-allow": "1.2.1", "prettier": "2.5.1", - "turbo": "2.0.9" + "turbo": "2.0.9", + "vitest": "1.6.0" }, "lint-staged": { "*.{json,scss,css,html}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b44f21b3d..c82bd171e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: turbo: specifier: 2.0.9 version: 2.0.9 + vitest: + specifier: 1.6.0 + version: 1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) backend: dependencies: @@ -19820,6 +19823,23 @@ snapshots: - supports-color - terser + vite-node@1.6.0(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3): + dependencies: + cac: 6.7.14 + debug: 4.3.6(supports-color@5.5.0) + pathe: 1.1.2 + picocolors: 1.0.1 + vite: 5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + vite-plugin-checker@0.6.4(eslint@8.57.0)(optionator@0.9.4)(typescript@5.5.4)(vite@5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3)): dependencies: '@babel/code-frame': 7.24.7 @@ -19885,6 +19905,17 @@ snapshots: sass: 1.70.0 terser: 5.31.3 + vite@5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3): + dependencies: + esbuild: 0.19.12 + postcss: 8.4.40 + rollup: 4.19.1 + optionalDependencies: + '@types/node': 20.5.1 + fsevents: 2.3.3 + sass: 1.70.0 + terser: 5.31.3 + vitest-mongodb@1.0.0: dependencies: debug: 4.3.6(supports-color@5.5.0) @@ -19931,6 +19962,40 @@ snapshots: - supports-color - terser + vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): + dependencies: + '@vitest/expect': 1.6.0 + '@vitest/runner': 1.6.0 + '@vitest/snapshot': 1.6.0 + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + acorn-walk: 8.3.3 + chai: 4.5.0 + debug: 4.3.6(supports-color@5.5.0) + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.11 + pathe: 1.1.2 + picocolors: 1.0.1 + std-env: 3.7.0 + strip-literal: 2.1.0 + tinybench: 2.8.0 + tinypool: 0.8.4 + vite: 5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) + vite-node: 1.6.0(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.5.1 + happy-dom: 13.4.1 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + vlq@0.2.3: {} vscode-json-languageservice@4.2.1: diff --git a/vitest.workspace.json b/vitest.workspace.json new file mode 100644 index 000000000..61cc01045 --- /dev/null +++ b/vitest.workspace.json @@ -0,0 +1 @@ +["packages/*", "frontend", "backend"] From ef8dfe22726b0ce96ffcb9e21b61e880ac231278 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 11 Aug 2024 12:07:42 +0200 Subject: [PATCH 19/32] chore: add test case for apekey permission bug (@fehmer) (#5762) --- backend/__tests__/api/controllers/ape-key.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/__tests__/api/controllers/ape-key.spec.ts b/backend/__tests__/api/controllers/ape-key.spec.ts index 4855ee5f4..5f33d32fe 100644 --- a/backend/__tests__/api/controllers/ape-key.spec.ts +++ b/backend/__tests__/api/controllers/ape-key.spec.ts @@ -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); }); From 092d513f01ad91cfa36eb5c20a67291bd895aebe Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 11 Aug 2024 17:50:26 +0200 Subject: [PATCH 20/32] chore: fix test coverage not working with vitest workspaces (@fehmer) (#5764) --- package.json | 1 + pnpm-lock.yaml | 22 ++++++++++++++++++++++ vitest.config.js | 11 +++++++++++ 3 files changed, 34 insertions(+) create mode 100644 vitest.config.js diff --git a/package.json b/package.json index 2eb6ee6d4..90caf0206 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "devDependencies": { "@commitlint/cli": "17.7.1", "@commitlint/config-conventional": "17.7.0", + "@vitest/coverage-v8": "1.6.0", "@monkeytype/release": "workspace:*", "conventional-changelog": "4.0.0", "husky": "8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c82bd171e..2d9ccbb0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@monkeytype/release': specifier: workspace:* version: link:packages/release + '@vitest/coverage-v8': + specifier: 1.6.0 + version: 1.6.0(vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) conventional-changelog: specifier: 4.0.0 version: 4.0.0 @@ -12188,6 +12191,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.3.6(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.11 + magicast: 0.3.4 + picocolors: 1.0.1 + std-env: 3.7.0 + strip-literal: 2.1.0 + test-exclude: 6.0.0 + vitest: 1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + transitivePeerDependencies: + - supports-color + '@vitest/expect@1.6.0': dependencies: '@vitest/spy': 1.6.0 diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 000000000..82eb8ea19 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + enabled: true, + include: ["**/*.ts"], + reporter: ["json"], + }, + }, +}); From d7a40c8d5ab798e14792dfd913c3374d7dd8668c Mon Sep 17 00:00:00 2001 From: Breno Queiroz Lima <72840950+brenob6@users.noreply.github.com> Date: Mon, 12 Aug 2024 07:25:09 -0300 Subject: [PATCH 21/32] fix(save custom text modal): save button not being enabled (@brenob6) (#5759) * fix: invalid return values * add: hexToRgb test suite * test: update hexToRgb test value * add more tests * fix: save custom text button not enabled (#5692) --------- Co-authored-by: Miodec --- frontend/src/ts/modals/save-custom-text.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/modals/save-custom-text.ts b/frontend/src/ts/modals/save-custom-text.ts index 728327537..47aa5a1d6 100644 --- a/frontend/src/ts/modals/save-custom-text.ts +++ b/frontend/src/ts/modals/save-custom-text.ts @@ -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)) { From 474ef2e53d04bea582e6a22b529d46e0d2cacdeb Mon Sep 17 00:00:00 2001 From: Avick Saha <70565523+avick-saha@users.noreply.github.com> Date: Mon, 12 Aug 2024 16:15:52 +0530 Subject: [PATCH 22/32] fix(language): add one thousandth word to english 1k (@avick-saha) (#5757) --- frontend/static/languages/english_1k.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/static/languages/english_1k.json b/frontend/static/languages/english_1k.json index c0d999dbd..cdcd0100b 100644 --- a/frontend/static/languages/english_1k.json +++ b/frontend/static/languages/english_1k.json @@ -1001,6 +1001,7 @@ "shell", "neck", "program", - "public" + "public", + "universe" ] } From 6d000aec6af3bf0510d8f5472b50554a2095f619 Mon Sep 17 00:00:00 2001 From: Jave Bantilan <69343139+Killer8Hyper@users.noreply.github.com> Date: Mon, 12 Aug 2024 18:46:54 +0800 Subject: [PATCH 23/32] impr: update filipino word list (@Killer8Hyper) (#5761) --- frontend/static/languages/filipino.json | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/static/languages/filipino.json b/frontend/static/languages/filipino.json index f7b4c26b4..931e5a5b7 100644 --- a/frontend/static/languages/filipino.json +++ b/frontend/static/languages/filipino.json @@ -84,7 +84,7 @@ "kasama", "taon", "mahal", - "makita", + "kita", "ninyo", "ngunit", "marami", @@ -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", From d5b243cf5707558a9c86cd85d12cb7eb1e2c2998 Mon Sep 17 00:00:00 2001 From: neinja <130762913+neinja007@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:49:15 +0200 Subject: [PATCH 24/32] impr(quotes): add 3 german quotes (@neinja007) (#5767) --- frontend/static/quotes/german.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/static/quotes/german.json b/frontend/static/quotes/german.json index 4112bd060..1ef408acf 100644 --- a/frontend/static/quotes/german.json +++ b/frontend/static/quotes/german.json @@ -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 } ] } From c6e8f413fc6f01621a0ab38802cc8cd41f9de4c0 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 12 Aug 2024 14:08:17 +0200 Subject: [PATCH 25/32] impr: use tsrest for leaderboard endpoints (@fehmer) (#5717) !nuf --- backend/__tests__/__testData__/auth.ts | 37 + .../api/controllers/leaderboard.spec.ts | 1128 ++++++++++++++++- .../__tests__/api/controllers/public.spec.ts | 4 +- backend/__tests__/dal/leaderboards.spec.ts | 36 +- backend/package.json | 2 +- backend/scripts/openapi.ts | 5 + backend/src/api/controllers/leaderboard.ts | 164 +-- backend/src/api/routes/index.ts | 2 +- backend/src/api/routes/leaderboards.ts | 145 +-- backend/src/dal/leaderboards.ts | 55 +- .../src/documentation/internal-swagger.json | 76 -- backend/src/documentation/public-swagger.json | 159 --- backend/src/jobs/update-leaderboards.ts | 7 +- backend/src/queues/george-queue.ts | 7 +- backend/src/services/weekly-xp-leaderboard.ts | 40 +- backend/src/utils/daily-leaderboards.ts | 48 +- frontend/src/ts/ape/endpoints/index.ts | 2 - frontend/src/ts/ape/endpoints/leaderboards.ts | 53 - frontend/src/ts/ape/index.ts | 1 - frontend/src/ts/ape/types/leaderboards.d.ts | 25 - frontend/src/ts/config.ts | 9 +- frontend/src/ts/elements/leaderboards.ts | 158 ++- frontend/src/ts/types/types.d.ts | 6 - packages/contracts/src/index.ts | 2 + packages/contracts/src/leaderboards.ts | 174 +++ packages/contracts/src/schemas/api.ts | 3 +- packages/contracts/src/schemas/configs.ts | 8 +- .../contracts/src/schemas/leaderboards.ts | 47 + packages/contracts/src/schemas/util.ts | 15 +- packages/shared-types/src/index.ts | 16 - pnpm-lock.yaml | 20 +- 31 files changed, 1708 insertions(+), 746 deletions(-) create mode 100644 backend/__tests__/__testData__/auth.ts delete mode 100644 frontend/src/ts/ape/endpoints/leaderboards.ts delete mode 100644 frontend/src/ts/ape/types/leaderboards.d.ts create mode 100644 packages/contracts/src/leaderboards.ts create mode 100644 packages/contracts/src/schemas/leaderboards.ts diff --git a/backend/__tests__/__testData__/auth.ts b/backend/__tests__/__testData__/auth.ts new file mode 100644 index 000000000..3a6c1f604 --- /dev/null +++ b/backend/__tests__/__testData__/auth.ts @@ -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 { + 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}`); +} diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 00fc042a8..c2f7bef45 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -1,35 +1,1117 @@ +import _ from "lodash"; +import { ObjectId } from "mongodb"; import request from "supertest"; import app from "../../../src/app"; +import * as LeaderboardDal from "../../../src/dal/leaderboards"; +import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; +import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; import * as Configuration from "../../../src/init/configuration"; +import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; +import { + LeaderboardEntry, + XpLeaderboardEntry, + XpLeaderboardRank, +} from "@monkeytype/contracts/schemas/leaderboards"; const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); -describe("leaderboards controller test", () => { - it("GET /leaderboards/xp/weekly", async () => { - const configSpy = vi - .spyOn(Configuration, "getCachedConfiguration") - .mockResolvedValue({ - leaderboards: { - weeklyXp: { - enabled: true, - expirationTimeInDays: 15, - xpRewardBrackets: [], - }, - }, - } as any); +const allModes = [ + "10", + "25", + "50", + "100", + "15", + "30", + "60", + "120", + "zen", + "custom", +]; - const response = await mockApp - .get("/leaderboards/xp/weekly") - .set({ - Accept: "application/json", - }) - .expect(200); +describe("Loaderboard Controller", () => { + describe("get leaderboard", () => { + const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get"); - expect(response.body).toEqual({ - message: "Weekly xp leaderboard retrieved", - data: [], + beforeEach(() => { + getLeaderboardMock.mockReset(); }); - configSpy.mockRestore(); + it("should get for english time 60", async () => { + //GIVEN + + const resultData = [ + { + wpm: 20, + acc: 90, + timestamp: 1000, + raw: 92, + consistency: 80, + uid: "user1", + name: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + rank: 1, + badgeId: 1, + isPremium: true, + }, + { + wpm: 10, + acc: 80, + timestamp: 1200, + raw: 82, + uid: "user2", + name: "user2", + rank: 2, + }, + ]; + const mockData = resultData.map((it) => ({ ...it, _id: new ObjectId() })); + getLeaderboardMock.mockResolvedValue(mockData); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Leaderboard retrieved", + data: resultData, + }); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + 0, + 50 + ); + }); + + it("should get for english time 60 with skip and limit", async () => { + //GIVEN + getLeaderboardMock.mockResolvedValue([]); + const skip = 23; + const limit = 42; + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "english", + mode: "time", + mode2: "60", + skip, + limit, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Leaderboard retrieved", + data: [], + }); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + skip, + limit + ); + }); + + it("should get for mode", async () => { + getLeaderboardMock.mockResolvedValue([]); + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards") + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("should get for mode2", async () => { + getLeaderboardMock.mockResolvedValue([]); + for (const mode2 of allModes) { + const response = await mockApp.get("/leaderboards").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("/leaderboards").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("/leaderboards") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + skip: -1, + limit: 100, + }) + .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".', + '"skip" Number must be greater than or equal to 0', + '"limit" Number must be less than or equal to 50', + ], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards") + .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'"], + }); + }); + it("fails while leaderboard is updating", async () => { + //GIVEN + getLeaderboardMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(503); + + expect(body.message).toEqual( + "Leaderboard is currently updating. Please try again in a few seconds." + ); + }); + }); + + describe("get rank", () => { + const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank"); + + afterEach(() => { + getLeaderboardRankMock.mockReset(); + }); + + it("fails withouth authentication", async () => { + await mockApp + .get("/leaderboards/rank") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(401); + }); + + it("should get for english time 60", async () => { + //GIVEN + + const entryId = new ObjectId(); + const resultEntry = { + _id: entryId.toHexString(), + wpm: 10, + acc: 80, + timestamp: 1200, + raw: 82, + uid: "user2", + name: "user2", + rank: 2, + }; + getLeaderboardRankMock.mockResolvedValue({ + count: 1000, + rank: 50, + entry: resultEntry, + }); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards/rank") + .query({ language: "english", mode: "time", mode2: "60" }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Rank retrieved", + data: { + count: 1000, + rank: 50, + entry: resultEntry, + }, + }); + + expect(getLeaderboardRankMock).toHaveBeenCalledWith( + "time", + "60", + "english", + uid + ); + }); + it("should get with ape key", async () => { + await acceptApeKeys(true); + const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); + + await mockApp + .get("/leaderboards/rank") + .query({ language: "english", mode: "time", mode2: "60" }) + .set("authorization", "ApeKey " + apeKey) + .expect(200); + }); + it("should get for mode", async () => { + getLeaderboardRankMock.mockResolvedValue({} as any); + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("should get for mode2", async () => { + getLeaderboardRankMock.mockResolvedValue({} as any); + for (const mode2 of allModes) { + const response = await mockApp + .get("/leaderboards/rank") + .set("authorization", `Uid ${uid}`) + .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("/leaderboards/rank") + .set("authorization", `Uid ${uid}`) + .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("/leaderboards/rank") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + }) + .set("authorization", `Uid ${uid}`) + .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("/leaderboards/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is updating", async () => { + //GIVEN + getLeaderboardRankMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .set("authorization", `Uid ${uid}`) + .expect(503); + + expect(body.message).toEqual( + "Leaderboard is currently updating. Please try again in a few seconds." + ); + }); + }); + + describe("get daily leaderboard", () => { + const getDailyLeaderboardMock = vi.spyOn( + DailyLeaderboards, + "getDailyLeaderboard" + ); + + beforeEach(async () => { + getDailyLeaderboardMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await dailyLeaderboardEnabled(true); + + getDailyLeaderboardMock.mockReturnValue({ + getResults: () => Promise.resolve([]), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should get for english time 60", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const premiumEnabled = (await configuration).users.premium.enabled; + + const resultData: LeaderboardEntry[] = [ + { + name: "user1", + rank: 1, + wpm: 20, + acc: 90, + timestamp: 1000, + raw: 92, + consistency: 80, + uid: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + }, + { + wpm: 10, + rank: 2, + acc: 80, + timestamp: 1200, + raw: 82, + consistency: 72, + uid: "user2", + name: "user2", + }, + ]; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue(resultData); + getDailyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard retrieved", + data: resultData, + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getResultMock).toHaveBeenCalledWith(0, 49, lbConf, premiumEnabled); + }); + + it("should get for english time 60 for yesterday", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + daysBefore: 1, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard retrieved", + data: [], + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + 1722470400000 + ); + }); + it("should get for english time 60 with skip and limit", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const premiumEnabled = (await configuration).users.premium.enabled; + const limit = 23; + const skip = 42; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue([]); + getDailyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + skip, + limit, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard retrieved", + data: [], + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getResultMock).toHaveBeenCalledWith( + skip, + skip + limit - 1, + lbConf, + premiumEnabled + ); + }); + + it("fails for daysBefore not one", async () => { + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + daysBefore: 2, + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ['"daysBefore" Invalid literal value, expected 1'], + }); + }); + + it("fails if daily leaderboards are disabled", async () => { + await dailyLeaderboardEnabled(false); + + const { body } = await mockApp.get("/leaderboards/daily").expect(503); + + expect(body.message).toEqual( + "Daily leaderboards are not available at this time." + ); + }); + + it("should get for mode", async () => { + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards/daily") + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("should get for mode2", async () => { + for (const mode2 of allModes) { + const response = await mockApp + .get("/leaderboards/daily") + .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("/leaderboards").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("/leaderboards/daily") + .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("/leaderboards/daily") + .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'"], + }); + }); + it("fails while leaderboard is missing", async () => { + //GIVEN + getDailyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily") + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(404); + + expect(body.message).toEqual( + "There is no daily leaderboard for this mode" + ); + }); + }); + + describe("get daily leaderboard rank", () => { + const getDailyLeaderboardMock = vi.spyOn( + DailyLeaderboards, + "getDailyLeaderboard" + ); + + beforeEach(async () => { + getDailyLeaderboardMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await dailyLeaderboardEnabled(true); + + getDailyLeaderboardMock.mockReturnValue({ + getRank: () => Promise.resolve({} as any), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fails withouth authentication", async () => { + await mockApp + .get("/leaderboards/daily/rank") + + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(401); + }); + it("should get for english time 60", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const rankData = { + min: 100, + count: 1000, + rank: 12, + entry: { + wpm: 10, + rank: 2, + acc: 80, + timestamp: 1200, + raw: 82, + consistency: 72, + uid: "user2", + name: "user2", + }, + }; + + const getRankMock = vi.fn(); + getRankMock.mockResolvedValue(rankData); + getDailyLeaderboardMock.mockReturnValue({ + getRank: getRankMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode: "time", mode2: "60" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Daily leaderboard rank retrieved", + data: rankData, + }); + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + }); + it("fails if daily leaderboards are disabled", async () => { + await dailyLeaderboardEnabled(false); + + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .expect(503); + + expect(body.message).toEqual( + "Daily leaderboards are not available at this time." + ); + }); + it("should get for mode", async () => { + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + it("should get for mode2", async () => { + for (const mode2 of allModes) { + const response = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .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("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .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("/leaderboards/daily/rank") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + }) + .set("authorization", `Uid ${uid}`) + .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("/leaderboards/daily/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is missing", async () => { + //GIVEN + getDailyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/daily/rank") + .set("authorization", `Uid ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(404); + + expect(body.message).toEqual( + "There is no daily leaderboard for this mode" + ); + }); + }); + + describe("get xp weekly leaderboard", () => { + const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + + beforeEach(async () => { + getXpWeeklyLeaderboardMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await weeklyLeaderboardEnabled(true); + + getXpWeeklyLeaderboardMock.mockReturnValue({ + getResults: () => Promise.resolve([]), + } as any); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should get", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + + const resultData: XpLeaderboardEntry[] = [ + { + totalXp: 100, + rank: 1, + timeTypedSeconds: 100, + uid: "user1", + name: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + lastActivityTimestamp: 1000, + }, + { + totalXp: 75, + rank: 2, + timeTypedSeconds: 200, + uid: "user2", + name: "user2", + discordId: "discordId2", + discordAvatar: "discordAvatar2", + lastActivityTimestamp: 2000, + }, + ]; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue(resultData); + getXpWeeklyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({}) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: resultData, + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getResultMock).toHaveBeenCalledWith(0, 49, lbConf); + }); + + it("should get for last week", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + weeksBefore: 1, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: [], + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith( + lbConf, + 1721606400000 + ); + }); + + it("should get with skip and limit", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + const limit = 23; + const skip = 42; + + const getResultMock = vi.fn(); + getResultMock.mockResolvedValue([]); + getXpWeeklyLeaderboardMock.mockReturnValue({ + getResults: getResultMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + skip, + limit, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: [], + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getResultMock).toHaveBeenCalledWith( + skip, + skip + limit - 1, + lbConf + ); + }); + + it("fails if daily leaderboards are disabled", async () => { + await weeklyLeaderboardEnabled(false); + + const { body } = await mockApp.get("/leaderboards/xp/weekly").expect(503); + + expect(body.message).toEqual( + "Weekly XP leaderboards are not available at this time." + ); + }); + + it("fails for weeksBefore not one", async () => { + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + weeksBefore: 2, + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ['"weeksBefore" Invalid literal value, expected 1'], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/leaderboards/xp/weekly") + .query({ + extra: "value", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("fails while leaderboard is missing", async () => { + //GIVEN + getXpWeeklyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp.get("/leaderboards/xp/weekly").expect(404); + + expect(body.message).toEqual("XP leaderboard for this week not found."); + }); + }); + + describe("get xp weekly leaderboard rank", () => { + const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + + beforeEach(async () => { + getXpWeeklyLeaderboardMock.mockReset(); + await weeklyLeaderboardEnabled(true); + }); + + it("fails withouth authentication", async () => { + await mockApp.get("/leaderboards/xp/weekly/rank").expect(401); + }); + + it("should get", async () => { + //GIVEN + const lbConf = (await configuration).leaderboards.weeklyXp; + + const resultData: XpLeaderboardRank = { + totalXp: 100, + rank: 1, + count: 100, + timeTypedSeconds: 100, + uid: "user1", + name: "user1", + discordId: "discordId", + discordAvatar: "discordAvatar", + lastActivityTimestamp: 1000, + }; + const getRankMock = vi.fn(); + getRankMock.mockResolvedValue(resultData); + getXpWeeklyLeaderboardMock.mockReturnValue({ + getRank: getRankMock, + } as any); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Weekly xp leaderboard rank retrieved", + data: resultData, + }); + + expect(getXpWeeklyLeaderboardMock).toHaveBeenCalledWith(lbConf, -1); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + }); + it("fails if daily leaderboards are disabled", async () => { + await weeklyLeaderboardEnabled(false); + + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .set("authorization", `Uid ${uid}`) + .expect(503); + + expect(body.message).toEqual( + "Weekly XP leaderboards are not available at this time." + ); + }); + + it("fails while leaderboard is missing", async () => { + //GIVEN + getXpWeeklyLeaderboardMock.mockReturnValue(null); + + //WHEN + const { body } = await mockApp + .get("/leaderboards/xp/weekly/rank") + .set("authorization", `Uid ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + }) + .expect(404); + + expect(body.message).toEqual("XP leaderboard for this week not found."); + }); }); }); + +async function acceptApeKeys(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + apeKeys: { acceptKeys: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +async function dailyLeaderboardEnabled(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + dailyLeaderboards: { enabled: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function weeklyLeaderboardEnabled(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + leaderboards: { weeklyXp: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/api/controllers/public.spec.ts b/backend/__tests__/api/controllers/public.spec.ts index accc90a9a..9d9960e05 100644 --- a/backend/__tests__/api/controllers/public.spec.ts +++ b/backend/__tests__/api/controllers/public.spec.ts @@ -89,9 +89,9 @@ describe("PublicController", () => { expect(body).toEqual({ message: "Invalid query schema", validationErrors: [ - '"language" Invalid', + '"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 either a number, "zen" or "custom."', + '"mode2" Needs to be a number or a number represented as a string e.g. "10".', ], }); }); diff --git a/backend/__tests__/dal/leaderboards.spec.ts b/backend/__tests__/dal/leaderboards.spec.ts index 910c37794..f96822a9b 100644 --- a/backend/__tests__/dal/leaderboards.spec.ts +++ b/backend/__tests__/dal/leaderboards.spec.ts @@ -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, diff --git a/backend/package.json b/backend/package.json index cf0d4edea..e5964f436 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index b5140de6c..38a1e327c 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -71,6 +71,11 @@ export function getOpenApi(): OpenAPIObject { 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.", diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index bed10e239..17aa0a810 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -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 { - 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 +): Promise { + 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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); } diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 659a54180..b4b6d9f29 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -42,7 +42,6 @@ const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { "/users": users, "/results": results, - "/leaderboards": leaderboards, "/quotes": quotes, "/webhooks": webhooks, "/docs": docs, @@ -56,6 +55,7 @@ const router = s.router(contract, { presets, psas, public: publicStats, + leaderboards, }); export function addApiRoutes(app: Application): void { diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 9b2705e3a..08fffee8e 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -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), + }, +}); diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 18a84508a..7868b4031 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -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 => + db.collection( + `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 { +): Promise { //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(`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 { +): Promise { try { - const entry = await db - .collection(`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) { diff --git a/backend/src/documentation/internal-swagger.json b/backend/src/documentation/internal-swagger.json index a999ae0da..7bcd21a78 100644 --- a/backend/src/documentation/internal-swagger.json +++ b/backend/src/documentation/internal-swagger.json @@ -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"], diff --git a/backend/src/documentation/public-swagger.json b/backend/src/documentation/public-swagger.json index f77795b0e..c5a2cdc90 100644 --- a/backend/src/documentation/public-swagger.json +++ b/backend/src/documentation/public-swagger.json @@ -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": { diff --git a/backend/src/jobs/update-leaderboards.ts b/backend/src/jobs/update-leaderboards.ts index 2310bd764..e6e219c07 100644 --- a/backend/src/jobs/update-leaderboards.ts +++ b/backend/src/jobs/update-leaderboards.ts @@ -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 { +async function getTop10( + leaderboardTime: string +): Promise { 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( diff --git a/backend/src/queues/george-queue.ts b/backend/src/queues/george-queue.ts index d1d158310..fb85733b7 100644 --- a/backend/src/queues/george-queue.ts +++ b/backend/src/queues/george-queue.ts @@ -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 { } async announceLeaderboardUpdate( - newRecords: LeaderboardEntry[], + newRecords: Omit[], leaderboardId: string ): Promise { const taskName = "announceLeaderboardUpdate"; @@ -90,7 +89,7 @@ class GeorgeQueue extends MonkeyQueue { async announceDailyLeaderboardTopResults( leaderboardId: string, leaderboardTimestamp: number, - topResults: LbEntryWithRank[] + topResults: LeaderboardEntry[] ): Promise { const taskName = "announceDailyLeaderboardTopResults"; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 8ab2785fc..80ee39782 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -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 { + ): Promise { 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 { + ): Promise { 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" >; diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 3ad1773a0..e013d8d94 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -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, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] ): Promise { const connection = RedisClient.getConnection(); @@ -127,7 +107,7 @@ export class DailyLeaderboard { maxRank: number, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], premiumFeaturesEnabled: boolean - ): Promise { + ): Promise { 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 { + ): Promise { 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, }; } diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 609c791b9..2e31c23f9 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -1,4 +1,3 @@ -import Leaderboards from "./leaderboards"; import Quotes from "./quotes"; import Results from "./results"; import Users from "./users"; @@ -6,7 +5,6 @@ import Configuration from "./configuration"; import Dev from "./dev"; export default { - Leaderboards, Quotes, Results, Users, diff --git a/frontend/src/ts/ape/endpoints/leaderboards.ts b/frontend/src/ts/ape/endpoints/leaderboards.ts deleted file mode 100644 index 14570ff37..000000000 --- a/frontend/src/ts/ape/endpoints/leaderboards.ts +++ /dev/null @@ -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 { - 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 { - 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 }); - } -} diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 46ddac7b3..4244f6aa4 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -17,7 +17,6 @@ const Ape = { users: new endpoints.Users(httpClient), results: new endpoints.Results(httpClient), quotes: new endpoints.Quotes(httpClient), - leaderboards: new endpoints.Leaderboards(httpClient), configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), }; diff --git a/frontend/src/ts/ape/types/leaderboards.d.ts b/frontend/src/ts/ape/types/leaderboards.d.ts deleted file mode 100644 index 02f95db35..000000000 --- a/frontend/src/ts/ape/types/leaderboards.d.ts +++ /dev/null @@ -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; - }; -} diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index 7f92b705e..6e6d008cc 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -21,6 +21,7 @@ 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"; export let localStorageConfig: Config; @@ -1565,12 +1566,8 @@ export function setCustomThemeColors( return true; } -export function setLanguage( - language: ConfigSchemas.Language, - nosave?: boolean -): boolean { - if (!isConfigValueValid("language", language, ConfigSchemas.LanguageSchema)) - return false; +export function setLanguage(language: Language, nosave?: boolean): boolean { + if (!isConfigValueValid("language", language, LanguageSchema)) return false; config.language = language; void AnalyticsController.log("changedLanguage", { language }); diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts index 20c91578f..02c0810cf 100644 --- a/frontend/src/ts/elements/leaderboards.ts +++ b/frontend/src/ts/elements/leaderboards.ts @@ -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; + [_key in LbKey]: + | (LeaderboardRank & { minWpm?: number }) //Daily LB rank has minWpm + | Record; } = { "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 { 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 - >[] = []; - 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 { 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 { async function requestNew(lb: LbKey, skip: number): Promise { 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 { 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 { } async function getAvatarUrls( - data: Ape.Leaderboards.GetLeaderboard + data: LeaderboardEntry[] ): Promise<(string | null)[]> { return Promise.allSettled( data.map(async (entry) => diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index a44320cbf..f2fbebe86 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -202,12 +202,6 @@ declare namespace MonkeyTypes { }; }; - type Leaderboards = { - time: { - [_key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[]; - }; - }; - type QuoteRatings = Record>; type UserTag = import("@monkeytype/shared-types").UserTag & { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1224a0e19..0f7469639 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ import { configsContract } from "./configs"; import { presetsContract } from "./presets"; import { psasContract } from "./psas"; import { publicContract } from "./public"; +import { leaderboardsContract } from "./leaderboards"; const c = initContract(); @@ -15,4 +16,5 @@ export const contract = c.router({ presets: presetsContract, psas: psasContract, public: publicContract, + leaderboards: leaderboardsContract, }); diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts new file mode 100644 index 000000000..d5c0aba0a --- /dev/null +++ b/packages/contracts/src/leaderboards.ts @@ -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; +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; +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, + } +); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index 305d6100a..4795f6828 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -6,7 +6,8 @@ export type OpenApiTag = | "ape-keys" | "admin" | "psas" - | "public"; + | "public" + | "leaderboards"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ diff --git a/packages/contracts/src/schemas/configs.ts b/packages/contracts/src/schemas/configs.ts index 8579a1492..0465b57f3 100644 --- a/packages/contracts/src/schemas/configs.ts +++ b/packages/contracts/src/schemas/configs.ts @@ -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; export const ThemeNameSchema = token().max(50); export type ThemeName = z.infer; -export const LanguageSchema = z - .string() - .max(50) - .regex(/^[a-zA-Z0-9_+]+$/); -export type Language = z.infer; - export const KeymapLayoutSchema = z .string() .max(50) diff --git a/packages/contracts/src/schemas/leaderboards.ts b/packages/contracts/src/schemas/leaderboards.ts new file mode 100644 index 000000000..305b5b7b3 --- /dev/null +++ b/packages/contracts/src/schemas/leaderboards.ts @@ -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; + +export const LeaderboardRankSchema = z.object({ + count: z.number().int().nonnegative(), + rank: z.number().int().nonnegative().optional(), + entry: LeaderboardEntrySchema.optional(), +}); +export type LeaderboardRank = z.infer; + +export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({ + minWpm: z.number().nonnegative(), +}); +export type DailyLeaderboardRank = z.infer; + +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; + +export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({ + count: z.number().int().nonnegative(), +}); +export type XpLeaderboardRank = z.infer; diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts index 25d9a4b77..be640d318 100644 --- a/packages/contracts/src/schemas/util.ts +++ b/packages/contracts/src/schemas/util.ts @@ -1,13 +1,12 @@ import { z, ZodString } from "zod"; export const StringNumberSchema = z - - .custom<`${number}`>((val) => { - if (typeof val === "number") val = val.toString(); - return typeof val === "string" ? /^\d+$/.test(val) : false; - }, 'Needs to be a number or a number represented as a string e.g. "10".') - .transform(String); - + .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; export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/); @@ -21,5 +20,5 @@ export type Tag = z.infer; export const LanguageSchema = z .string() .max(50) - .regex(/^[a-zA-Z0-9_+]+$/); + .regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]"); export type Language = z.infer; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d069ecd3c..297f17374 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -301,22 +301,6 @@ export type ResultFilters = { } & Record; }; -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[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d9ccbb0e..adad631fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: workspace:* version: link:../packages/typescript-config '@redocly/cli': - specifier: 1.18.1 - version: 1.18.1(encoding@0.1.13)(enzyme@3.11.0) + specifier: 1.19.0 + version: 1.19.0(encoding@0.1.13)(enzyme@3.11.0) '@types/bcrypt': specifier: 5.0.2 version: 5.0.2 @@ -2327,16 +2327,16 @@ packages: '@redocly/ajv@8.11.0': resolution: {integrity: sha512-9GWx27t7xWhDIR02PA18nzBdLcKQRgc46xNQvjFkrYk4UOmvKhJ/dawwiX0cCOeetN5LcaaiqQbVOWYK62SGHw==} - '@redocly/cli@1.18.1': - resolution: {integrity: sha512-+bRKj46R9wvTzMdnoYfMueJ9/ek0NprEsQNowV7XcHgOXifeFFikRtBFcpkwqCNxaQ/nWAJn4LHZaFcssbcHow==} + '@redocly/cli@1.19.0': + resolution: {integrity: sha512-ev6J0eD+quprvW9PVCl9JmRFZbj6cuK+mnYPAjcrPvesy2RF752fflcpgQjGnyFaGb1Cj+DiwDi3dYr3EAp04A==} engines: {node: '>=14.19.0', npm: '>=7.0.0'} hasBin: true '@redocly/config@0.7.0': resolution: {integrity: sha512-6GKxTo/9df0654Mtivvr4lQnMOp+pRj9neVywmI5+BwfZLTtkJnj2qB3D6d8FHTr4apsNOf6zTa5FojX0Evh4g==} - '@redocly/openapi-core@1.18.1': - resolution: {integrity: sha512-y2ZR3aaVF80XRVoFP0Dp2z5DeCOilPTuS7V4HnHIYZdBTfsqzjkO169h5JqAaifnaLsLBhe3YArdgLb7W7wW6Q==} + '@redocly/openapi-core@1.19.0': + resolution: {integrity: sha512-ezK6qr80sXvjDgHNrk/zmRs9vwpIAeHa0T/qmo96S+ib4ThQ5a8f3qjwEqxMeVxkxCTbkaY9sYSJKOxv4ejg5w==} engines: {node: '>=14.19.0', npm: '>=7.0.0'} '@rollup/plugin-babel@5.3.1': @@ -11653,9 +11653,9 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 - '@redocly/cli@1.18.1(encoding@0.1.13)(enzyme@3.11.0)': + '@redocly/cli@1.19.0(encoding@0.1.13)(enzyme@3.11.0)': dependencies: - '@redocly/openapi-core': 1.18.1(encoding@0.1.13) + '@redocly/openapi-core': 1.19.0(encoding@0.1.13) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 @@ -11684,7 +11684,7 @@ snapshots: '@redocly/config@0.7.0': {} - '@redocly/openapi-core@1.18.1(encoding@0.1.13)': + '@redocly/openapi-core@1.19.0(encoding@0.1.13)': dependencies: '@redocly/ajv': 8.11.0 '@redocly/config': 0.7.0 @@ -18231,7 +18231,7 @@ snapshots: redoc@2.1.5(core-js@3.37.1)(encoding@0.1.13)(enzyme@3.11.0)(mobx@6.13.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@cfaester/enzyme-adapter-react-18': 0.8.0(enzyme@3.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@redocly/openapi-core': 1.18.1(encoding@0.1.13) + '@redocly/openapi-core': 1.19.0(encoding@0.1.13) classnames: 2.5.1 core-js: 3.37.1 decko: 1.2.0 From ed47fb73a87dcd33a3db0f0ee2f623676ebd53ba Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 12 Aug 2024 14:31:54 +0200 Subject: [PATCH 26/32] chore: allow expressions in explicit-function-return-type rule --- packages/eslint-config/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index 37ffb2691..d2f59e23b 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -91,7 +91,12 @@ 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": "error", "@typescript-eslint/no-unused-vars": [ From 692d3f5920c531ee476705247c0c9c930e49e733 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 12 Aug 2024 15:50:34 +0200 Subject: [PATCH 27/32] impr: deep copy constant objects when retrieving them --- frontend/src/ts/constants/default-config.ts | 4 +- .../ts/constants/default-result-filters.ts | 66 +++++++++++++++++++ .../src/ts/controllers/account-controller.ts | 5 +- .../src/ts/elements/account/result-filters.ts | 64 +----------------- 4 files changed, 73 insertions(+), 66 deletions(-) create mode 100644 frontend/src/ts/constants/default-result-filters.ts diff --git a/frontend/src/ts/constants/default-config.ts b/frontend/src/ts/constants/default-config.ts index dccf78541..a43c0577a 100644 --- a/frontend/src/ts/constants/default-config.ts +++ b/frontend/src/ts/constants/default-config.ts @@ -3,7 +3,7 @@ import { CustomThemeColors, } from "@monkeytype/contracts/schemas/configs"; -export default { +const obj = { theme: "serika_dark", themeLight: "serika", themeDark: "serika_dark", @@ -101,3 +101,5 @@ export default { tapeMode: "off", maxLineWidth: 0, } as Config; + +export default JSON.parse(JSON.stringify(obj)) as Config; diff --git a/frontend/src/ts/constants/default-result-filters.ts b/frontend/src/ts/constants/default-result-filters.ts new file mode 100644 index 000000000..b831def9a --- /dev/null +++ b/frontend/src/ts/constants/default-result-filters.ts @@ -0,0 +1,66 @@ +import { ResultFilters } from "@monkeytype/shared-types"; + +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; diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index 555b99bcd..24625190e 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -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 { .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(); diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 0f89864fc..9c8029b0b 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -10,6 +10,7 @@ import * as Loader from "../loader"; import SlimSelect from "slim-select"; import { ResultFilters } from "@monkeytype/shared-types"; import { QuoteLength } from "@monkeytype/contracts/schemas/configs"; +import defaultResultFilters from "../../constants/default-result-filters"; type Option = { id: string; @@ -31,69 +32,6 @@ type Option = { const groupsUsingSelect = ["language", "funbox", "tags"]; const groupSelects: Partial> = {}; -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; From 2ae398cdaf8c9d7928dee336b503aa4ba66d31f6 Mon Sep 17 00:00:00 2001 From: Jave Bantilan <69343139+Killer8Hyper@users.noreply.github.com> Date: Mon, 12 Aug 2024 22:42:58 +0800 Subject: [PATCH 28/32] impr: update filipino word list (@Killer8Hyper) (#5768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced less commonly appearing or less frequently typed word: bayan → pasok (entry • entrance • admission • school day • work day • going to work) My bad, I really missed this one out. It was on my list/notes, but I completely forgot about it. It's been a year or two since I started planning to update some words in the list. So, yeah, this will be my final update on the most common 200 words in Filipino—unless someone requests an update. But I believe that's unlikely, as I've done my best to provide a useful list for everyone. --- frontend/static/languages/filipino.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/static/languages/filipino.json b/frontend/static/languages/filipino.json index 931e5a5b7..798f21306 100644 --- a/frontend/static/languages/filipino.json +++ b/frontend/static/languages/filipino.json @@ -107,7 +107,7 @@ "panahon", "ayaw", "buong", - "bayan", + "pasok", "ulit", "tungkol", "tama", From 38a852980897bf3133e4068086e7b15a8693ca7a Mon Sep 17 00:00:00 2001 From: Walid Mosbahi <111127040+Bretis2019@users.noreply.github.com> Date: Mon, 12 Aug 2024 15:51:16 +0100 Subject: [PATCH 29/32] impr(commandline): preview error sound on hover (@Bretis2019) (#5769) * fix: play sound on error on hover in settings pop up menu * name --------- Co-authored-by: Miodec --- .../src/ts/commandline/lists/sound-on-error.ts | 12 ++++++++++++ frontend/src/ts/controllers/sound-controller.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/frontend/src/ts/commandline/lists/sound-on-error.ts b/frontend/src/ts/commandline/lists/sound-on-error.ts index 4022e6fe5..c04f017fb 100644 --- a/frontend/src/ts/commandline/lists/sound-on-error.ts +++ b/frontend/src/ts/commandline/lists/sound-on-error.ts @@ -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(); diff --git a/frontend/src/ts/controllers/sound-controller.ts b/frontend/src/ts/controllers/sound-controller.ts index 04a08777f..8ac913893 100644 --- a/frontend/src/ts/controllers/sound-controller.ts +++ b/frontend/src/ts/controllers/sound-controller.ts @@ -404,6 +404,20 @@ export async function previewClick(val: string): Promise { safeClickSounds[val][0].sounds[0].play(); } +export async function previewError(val: string): Promise { + 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) => { From 55e183e7bb963b5c7ad3f2892d44bc8397375901 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 12 Aug 2024 17:04:01 +0200 Subject: [PATCH 30/32] impr: add local storage with schema class to improve type safety (@miodec) (#5763) !nuf --- backend/src/dal/user.ts | 2 +- .../elements/account/result-filters.spec.ts | 73 +++++++++ frontend/__tests__/test/misc.spec.ts | 46 ++++++ .../utils/local-storage-with-schema.spec.ts | 126 +++++++++++++++ frontend/src/ts/ape/endpoints/users.ts | 2 +- frontend/src/ts/config.ts | 77 +++++----- .../ts/constants/default-result-filters.ts | 2 +- .../src/ts/controllers/account-controller.ts | 2 +- frontend/src/ts/controllers/tag-controller.ts | 41 +++-- .../src/ts/elements/account/result-filters.ts | 145 ++++++++++-------- frontend/src/ts/elements/merch-banner.ts | 24 +++ frontend/src/ts/elements/psa.ts | 20 ++- frontend/src/ts/modals/cookies.ts | 33 ++-- frontend/src/ts/pages/account.ts | 5 +- frontend/src/ts/ready.ts | 18 +-- frontend/src/ts/states/arabic-lazy-mode.ts | 13 +- frontend/src/ts/states/version.ts | 12 +- frontend/src/ts/test/custom-text.ts | 46 ++++-- frontend/src/ts/types/types.d.ts | 11 +- .../src/ts/utils/local-storage-with-schema.ts | 66 ++++++++ frontend/src/ts/utils/logger.ts | 12 +- frontend/src/ts/utils/misc.ts | 4 + packages/contracts/src/schemas/users.ts | 63 +++++++- packages/shared-types/src/index.ts | 63 +------- 24 files changed, 642 insertions(+), 264 deletions(-) create mode 100644 frontend/__tests__/elements/account/result-filters.spec.ts create mode 100644 frontend/__tests__/utils/local-storage-with-schema.spec.ts create mode 100644 frontend/src/ts/elements/merch-banner.ts create mode 100644 frontend/src/ts/utils/local-storage-with-schema.ts diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 893de43c7..a294417f6 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -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; diff --git a/frontend/__tests__/elements/account/result-filters.spec.ts b/frontend/__tests__/elements/account/result-filters.spec.ts new file mode 100644 index 000000000..f5d1bd15d --- /dev/null +++ b/frontend/__tests__/elements/account/result-filters.spec.ts @@ -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()); + }); + }); + }); +}); diff --git a/frontend/__tests__/test/misc.spec.ts b/frontend/__tests__/test/misc.spec.ts index 3f86c02a0..9239d3f35 100644 --- a/frontend/__tests__/test/misc.spec.ts +++ b/frontend/__tests__/test/misc.spec.ts @@ -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); + }); + }); + }); }); diff --git a/frontend/__tests__/utils/local-storage-with-schema.spec.ts b/frontend/__tests__/utils/local-storage-with-schema.spec.ts new file mode 100644 index 000000000..75db29071 --- /dev/null +++ b/frontend/__tests__/utils/local-storage-with-schema.spec.ts @@ -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 = { + 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); + }); + }); +}); diff --git a/frontend/src/ts/ape/endpoints/users.ts b/frontend/src/ts/ape/endpoints/users.ts index fab02dfd5..fda651de7 100644 --- a/frontend/src/ts/ape/endpoints/users.ts +++ b/frontend/src/ts/ape/endpoints/users.ts @@ -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"; diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index 6e6d008cc..553d8c014 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -16,14 +16,35 @@ import { canSetConfigWithCurrentFunboxes, canSetFunboxWithConfig, } from "./test/funbox/funbox-validation"; -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; @@ -48,29 +69,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); } @@ -1977,8 +1994,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) { @@ -2095,33 +2110,19 @@ export async function reset(): Promise { export async function loadFromLocalStorage(): Promise { 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"; @@ -2159,7 +2160,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; } @@ -2168,7 +2169,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; } @@ -2177,7 +2178,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; } diff --git a/frontend/src/ts/constants/default-result-filters.ts b/frontend/src/ts/constants/default-result-filters.ts index b831def9a..71ff08662 100644 --- a/frontend/src/ts/constants/default-result-filters.ts +++ b/frontend/src/ts/constants/default-result-filters.ts @@ -1,4 +1,4 @@ -import { ResultFilters } from "@monkeytype/shared-types"; +import { ResultFilters } from "@monkeytype/contracts/schemas/users"; const object: ResultFilters = { _id: "default-result-filters-id", diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index 24625190e..ca2e85d53 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -167,7 +167,7 @@ async function getDataAndInit(): Promise { 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" ); diff --git a/frontend/src/ts/controllers/tag-controller.ts b/frontend/src/ts/controllers/tag-controller.ts index e934095d5..a6d3fbbbf 100644 --- a/frontend/src/ts/controllers/tag-controller.ts +++ b/frontend/src/ts/controllers/tag-controller.ts @@ -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(); } diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 9c8029b0b..4a51bdd36 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -8,10 +8,50 @@ 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 { + 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; value: string; @@ -36,51 +76,14 @@ const groupSelects: Partial> = {}; let filters = defaultResultFilters; function save(): void { - window.localStorage.setItem("resultFilters", JSON.stringify(filters)); + resultFiltersLS.set(filters); } export async function load(): Promise { 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 = { none: false }; - Object.keys(defaultResultFilters.tags).forEach((tag) => { if (filters.tags[tag] !== undefined) { newTags[tag] = filters.tags[tag]; @@ -90,7 +93,6 @@ export async function load(): Promise { }); filters.tags = newTags; - // await updateFilterPresets(); save(); } catch { console.log("error in loading result filters"); @@ -226,7 +228,7 @@ function getFilters(): ResultFilters { return filters; } -function getGroup(group: G): ResultFilters[G] { +function getGroup(group: G): ResultFilters[G] { return filters[group]; } @@ -234,22 +236,22 @@ function getGroup(group: G): ResultFilters[G] { // filters[group][filter] = value; // } -export function getFilter( +export function getFilter( group: G, - filter: MonkeyTypes.Filter -): ResultFilters[G][MonkeyTypes.Filter] { + filter: ResultFiltersGroupItem +): ResultFilters[G][ResultFiltersGroupItem] { return filters[group][filter]; } -function setFilter( - group: keyof ResultFilters, - filter: MonkeyTypes.Filter, +function setFilter( + group: G, + filter: ResultFiltersGroupItem, 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; @@ -268,7 +270,7 @@ export function reset(): void { } type AboveChartDisplay = Partial< - Record + Record >; export function updateActive(): void { @@ -290,7 +292,10 @@ export function updateActive(): void { if (groupAboveChartDisplay === undefined) continue; - const filterValue = getFilter(group, filter); + const filterValue = getFilter( + group, + filter as ResultFiltersGroupItem + ); if (filterValue === true) { groupAboveChartDisplay.array?.push(filter); } else { @@ -330,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(); @@ -374,7 +379,7 @@ export function updateActive(): void { }, 0); } - function addText(group: keyof ResultFilters): string { + function addText(group: ResultFiltersGroup): string { let ret = ""; ret += "
"; if (group === "difficulty") { @@ -472,9 +477,9 @@ export function updateActive(): void { }, 0); } -function toggle( +function toggle( group: G, - filter: MonkeyTypes.Filter + filter: ResultFiltersGroupItem ): void { // user is changing the filters -> current filter is no longer a filter preset deSelectFilterPreset(); @@ -486,7 +491,7 @@ function toggle( const currentValue = filters[group][filter] as unknown as boolean; const newValue = !currentValue; filters[group][filter] = - newValue as unknown as ResultFilters[G][MonkeyTypes.Filter]; + newValue as ResultFilters[G][ResultFiltersGroupItem]; save(); } catch (e) { Notifications.add( @@ -505,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; + .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 @@ -532,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]; @@ -596,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", @@ -627,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; @@ -656,7 +663,7 @@ $(".pageAccount .topFilters button.toggleAdvancedFilters").on("click", () => { }); function adjustScrollposition( - group: keyof ResultFilters, + group: ResultFiltersGroup, topItem: number = 0 ): void { const slimSelect = groupSelects[group]; @@ -668,7 +675,7 @@ function adjustScrollposition( } function selectBeforeChangeFn( - group: keyof ResultFilters, + group: ResultFiltersGroup, selectedOptions: Option[], oldSelectedOptions: Option[] ): void | boolean { @@ -705,7 +712,11 @@ function selectBeforeChangeFn( break; } - setFilter(group, selectedOption.value, true); + setFilter( + group, + selectedOption.value as ResultFiltersGroupItem, + true + ); } updateActive(); @@ -925,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 diff --git a/frontend/src/ts/elements/merch-banner.ts b/frontend/src/ts/elements/merch-banner.ts new file mode 100644 index 000000000..bcb10b7ae --- /dev/null +++ b/frontend/src/ts/elements/merch-banner.ts @@ -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 monkeytype.store`, + 1, + "./images/merch2.png", + false, + () => { + closed.set(true); + }, + true + ); + } +} diff --git a/frontend/src/ts/elements/psa.ts b/frontend/src/ts/elements/psa.ts index 324e2a863..3c185ec36 100644 --- a/frontend/src/ts/elements/psa.ts +++ b/frontend/src/ts/elements/psa.ts @@ -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 { diff --git a/frontend/src/ts/modals/cookies.ts b/frontend/src/ts/modals/cookies.ts index 868c2a148..2f0c7fcd3 100644 --- a/frontend/src/ts/modals/cookies.ts +++ b/frontend/src/ts/modals/cookies.ts @@ -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; -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(); } } diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index d0181e08f..e692f5b93 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -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 { 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 { return; } - let numfilter: MonkeyTypes.Filter<"numbers"> = "off"; + let numfilter: ResultFiltersGroupItem<"numbers"> = "off"; if (result.numbers) { numfilter = "on"; } diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts index 8af4be4e0..bfd5f583f 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/ready.ts @@ -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 monkeytype.store`, - 1, - "./images/merch2.png", - false, - () => { - window.localStorage.setItem("merchbannerclosed", "true"); - }, - true - ); - } - + MerchBanner.showIfNotClosedBefore(); setTimeout(() => { FunboxList.get(Config.funbox).forEach((it) => it.functions?.applyGlobalCSS?.() diff --git a/frontend/src/ts/states/arabic-lazy-mode.ts b/frontend/src/ts/states/arabic-lazy-mode.ts index 304389978..590fd50e5 100644 --- a/frontend/src/ts/states/arabic-lazy-mode.ts +++ b/frontend/src/ts/states/arabic-lazy-mode.ts @@ -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); } diff --git a/frontend/src/ts/states/version.ts b/frontend/src/ts/states/version.ts index 76aedc4a1..a0406b307 100644 --- a/frontend/src/ts/states/version.ts +++ b/frontend/src/ts/states/version.ts @@ -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 { diff --git a/frontend/src/ts/test/custom-text.ts b/frontend/src/ts/test/custom-text.ts index 4a60f2106..3b428070b 100644 --- a/frontend/src/ts/test/custom-text.ts +++ b/frontend/src/ts/test/custom-text.ts @@ -4,6 +4,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; + +const CustomTextLongObjectSchema = z.record( + z.string(), + z.object({ text: z.string(), progress: z.number() }) +); +type CustomTextLongObject = z.infer; + +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", @@ -79,10 +109,6 @@ export function getData(): CustomTextData { }; } -type CustomTextObject = Record; - -type CustomTextLongObject = Record; - export function getCustomText(name: string, long = false): string[] { if (long) { const customTextLong = getLocalStorageLong(); @@ -169,23 +195,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[] { diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index f2fbebe86..de4bfa6fd 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -230,7 +230,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; @@ -244,15 +244,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 keyof import("@monkeytype/shared-types").ResultFilters - ? keyof import("@monkeytype/shared-types").ResultFilters[G] - : never; - type TimerStats = { dateNow: number; now: number; diff --git a/frontend/src/ts/utils/local-storage-with-schema.ts b/frontend/src/ts/utils/local-storage-with-schema.ts new file mode 100644 index 000000000..f196a2030 --- /dev/null +++ b/frontend/src/ts/utils/local-storage-with-schema.ts @@ -0,0 +1,66 @@ +import { ZodIssue } from "zod"; + +export class LocalStorageWithSchema { + private key: string; + private schema: Zod.Schema; + private fallback: T; + private migrate?: (value: unknown, zodIssues: ZodIssue[]) => T; + + constructor(options: { + key: string; + schema: Zod.Schema; + 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; + } + } +} diff --git a/frontend/src/ts/utils/logger.ts b/frontend/src/ts/utils/logger.ts index 448583deb..71783f245 100644 --- a/frontend/src/ts/utils/logger.ts +++ b/frontend/src/ts/utils/logger.ts @@ -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 { diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 2e2939b4e..f27a6605e 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -676,4 +676,8 @@ export function updateTitle(title?: string): void { } } +export function isObject(obj: unknown): obj is Record { + return typeof obj === "object" && !Array.isArray(obj) && obj !== null; +} + // DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES diff --git a/packages/contracts/src/schemas/users.ts b/packages/contracts/src/schemas/users.ts index bf1bfabc2..43d058321 100644 --- a/packages/contracts/src/schemas/users.ts +++ b/packages/contracts/src/schemas/users.ts @@ -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; + +export type ResultFiltersGroup = keyof ResultFilters; + +export type ResultFiltersGroupItem = + keyof ResultFilters[T]; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 297f17374..d10093d20 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -240,67 +240,6 @@ export type CustomTextDataWithTextLen = Omit & { 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; - language: Record; - funbox: { - none?: boolean; - } & Record; -}; - export type PostResultResponse = { isPb: boolean; tagPbs: string[]; @@ -392,7 +331,7 @@ export type User = { verified?: boolean; needsToChangeName?: boolean; quoteMod?: boolean | string; - resultFilterPresets?: ResultFilters[]; + resultFilterPresets?: import("@monkeytype/contracts/schemas/users").ResultFilters[]; testActivity?: TestActivity; }; From 1c9dbccfa305fd317ebae86f1c1da5f31a0f84ee Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 12 Aug 2024 20:18:22 +0200 Subject: [PATCH 31/32] chore: update vitest to 2.0.5 (@fehmer) (#5770) --- backend/package.json | 4 +- backend/vitest.config.js | 1 - frontend/package.json | 4 +- package.json | 4 +- pnpm-lock.yaml | 367 ++++++++++++++------------------------- 5 files changed, 140 insertions(+), 240 deletions(-) diff --git a/backend/package.json b/backend/package.json index e5964f436..1e65bd150 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/vitest.config.js b/backend/vitest.config.js index aacbb1a01..641f8f5a9 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -6,7 +6,6 @@ export default defineConfig({ environment: "node", globalSetup: "__tests__/global-setup.ts", setupFiles: ["__tests__/setup-tests.ts"], - pool: "forks", coverage: { include: ["**/*.ts"], diff --git a/frontend/package.json b/frontend/package.json index 6805987e2..ec993b2e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,7 @@ "@types/object-hash": "2.2.1", "@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", @@ -66,7 +66,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", diff --git a/package.json b/package.json index 90caf0206..ec2f41dce 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "devDependencies": { "@commitlint/cli": "17.7.1", "@commitlint/config-conventional": "17.7.0", - "@vitest/coverage-v8": "1.6.0", + "@vitest/coverage-v8": "2.0.5", "@monkeytype/release": "workspace:*", "conventional-changelog": "4.0.0", "husky": "8.0.1", @@ -67,7 +67,7 @@ "only-allow": "1.2.1", "prettier": "2.5.1", "turbo": "2.0.9", - "vitest": "1.6.0" + "vitest": "2.0.5" }, "lint-staged": { "*.{json,scss,css,html}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adad631fe..0a47701b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: workspace:* version: link:packages/release '@vitest/coverage-v8': - specifier: 1.6.0 - version: 1.6.0(vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) + specifier: 2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) conventional-changelog: specifier: 4.0.0 version: 4.0.0 @@ -42,8 +42,8 @@ importers: specifier: 2.0.9 version: 2.0.9 vitest: - specifier: 1.6.0 - version: 1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + specifier: 2.0.5 + version: 2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) backend: dependencies: @@ -232,8 +232,8 @@ importers: specifier: 10.0.0 version: 10.0.0 '@vitest/coverage-v8': - specifier: 1.6.0 - version: 1.6.0(vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) + specifier: 2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) concurrently: specifier: 8.2.2 version: 8.2.2 @@ -262,8 +262,8 @@ importers: specifier: 5.5.4 version: 5.5.4 vitest: - specifier: 1.6.0 - version: 1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + specifier: 2.0.5 + version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) vitest-mongodb: specifier: 1.0.0 version: 1.0.0 @@ -389,8 +389,8 @@ importers: specifier: 2.1.0 version: 2.1.0 '@vitest/coverage-v8': - specifier: 1.6.0 - version: 1.6.0(vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) + specifier: 2.0.5 + version: 2.0.5(vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3)) ajv: specifier: 8.12.0 version: 8.12.0 @@ -461,8 +461,8 @@ importers: specifier: 0.20.0 version: 0.20.0(vite@5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3))(workbox-build@7.1.1)(workbox-window@7.1.0) vitest: - specifier: 1.6.0 - version: 1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + specifier: 2.0.5 + version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) packages/contracts: dependencies: @@ -2107,10 +2107,6 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -2477,9 +2473,6 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@snyk/github-codeowners@1.1.0': resolution: {integrity: sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==} engines: {node: '>=8.10'} @@ -2803,25 +2796,28 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@vitest/coverage-v8@1.6.0': - resolution: {integrity: sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==} + '@vitest/coverage-v8@2.0.5': + resolution: {integrity: sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==} peerDependencies: - vitest: 1.6.0 + vitest: 2.0.5 - '@vitest/expect@1.6.0': - resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} + '@vitest/expect@2.0.5': + resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} - '@vitest/runner@1.6.0': - resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} + '@vitest/pretty-format@2.0.5': + resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} - '@vitest/snapshot@1.6.0': - resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} + '@vitest/runner@2.0.5': + resolution: {integrity: sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==} - '@vitest/spy@1.6.0': - resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} + '@vitest/snapshot@2.0.5': + resolution: {integrity: sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==} - '@vitest/utils@1.6.0': - resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} + '@vitest/spy@2.0.5': + resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + + '@vitest/utils@2.0.5': + resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} @@ -2935,10 +2931,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -3094,8 +3086,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} assign-symbols@1.0.0: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} @@ -3405,9 +3398,9 @@ packages: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} hasBin: true - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} + chai@5.1.1: + resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} + engines: {node: '>=12'} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -3444,8 +3437,9 @@ packages: chartjs-plugin-trendline@1.0.2: resolution: {integrity: sha512-1yaWvaW3WvaikITgrc6JEyvWZWDN9Opjz65fCkgQr/dRdKuXcYzOMl45jylPiJyC9dWWL6HCYiL2HuwItjI8RQ==} - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -3682,9 +3676,6 @@ packages: engines: {node: ^14.13.0 || >=16.0.0} hasBin: true - confbox@0.1.7: - resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} - config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -3976,8 +3967,8 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} deep-equal-in-any-order@2.0.6: @@ -4166,10 +4157,6 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -5877,9 +5864,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.0: - resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} - js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -6097,10 +6081,6 @@ packages: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} - local-pkg@0.5.0: - resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} - engines: {node: '>=14'} - locate-path@2.0.0: resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} engines: {node: '>=4'} @@ -6235,8 +6215,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.1: + resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} lower-case@1.1.4: resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} @@ -6613,9 +6593,6 @@ packages: engines: {node: '>=10'} hasBin: true - mlly@1.7.1: - resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} - mobx-react-lite@4.0.7: resolution: {integrity: sha512-RjwdseshK9Mg8On5tyJZHtGD+J78ZnCnRaxeQDSiciKVQDUbfZcXhmld0VMxAwvcTnPEHZySGGewm467Fcpreg==} peerDependencies: @@ -7114,10 +7091,6 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - p-locate@2.0.0: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} engines: {node: '>=4'} @@ -7301,8 +7274,9 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -7389,9 +7363,6 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - pkg-types@1.1.3: - resolution: {integrity: sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==} - plugin-error@2.0.1: resolution: {integrity: sha512-zMakqvIDyY40xHOvzXka0kUvf40nYIuwRE8dWhti2WtjQZ31xAgBZBhxsK7vK3QbRXS1Xms/LO7B5cuAsfB2Gg==} engines: {node: '>=10.13.0'} @@ -7484,10 +7455,6 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} @@ -8464,9 +8431,6 @@ packages: resolution: {integrity: sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==} engines: {node: '>=14.16'} - strip-literal@2.1.0: - resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} - strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} @@ -8589,9 +8553,9 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} text-decoder@1.1.1: resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==} @@ -8645,12 +8609,16 @@ packages: tinybench@2.8.0: resolution: {integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==} - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + tinypool@1.0.0: + resolution: {integrity: sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} - tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + tinyspy@3.0.0: + resolution: {integrity: sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==} engines: {node: '>=14.0.0'} tmp@0.0.33: @@ -8836,10 +8804,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -8911,9 +8875,6 @@ packages: ua-parser-js@0.7.33: resolution: {integrity: sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==} - ufo@1.5.4: - resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} - uglify-js@3.19.1: resolution: {integrity: sha512-y/2wiW+ceTYR2TSSptAhfnEtpLaQ4Ups5zrjB2d3kuVxHj16j/QJwPl5PvuGy9uARb39J0+iKxcRPvtpsx4A4A==} engines: {node: '>=0.8.0'} @@ -9135,8 +9096,8 @@ packages: resolution: {integrity: sha512-JdUu5viGyw7K1HMstqaAN7y1rnNz93srGeF7FJgFCzM7NL1nH/QlpywDA296qv/KjPPPsq60mOJhtXddikVKSA==} hasBin: true - vite-node@1.6.0: - resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} + vite-node@2.0.5: + resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -9230,15 +9191,15 @@ packages: vitest-mongodb@1.0.0: resolution: {integrity: sha512-IG39uQ4JpJf62rx9H0FUYwluXVQI5/Am6yrD9dE92SwJnFsJwpN4AynZkBbDuedvqFzG2GWK6mzzwU3vq28N0w==} - vitest@1.6.0: - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + vitest@2.0.5: + resolution: {integrity: sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 + '@vitest/browser': 2.0.5 + '@vitest/ui': 2.0.5 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -9570,10 +9531,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-queue@1.1.1: - resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} - engines: {node: '>=12.20'} - zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -11420,10 +11377,6 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -11806,8 +11759,6 @@ snapshots: '@sideway/pinpoint@2.0.0': {} - '@sinclair/typebox@0.27.8': {} - '@snyk/github-codeowners@1.1.0': dependencies: commander: 4.1.1 @@ -12172,7 +12123,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': + '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -12183,15 +12134,14 @@ snapshots: istanbul-reports: 3.1.7 magic-string: 0.30.11 magicast: 0.3.4 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 - test-exclude: 6.0.0 - vitest: 1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': + '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -12202,42 +12152,45 @@ snapshots: istanbul-reports: 3.1.7 magic-string: 0.30.11 magicast: 0.3.4 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 - test-exclude: 6.0.0 - vitest: 1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - supports-color - '@vitest/expect@1.6.0': + '@vitest/expect@2.0.5': dependencies: - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - chai: 4.5.0 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 + tinyrainbow: 1.2.0 - '@vitest/runner@1.6.0': + '@vitest/pretty-format@2.0.5': dependencies: - '@vitest/utils': 1.6.0 - p-limit: 5.0.0 + tinyrainbow: 1.2.0 + + '@vitest/runner@2.0.5': + dependencies: + '@vitest/utils': 2.0.5 pathe: 1.1.2 - '@vitest/snapshot@1.6.0': + '@vitest/snapshot@2.0.5': dependencies: + '@vitest/pretty-format': 2.0.5 magic-string: 0.30.11 pathe: 1.1.2 - pretty-format: 29.7.0 - '@vitest/spy@1.6.0': + '@vitest/spy@2.0.5': dependencies: - tinyspy: 2.2.1 + tinyspy: 3.0.0 - '@vitest/utils@1.6.0': + '@vitest/utils@2.0.5': dependencies: - diff-sequences: 29.6.3 + '@vitest/pretty-format': 2.0.5 estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 + loupe: 3.1.1 + tinyrainbow: 1.2.0 JSONStream@1.3.5: dependencies: @@ -12342,8 +12295,6 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.1: {} ansi-wrap@0.1.0: {} @@ -12513,7 +12464,7 @@ snapshots: asap@2.0.6: {} - assertion-error@1.1.0: {} + assertion-error@2.0.1: {} assign-symbols@1.0.0: {} @@ -12887,15 +12838,13 @@ snapshots: ansicolors: 0.3.2 redeyed: 2.1.1 - chai@4.5.0: + chai@5.1.1: dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.1 + pathval: 2.0.0 chalk@2.4.2: dependencies: @@ -12926,9 +12875,7 @@ snapshots: chartjs-plugin-trendline@1.0.2: {} - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 + check-error@2.1.1: {} cheerio-select@2.1.0: dependencies: @@ -13202,8 +13149,6 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 - confbox@0.1.7: {} - config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -13493,9 +13438,7 @@ snapshots: decode-uri-component@0.2.2: {} - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 + deep-eql@5.0.2: {} deep-equal-in-any-order@2.0.6: dependencies: @@ -13696,8 +13639,6 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diff-sequences@29.6.3: {} - diff@4.0.2: {} dir-glob@3.0.1: @@ -16015,8 +15956,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.0: {} - js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -16284,11 +16223,6 @@ snapshots: pify: 3.0.0 strip-bom: 3.0.0 - local-pkg@0.5.0: - dependencies: - mlly: 1.7.1 - pkg-types: 1.1.3 - locate-path@2.0.0: dependencies: p-locate: 2.0.0 @@ -16402,7 +16336,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@2.3.7: + loupe@3.1.1: dependencies: get-func-name: 2.0.2 @@ -17004,13 +16938,6 @@ snapshots: mkdirp@2.1.6: {} - mlly@1.7.1: - dependencies: - acorn: 8.12.1 - pathe: 1.1.2 - pkg-types: 1.1.3 - ufo: 1.5.4 - mobx-react-lite@4.0.7(mobx@6.13.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: mobx: 6.13.1 @@ -17564,10 +17491,6 @@ snapshots: dependencies: yocto-queue: 0.1.0 - p-limit@5.0.0: - dependencies: - yocto-queue: 1.1.1 - p-locate@2.0.0: dependencies: p-limit: 1.3.0 @@ -17734,7 +17657,7 @@ snapshots: pathe@1.1.2: {} - pathval@1.1.1: {} + pathval@2.0.0: {} pend@1.2.0: {} @@ -17811,12 +17734,6 @@ snapshots: dependencies: find-up: 4.1.0 - pkg-types@1.1.3: - dependencies: - confbox: 0.1.7 - mlly: 1.7.1 - pathe: 1.1.2 - plugin-error@2.0.1: dependencies: ansi-colors: 1.1.0 @@ -17925,12 +17842,6 @@ snapshots: pretty-bytes@6.1.1: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - pretty-hrtime@1.0.3: {} pretty-ms@7.0.1: @@ -19039,10 +18950,6 @@ snapshots: strip-json-comments@5.0.1: {} - strip-literal@2.1.0: - dependencies: - js-tokens: 9.0.0 - strnum@1.0.5: optional: true @@ -19257,11 +19164,11 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@6.0.0: + test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 + glob: 10.4.5 + minimatch: 9.0.5 text-decoder@1.1.1: dependencies: @@ -19310,9 +19217,11 @@ snapshots: tinybench@2.8.0: {} - tinypool@0.8.4: {} + tinypool@1.0.0: {} - tinyspy@2.2.1: {} + tinyrainbow@1.2.0: {} + + tinyspy@3.0.0: {} tmp@0.0.33: dependencies: @@ -19484,8 +19393,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.1.0: {} - type-fest@0.16.0: {} type-fest@0.18.1: {} @@ -19551,8 +19458,6 @@ snapshots: ua-parser-js@0.7.33: {} - ufo@1.5.4: {} - uglify-js@3.19.1: {} unbox-primitive@1.0.2: @@ -19828,12 +19733,12 @@ snapshots: - rollup - supports-color - vite-node@1.6.0(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3): + vite-node@2.0.5(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3): dependencies: cac: 6.7.14 debug: 4.3.6(supports-color@5.5.0) pathe: 1.1.2 - picocolors: 1.0.1 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - '@types/node' @@ -19845,12 +19750,12 @@ snapshots: - supports-color - terser - vite-node@1.6.0(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3): + vite-node@2.0.5(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3): dependencies: cac: 6.7.14 debug: 4.3.6(supports-color@5.5.0) pathe: 1.1.2 - picocolors: 1.0.1 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) transitivePeerDependencies: - '@types/node' @@ -19950,27 +19855,26 @@ snapshots: - snappy - supports-color - vitest@1.6.0(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): + vitest@2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): dependencies: - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.3 - chai: 4.5.0 + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.5 + '@vitest/pretty-format': 2.0.5 + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 debug: 4.3.6(supports-color@5.5.0) execa: 8.0.1 - local-pkg: 0.5.0 magic-string: 0.30.11 pathe: 1.1.2 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 tinybench: 2.8.0 - tinypool: 0.8.4 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) - vite-node: 1.6.0(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) + vite-node: 2.0.5(@types/node@20.14.11)(sass@1.70.0)(terser@5.31.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.14.11 @@ -19984,27 +19888,26 @@ snapshots: - supports-color - terser - vitest@1.6.0(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): + vitest@2.0.5(@types/node@20.5.1)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3): dependencies: - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.3 - chai: 4.5.0 + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.5 + '@vitest/pretty-format': 2.0.5 + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 debug: 4.3.6(supports-color@5.5.0) execa: 8.0.1 - local-pkg: 0.5.0 magic-string: 0.30.11 pathe: 1.1.2 - picocolors: 1.0.1 std-env: 3.7.0 - strip-literal: 2.1.0 tinybench: 2.8.0 - tinypool: 0.8.4 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 vite: 5.1.7(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) - vite-node: 1.6.0(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) + vite-node: 2.0.5(@types/node@20.5.1)(sass@1.70.0)(terser@5.31.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.5.1 @@ -20444,8 +20347,6 @@ snapshots: yocto-queue@0.1.0: {} - yocto-queue@1.1.1: {} - zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 From 19cef8b4af4e48a8be85ea22137ac12818751912 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 12 Aug 2024 22:22:35 +0200 Subject: [PATCH 32/32] impr: add req.method to dev slowdown log !nuf --- backend/src/api/routes/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index b4b6d9f29..86eec0bc5 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -123,7 +123,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();