From 1009791915c020aa8047f97709ee0a4eca6e78e9 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 17 Nov 2025 12:51:40 +0100 Subject: [PATCH 1/3] refactor: remove lodash from backend (@fehmer) (#6953) --- .../__integration__/dal/ape-keys.spec.ts | 120 +++++++++-- .../__integration__/dal/config.spec.ts | 42 ++++ .../dal/leaderboards.isolated.spec.ts | 19 +- .../__integration__/dal/preset.spec.ts | 1 - .../__integration__/dal/result.spec.ts | 2 +- .../__integration__/dal/user.spec.ts | 180 ++++++++++------ .../__tests__/api/controllers/admin.spec.ts | 7 +- .../__tests__/api/controllers/ape-key.spec.ts | 10 +- .../api/controllers/connections.spec.ts | 7 +- .../api/controllers/leaderboard.spec.ts | 28 +-- .../__tests__/api/controllers/quotes.spec.ts | 16 +- .../__tests__/api/controllers/result.spec.ts | 187 +++++++++-------- .../__tests__/api/controllers/user.spec.ts | 72 +++---- backend/__tests__/init/configurations.spec.ts | 53 +++++ backend/__tests__/setup-tests.ts | 16 +- backend/__tests__/utils/misc.spec.ts | 194 ++++++++++++++---- backend/__tests__/utils/pb.spec.ts | 22 +- .../__tests__/workers/later-worker.spec.ts | 33 +++ backend/package.json | 2 - backend/src/api/controllers/ape-key.ts | 9 +- backend/src/api/controllers/connections.ts | 6 +- backend/src/api/controllers/leaderboard.ts | 9 +- backend/src/api/controllers/quote.ts | 6 +- backend/src/api/controllers/result.ts | 13 +- backend/src/api/controllers/user.ts | 43 +++- backend/src/api/routes/index.ts | 6 +- backend/src/constants/monkey-status-codes.ts | 6 +- backend/src/dal/ape-keys.ts | 9 +- backend/src/dal/config.ts | 60 +++--- backend/src/dal/leaderboards.ts | 7 +- backend/src/dal/preset.ts | 5 +- backend/src/dal/result.ts | 35 ++-- backend/src/dal/user.ts | 26 ++- backend/src/init/configuration.ts | 30 +-- backend/src/init/redis.ts | 4 +- backend/src/middlewares/permission.ts | 1 - backend/src/middlewares/rate-limit.ts | 1 - backend/src/middlewares/utility.ts | 1 - backend/src/services/weekly-xp-leaderboard.ts | 4 +- backend/src/utils/daily-leaderboards.ts | 5 +- backend/src/utils/misc.ts | 25 ++- backend/src/utils/pb.ts | 9 +- backend/src/utils/validation.ts | 1 - backend/src/workers/email-worker.ts | 1 - backend/src/workers/later-worker.ts | 50 ++--- packages/util/__test__/strings.spec.ts | 14 ++ packages/util/src/strings.ts | 3 + pnpm-lock.yaml | 11 - 48 files changed, 915 insertions(+), 496 deletions(-) create mode 100644 backend/__tests__/__integration__/dal/config.spec.ts create mode 100644 backend/__tests__/init/configurations.spec.ts create mode 100644 backend/__tests__/workers/later-worker.spec.ts create mode 100644 packages/util/__test__/strings.spec.ts create mode 100644 packages/util/src/strings.ts diff --git a/backend/__tests__/__integration__/dal/ape-keys.spec.ts b/backend/__tests__/__integration__/dal/ape-keys.spec.ts index 153a857abe42..f2506cc8afd2 100644 --- a/backend/__tests__/__integration__/dal/ape-keys.spec.ts +++ b/backend/__tests__/__integration__/dal/ape-keys.spec.ts @@ -1,23 +1,107 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { ObjectId } from "mongodb"; -import { addApeKey } from "../../../src/dal/ape-keys"; +import { + addApeKey, + DBApeKey, + editApeKey, + getApeKey, + updateLastUsedOn, +} from "../../../src/dal/ape-keys"; describe("ApeKeysDal", () => { - it("should be able to add a new ape key", async () => { - const apeKey = { - _id: new ObjectId(), - uid: "123", - name: "test", - hash: "12345", - createdOn: Date.now(), - modifiedOn: Date.now(), - lastUsedOn: Date.now(), - useCount: 0, - enabled: true, - }; - - const apeKeyId = await addApeKey(apeKey); - - expect(apeKeyId).toBe(apeKey._id.toHexString()); + beforeEach(() => { + vi.useFakeTimers(); + }); + + describe("addApeKey", () => { + it("should be able to add a new ape key", async () => { + const apeKey = buildApeKey(); + + const apeKeyId = await addApeKey(apeKey); + + expect(apeKeyId).toBe(apeKey._id.toHexString()); + + const read = await getApeKey(apeKeyId); + expect(read).toEqual({ + ...apeKey, + }); + }); + }); + + describe("editApeKey", () => { + it("should edit name of an existing ape key", async () => { + //GIVEN + const apeKey = buildApeKey({ useCount: 5, enabled: true }); + const apeKeyId = await addApeKey(apeKey); + + //WHEN + const newName = "new name"; + await editApeKey(apeKey.uid, apeKeyId, newName, undefined); + + //THENa + const readAfterEdit = (await getApeKey(apeKeyId)) as DBApeKey; + expect(readAfterEdit).toEqual({ + ...apeKey, + name: newName, + modifiedOn: Date.now(), + }); + }); + + it("should edit enabled of an existing ape key", async () => { + //GIVEN + const apeKey = buildApeKey({ useCount: 5, enabled: true }); + const apeKeyId = await addApeKey(apeKey); + + //WHEN + + await editApeKey(apeKey.uid, apeKeyId, undefined, false); + + //THEN + const readAfterEdit = (await getApeKey(apeKeyId)) as DBApeKey; + expect(readAfterEdit).toEqual({ + ...apeKey, + enabled: false, + modifiedOn: Date.now(), + }); + }); + }); + + describe("updateLastUsedOn", () => { + it("should update lastUsedOn and increment useCount when editing with lastUsedOn", async () => { + //GIVEN + const apeKey = buildApeKey({ + useCount: 5, + lastUsedOn: 42, + }); + const apeKeyId = await addApeKey(apeKey); + + //WHEN + await updateLastUsedOn(apeKey.uid, apeKeyId); + await updateLastUsedOn(apeKey.uid, apeKeyId); + + //THENa + const readAfterEdit = (await getApeKey(apeKeyId)) as DBApeKey; + expect(readAfterEdit).toEqual({ + ...apeKey, + modifiedOn: readAfterEdit.modifiedOn, + lastUsedOn: Date.now(), + useCount: 5 + 2, + }); + }); }); }); + +function buildApeKey(overrides: Partial = {}): DBApeKey { + return { + _id: new ObjectId(), + uid: "123", + name: "test", + hash: "12345", + createdOn: Date.now(), + modifiedOn: Date.now(), + lastUsedOn: Date.now(), + useCount: 0, + enabled: true, + ...overrides, + }; +} diff --git a/backend/__tests__/__integration__/dal/config.spec.ts b/backend/__tests__/__integration__/dal/config.spec.ts new file mode 100644 index 000000000000..bab22b49f96c --- /dev/null +++ b/backend/__tests__/__integration__/dal/config.spec.ts @@ -0,0 +1,42 @@ +import { ObjectId } from "mongodb"; +import { describe, expect, it } from "vitest"; +import * as ConfigDal from "../../../src/dal/config"; + +const getConfigCollection = ConfigDal.__testing.getConfigCollection; + +describe("ConfigDal", () => { + describe("saveConfig", () => { + it("should save and update user configuration correctly", async () => { + //GIVEN + const uid = new ObjectId().toString(); + await getConfigCollection().insertOne({ + uid, + config: { + ads: "on", + time: 60, + quickTab: true, //legacy value + }, + } as any); + + //WHEN + await ConfigDal.saveConfig(uid, { + ads: "on", + difficulty: "normal", + } as any); + + //WHEN + await ConfigDal.saveConfig(uid, { ads: "off" }); + + //THEN + const savedConfig = (await ConfigDal.getConfig( + uid + )) as ConfigDal.DBConfig; + + expect(savedConfig.config.ads).toBe("off"); + expect(savedConfig.config.time).toBe(60); + + //should remove legacy values + expect((savedConfig.config as any)["quickTab"]).toBeUndefined(); + }); + }); +}); diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index 5b37a0877755..cfa3a4750120 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -1,5 +1,4 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import _ from "lodash"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { ObjectId } from "mongodb"; import * as UserDal from "../../../src/dal/user"; import * as LeaderboardsDal from "../../../src/dal/leaderboards"; @@ -12,6 +11,7 @@ import { LbPersonalBests } from "../../../src/utils/pb"; import { pb } from "../../__testData__/users"; import { createConnection } from "../../__testData__/connections"; +import { omit } from "../../../src/utils/misc"; describe("LeaderboardsDal", () => { afterEach(async () => { @@ -60,7 +60,8 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: rank1 }), @@ -87,7 +88,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1 }), @@ -201,7 +202,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = result.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noBadge }), @@ -240,7 +241,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = result.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noPremium }), @@ -298,7 +299,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 3, user: rank3 }), @@ -337,7 +338,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 1, user: rank1, friendsRank: 1 }), @@ -376,7 +377,7 @@ describe("LeaderboardsDal", () => { )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - const lb = results.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }), diff --git a/backend/__tests__/__integration__/dal/preset.spec.ts b/backend/__tests__/__integration__/dal/preset.spec.ts index 3b087b8886cd..74ecedce8354 100644 --- a/backend/__tests__/__integration__/dal/preset.spec.ts +++ b/backend/__tests__/__integration__/dal/preset.spec.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { ObjectId } from "mongodb"; import * as PresetDal from "../../../src/dal/preset"; -import _ from "lodash"; describe("PresetDal", () => { describe("readPreset", () => { diff --git a/backend/__tests__/__integration__/dal/result.spec.ts b/backend/__tests__/__integration__/dal/result.spec.ts index 4d893656d307..c3bf95760fd8 100644 --- a/backend/__tests__/__integration__/dal/result.spec.ts +++ b/backend/__tests__/__integration__/dal/result.spec.ts @@ -50,7 +50,7 @@ async function createDummyData( tags: [], consistency: 100, keyConsistency: 100, - chartData: { wpm: [], raw: [], err: [] }, + chartData: { wpm: [], burst: [], err: [] }, uid, keySpacingStats: { average: 0, sd: 0 }, keyDurationStats: { average: 0, sd: 0 }, diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index c6f84fa85333..5bc19e779195 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import _ from "lodash"; + import * as UserDAL from "../../../src/dal/user"; import * as UserTestData from "../../__testData__/users"; import { createConnection as createFriend } from "../../__testData__/connections"; @@ -236,9 +236,13 @@ describe("UserDal", () => { // then const updatedUser = (await UserDAL.getUser(testUser.uid, "test")) ?? {}; - expect(_.values(updatedUser.personalBests).filter(_.isEmpty)).toHaveLength( - 5 - ); + expect(updatedUser.personalBests).toStrictEqual({ + time: {}, + words: {}, + quote: {}, + custom: {}, + zen: {}, + }); }); it("autoBan should automatically ban after configured anticheat triggers", async () => { @@ -621,97 +625,129 @@ describe("UserDal", () => { }); }); - it("updateProfile should appropriately handle multiple profile updates", async () => { - const uid = new ObjectId().toHexString(); - await UserDAL.addUser("test name", "test email", uid); + describe("updateProfile", () => { + it("updateProfile should appropriately handle multiple profile updates", async () => { + const uid = new ObjectId().toHexString(); + await UserDAL.addUser("test name", "test email", uid); - await UserDAL.updateProfile( - uid, - { + await UserDAL.updateProfile( + uid, + { + bio: "test bio", + }, + { + badges: [], + } + ); + + const user = await UserDAL.getUser(uid, "test add result filters"); + expect(user.profileDetails).toStrictEqual({ bio: "test bio", - }, - { + }); + expect(user.inventory).toStrictEqual({ badges: [], - } - ); + }); - const user = await UserDAL.getUser(uid, "test add result filters"); - expect(user.profileDetails).toStrictEqual({ - bio: "test bio", - }); - expect(user.inventory).toStrictEqual({ - badges: [], - }); + await UserDAL.updateProfile( + uid, + { + keyboard: "test keyboard", + socialProfiles: { + twitter: "test twitter", + }, + }, + { + badges: [ + { + id: 1, + selected: true, + }, + ], + } + ); - await UserDAL.updateProfile( - uid, - { + const updatedUser = await UserDAL.getUser(uid, "test add result filters"); + expect(updatedUser.profileDetails).toStrictEqual({ + bio: "test bio", keyboard: "test keyboard", socialProfiles: { twitter: "test twitter", }, - }, - { + }); + expect(updatedUser.inventory).toStrictEqual({ badges: [ { id: 1, selected: true, }, ], - } - ); + }); - const updatedUser = await UserDAL.getUser(uid, "test add result filters"); - expect(updatedUser.profileDetails).toStrictEqual({ - bio: "test bio", - keyboard: "test keyboard", - socialProfiles: { - twitter: "test twitter", - }, - }); - expect(updatedUser.inventory).toStrictEqual({ - badges: [ + await UserDAL.updateProfile( + uid, { - id: 1, - selected: true, + bio: "test bio 2", + socialProfiles: { + github: "test github", + website: "test website", + }, }, - ], - }); + { + badges: [ + { + id: 1, + }, + ], + } + ); - await UserDAL.updateProfile( - uid, - { + const updatedUser2 = await UserDAL.getUser( + uid, + "test add result filters" + ); + expect(updatedUser2.profileDetails).toStrictEqual({ bio: "test bio 2", + keyboard: "test keyboard", socialProfiles: { + twitter: "test twitter", github: "test github", website: "test website", }, - }, - { + }); + expect(updatedUser2.inventory).toStrictEqual({ badges: [ { id: 1, }, ], - } - ); - - const updatedUser2 = await UserDAL.getUser(uid, "test add result filters"); - expect(updatedUser2.profileDetails).toStrictEqual({ - bio: "test bio 2", - keyboard: "test keyboard", - socialProfiles: { - twitter: "test twitter", - github: "test github", - website: "test website", - }, + }); }); - expect(updatedUser2.inventory).toStrictEqual({ - badges: [ - { - id: 1, + it("should omit undefined or empty object values", async () => { + //GIVEN + const givenUser = await UserTestData.createUser({ + profileDetails: { + bio: "test bio", + keyboard: "test keyboard", + socialProfiles: { + twitter: "test twitter", + github: "test github", + }, }, - ], + }); + + //WHEN + await UserDAL.updateProfile(givenUser.uid, { + bio: undefined, //ignored + keyboard: "updates", + socialProfiles: {}, //ignored + }); + + //THEN + const read = await UserDAL.getUser(givenUser.uid, "read"); + expect(read.profileDetails).toStrictEqual({ + ...givenUser.profileDetails, + keyboard: "updates", + }); }); }); @@ -1177,7 +1213,6 @@ describe("UserDal", () => { discordId: "discordId", discordAvatar: "discordAvatar", }); - //when await UserDAL.linkDiscord(uid, "newId", "newAvatar"); @@ -1186,6 +1221,21 @@ describe("UserDal", () => { expect(read.discordId).toEqual("newId"); expect(read.discordAvatar).toEqual("newAvatar"); }); + it("should update without avatar", async () => { + //given + const { uid } = await UserTestData.createUser({ + discordId: "discordId", + discordAvatar: "discordAvatar", + }); + + //when + await UserDAL.linkDiscord(uid, "newId"); + + //then + const read = await UserDAL.getUser(uid, "read"); + expect(read.discordId).toEqual("newId"); + expect(read.discordAvatar).toEqual("discordAvatar"); + }); }); describe("unlinkDiscord", () => { it("throws for nonexisting user", async () => { diff --git a/backend/__tests__/api/controllers/admin.spec.ts b/backend/__tests__/api/controllers/admin.spec.ts index 2db8391c6458..b1b04c3dd39b 100644 --- a/backend/__tests__/api/controllers/admin.spec.ts +++ b/backend/__tests__/api/controllers/admin.spec.ts @@ -8,7 +8,7 @@ import * as ReportDal from "../../../src/dal/report"; import * as LogsDal from "../../../src/dal/logs"; import GeorgeQueue from "../../../src/queues/george-queue"; import * as AuthUtil from "../../../src/utils/auth"; -import _ from "lodash"; + import { enableRateLimitExpects } from "../../__testData__/rate-limit"; const { mockApp, uid } = setup(); @@ -570,9 +570,8 @@ describe("AdminController", () => { } }); async function enableAdminEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - admin: { endpointsEnabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.admin = { ...mockConfig.admin, endpointsEnabled: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/ape-key.spec.ts b/backend/__tests__/api/controllers/ape-key.spec.ts index 60aff1724e32..330ae4832576 100644 --- a/backend/__tests__/api/controllers/ape-key.spec.ts +++ b/backend/__tests__/api/controllers/ape-key.spec.ts @@ -5,7 +5,6 @@ import * as ApeKeyDal from "../../../src/dal/ape-keys"; import { ObjectId } from "mongodb"; import * as Configuration from "../../../src/init/configuration"; import * as UserDal from "../../../src/dal/user"; -import _ from "lodash"; const { mockApp, uid } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -354,9 +353,12 @@ function apeKeyDb( } async function enableApeKeysEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { endpointsEnabled: enabled, maxKeysPerUser: 1 }, - }); + const mockConfig = await configuration; + mockConfig.apeKeys = { + ...mockConfig.apeKeys, + endpointsEnabled: enabled, + maxKeysPerUser: 1, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/connections.spec.ts b/backend/__tests__/api/controllers/connections.spec.ts index a13e64de2bfe..4f0514bfc8ca 100644 --- a/backend/__tests__/api/controllers/connections.spec.ts +++ b/backend/__tests__/api/controllers/connections.spec.ts @@ -4,7 +4,6 @@ import app from "../../../src/app"; import { mockBearerAuthentication } from "../../__testData__/auth"; import * as Configuration from "../../../src/init/configuration"; import { ObjectId } from "mongodb"; -import _ from "lodash"; import * as ConnectionsDal from "../../../src/dal/connections"; import * as UserDal from "../../../src/dal/user"; @@ -383,14 +382,14 @@ describe("ConnectionsController", () => { }); async function enableConnectionsEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - connections: { enabled }, - }); + const mockConfig = await configuration; + mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } + async function expectFailForDisabledEndpoint(call: SuperTest): Promise { await enableConnectionsEndpoints(false); const { body } = await call.expect(503); diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 12fd86ea25ba..3c1477cb59b1 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { setup } from "../../__testData__/controller-test"; -import _ from "lodash"; import { ObjectId } from "mongodb"; import * as LeaderboardDal from "../../../src/dal/leaderboards"; import * as ConnectionsDal from "../../../src/dal/connections"; @@ -1422,9 +1421,8 @@ describe("Loaderboard Controller", () => { }); async function acceptApeKeys(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { acceptKeys: enabled }, - }); + const mockConfig = await configuration; + mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -1432,28 +1430,30 @@ async function acceptApeKeys(enabled: boolean): Promise { } async function dailyLeaderboardEnabled(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - dailyLeaderboards: { enabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.dailyLeaderboards = { + ...mockConfig.dailyLeaderboards, + enabled: enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } async function weeklyLeaderboardEnabled(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - leaderboards: { weeklyXp: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.leaderboards.weeklyXp = { + ...mockConfig.leaderboards.weeklyXp, + enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } - async function enableConnectionsFeature(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - connections: { enabled: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/quotes.spec.ts b/backend/__tests__/api/controllers/quotes.spec.ts index e2663ebdd334..1f9d6c0ebff0 100644 --- a/backend/__tests__/api/controllers/quotes.spec.ts +++ b/backend/__tests__/api/controllers/quotes.spec.ts @@ -9,7 +9,6 @@ import * as ReportDal from "../../../src/dal/report"; import * as LogsDal from "../../../src/dal/logs"; import * as Captcha from "../../../src/utils/captcha"; import { ObjectId } from "mongodb"; -import _ from "lodash"; import { ApproveQuote } from "@monkeytype/schemas/quotes"; const { mockApp, uid } = setup(); @@ -874,9 +873,8 @@ describe("QuotesController", () => { }); async function enableQuotes(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - quotes: { submissionsEnabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.quotes = { ...mockConfig.quotes, submissionsEnabled: enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -884,9 +882,13 @@ async function enableQuotes(enabled: boolean): Promise { } async function enableQuoteReporting(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - quotes: { reporting: { enabled, maxReports: 10, contentReportLimit: 20 } }, - }); + const mockConfig = await configuration; + mockConfig.quotes.reporting = { + ...mockConfig.quotes.reporting, + enabled, + maxReports: 10, + contentReportLimit: 20, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 758fc6c9ce7d..2a2a493d15cc 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { setup } from "../../__testData__/controller-test"; -import _, { omit } from "lodash"; import * as Configuration from "../../../src/init/configuration"; import * as ResultDal from "../../../src/dal/result"; import * as UserDal from "../../../src/dal/user"; @@ -10,6 +9,8 @@ import { ObjectId } from "mongodb"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { enableRateLimitExpects } from "../../__testData__/rate-limit"; import { DBResult } from "../../../src/utils/result"; +import { omit } from "../../../src/utils/misc"; +import { CompletedEvent } from "@monkeytype/schemas/results"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -488,15 +489,14 @@ describe("result controller test", () => { it("should apply defaults on missing data", async () => { //GIVEN const result = givenDbResult(uid); - const partialResult = omit( - result, + const partialResult = omit(result, [ "difficulty", "language", "funbox", "lazyMode", "punctuation", - "numbers" - ); + "numbers", + ]); const resultIdString = result._id.toHexString(); const tagIds = [ @@ -588,6 +588,7 @@ describe("result controller test", () => { beforeEach(async () => { await enableResultsSaving(true); + await enableUsersXpGain(true); [ userGetMock, @@ -611,48 +612,15 @@ describe("result controller test", () => { it("should add result", async () => { //GIVEN + const completedEvent = buildCompletedEvent({ + funbox: ["58008", "read_ahead_hard"], + }); //WHEN const { body } = await mockApp .post("/results") .set("Authorization", `Bearer ${uid}`) .send({ - result: { - acc: 86, - afkDuration: 5, - bailedOut: false, - blindMode: false, - charStats: [100, 2, 3, 5], - chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] }, - consistency: 23.5, - difficulty: "normal", - funbox: [], - hash: "hash", - incompleteTestSeconds: 2, - incompleteTests: [{ acc: 75, seconds: 10 }], - keyConsistency: 12, - keyDuration: [0, 3, 5], - keySpacing: [0, 2, 4], - language: "english", - lazyMode: false, - mode: "time", - mode2: "15", - numbers: false, - punctuation: false, - rawWpm: 99, - restartCount: 4, - tags: ["tagOneId", "tagTwoId"], - testDuration: 15.1, - timestamp: 1000, - uid, - wpmConsistency: 55, - wpm: 80, - stopOnLetter: false, - //new required - charTotal: 5, - keyOverlap: 7, - lastKeyToEnd: 9, - startToFirstKey: 11, - }, + result: completedEvent, }) .expect(200); @@ -662,7 +630,12 @@ describe("result controller test", () => { tagPbs: [], xp: 0, dailyXpBonus: false, - xpBreakdown: {}, + xpBreakdown: { + accPenalty: 28, + base: 20, + incomplete: 5, + funbox: 80, + }, streak: 0, insertedId: insertedId.toHexString(), }); @@ -751,44 +724,9 @@ describe("result controller test", () => { .post("/results") .set("Authorization", `Bearer ${uid}`) .send({ - result: { - acc: 86, - afkDuration: 5, - bailedOut: false, - blindMode: false, - charStats: [100, 2, 3, 5], - chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] }, - consistency: 23.5, - difficulty: "normal", - funbox: [], - hash: "hash", - incompleteTestSeconds: 2, - incompleteTests: [{ acc: 75, seconds: 10 }], - keyConsistency: 12, - keyDuration: [0, 3, 5], - keySpacing: [0, 2, 4], - language: "english", - lazyMode: false, - mode: "time", - mode2: "15", - numbers: false, - punctuation: false, - rawWpm: 99, - restartCount: 4, - tags: ["tagOneId", "tagTwoId"], - testDuration: 15.1, - timestamp: 1000, - uid, - wpmConsistency: 55, - wpm: 80, - stopOnLetter: false, - //new required - charTotal: 5, - keyOverlap: 7, - lastKeyToEnd: 9, - startToFirstKey: 11, + result: buildCompletedEvent({ extra2: "value", - }, + } as any), extra: "value", }) .expect(422); @@ -803,6 +741,24 @@ describe("result controller test", () => { }); }); + it("should fail wit duplicate funboxes", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: buildCompletedEvent({ + funbox: ["58008", "58008"], + }), + }) + .expect(400); + + //THEN + expect(body.message).toEqual("Duplicate funboxes"); + }); + // it("should fail invalid properties ", async () => { //GIVEN //WHEN @@ -824,10 +780,50 @@ describe("result controller test", () => { }); }); -async function enablePremiumFeatures(premium: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { premium: { enabled: premium } }, - }); +function buildCompletedEvent(result?: Partial): CompletedEvent { + return { + acc: 86, + afkDuration: 5, + bailedOut: false, + blindMode: false, + charStats: [100, 2, 3, 5], + chartData: { wpm: [1, 2, 3], burst: [50, 55, 56], err: [0, 2, 0] }, + consistency: 23.5, + difficulty: "normal", + funbox: [], + hash: "hash", + incompleteTestSeconds: 2, + incompleteTests: [{ acc: 75, seconds: 10 }], + keyConsistency: 12, + keyDuration: [0, 3, 5], + keySpacing: [0, 2, 4], + language: "english", + lazyMode: false, + mode: "time", + mode2: "15", + numbers: false, + punctuation: false, + rawWpm: 99, + restartCount: 4, + tags: ["tagOneId", "tagTwoId"], + testDuration: 15.1, + timestamp: 1000, + uid, + wpmConsistency: 55, + wpm: 80, + stopOnLetter: false, + //new required + charTotal: 5, + keyOverlap: 7, + lastKeyToEnd: 9, + startToFirstKey: 11, + ...result, + }; +} + +async function enablePremiumFeatures(enabled: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -857,7 +853,7 @@ function givenDbResult(uid: string, customize?: Partial): DBResult { isPb: true, chartData: { wpm: [Math.random() * 100], - raw: [Math.random() * 100], + burst: [Math.random() * 100], err: [Math.random() * 100], }, name: "testName", @@ -866,9 +862,11 @@ function givenDbResult(uid: string, customize?: Partial): DBResult { } async function acceptApeKeys(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { acceptKeys: enabled }, - }); + const mockConfig = await configuration; + mockConfig.apeKeys = { + ...mockConfig.apeKeys, + acceptKeys: enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -876,9 +874,16 @@ async function acceptApeKeys(enabled: boolean): Promise { } async function enableResultsSaving(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - results: { savingEnabled: enabled }, - }); + const mockConfig = await configuration; + mockConfig.results = { ...mockConfig.results, savingEnabled: enabled }; + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function enableUsersXpGain(enabled: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users.xp = { ...mockConfig.users.xp, enabled, funboxBonus: 1 }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 61bd8a31aa22..e2f8eb9639d7 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -31,7 +31,6 @@ import { ObjectId } from "mongodb"; import { PersonalBest } from "@monkeytype/schemas/shared"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { randomUUID } from "node:crypto"; -import _ from "lodash"; import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users"; import MonkeyError, { isFirebaseError } from "../../../src/utils/error"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; @@ -3413,7 +3412,7 @@ describe("user controller test", () => { await enableInbox(true); }); - it("shold get inbox", async () => { + it("should get inbox", async () => { //GIVEN const mailOne: MonkeyMail = { id: randomUUID(), @@ -3965,31 +3964,18 @@ function fillYearWithDay(days: number): number[] { return result; } -async function enablePremiumFeatures(premium: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { premium: { enabled: premium } }, - }); +async function enablePremiumFeatures(enabled: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users.premium = { ...mockConfig.users.premium, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } -// eslint-disable-next-line no-unused-vars -async function enableAdminFeatures(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - admin: { endpointsEnabled: enabled }, - }); - - vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( - mockConfig - ); -} - -async function enableSignup(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { signUp: enabled }, - }); +async function enableSignup(signUp: boolean): Promise { + const mockConfig = await configuration; + mockConfig.users = { ...mockConfig.users, signUp }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -3997,9 +3983,11 @@ async function enableSignup(enabled: boolean): Promise { } async function enableDiscordIntegration(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { discordIntegration: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.users.discordIntegration = { + ...mockConfig.users.discordIntegration, + enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4007,19 +3995,20 @@ async function enableDiscordIntegration(enabled: boolean): Promise { } async function enableResultFilterPresets(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - results: { filterPresets: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.results.filterPresets = { + ...mockConfig.results.filterPresets, + enabled, + }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } -async function acceptApeKeys(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - apeKeys: { acceptKeys: enabled }, - }); +async function acceptApeKeys(acceptKeys: boolean): Promise { + const mockConfig = await configuration; + mockConfig.apeKeys = { ...mockConfig.apeKeys, acceptKeys }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4027,18 +4016,16 @@ async function acceptApeKeys(enabled: boolean): Promise { } async function enableProfiles(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { profiles: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.users.profiles = { ...mockConfig.users.profiles, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } async function enableInbox(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - users: { inbox: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.users.inbox = { ...mockConfig.users.inbox, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4046,9 +4033,8 @@ async function enableInbox(enabled: boolean): Promise { } async function enableReporting(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - quotes: { reporting: { enabled } }, - }); + const mockConfig = await configuration; + mockConfig.quotes.reporting = { ...mockConfig.quotes.reporting, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig @@ -4056,14 +4042,14 @@ async function enableReporting(enabled: boolean): Promise { } async function enableConnectionsEndpoints(enabled: boolean): Promise { - const mockConfig = _.merge(await configuration, { - connections: { enabled }, - }); + const mockConfig = await configuration; + mockConfig.connections = { ...mockConfig.connections, enabled }; vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( mockConfig ); } + async function expectFailForDisabledEndpoint(call: SuperTest): Promise { await enableConnectionsEndpoints(false); const { body } = await call.expect(503); diff --git a/backend/__tests__/init/configurations.spec.ts b/backend/__tests__/init/configurations.spec.ts new file mode 100644 index 000000000000..949171f75fb0 --- /dev/null +++ b/backend/__tests__/init/configurations.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import * as Configurations from "../../src/init/configuration"; + +import { Configuration } from "@monkeytype/schemas/configuration"; +const mergeConfigurations = Configurations.__testing.mergeConfigurations; + +describe("configurations", () => { + describe("mergeConfigurations", () => { + it("should merge configurations correctly", () => { + //GIVEN + const baseConfig: Configuration = { + maintenance: false, + dev: { + responseSlowdownMs: 5, + }, + quotes: { + reporting: { + enabled: false, + maxReports: 5, + }, + submissionEnabled: true, + }, + } as any; + const liveConfig: Partial = { + maintenance: true, + quotes: { + reporting: { + enabled: true, + } as any, + maxFavorites: 10, + } as any, + }; + + //WHEN + mergeConfigurations(baseConfig, liveConfig); + + //THEN + expect(baseConfig).toEqual({ + maintenance: true, + dev: { + responseSlowdownMs: 5, + }, + quotes: { + reporting: { + enabled: true, + maxReports: 5, + }, + submissionEnabled: true, + }, + } as any); + }); + }); +}); diff --git a/backend/__tests__/setup-tests.ts b/backend/__tests__/setup-tests.ts index 183a1c377032..0c9c8b7c514f 100644 --- a/backend/__tests__/setup-tests.ts +++ b/backend/__tests__/setup-tests.ts @@ -1,17 +1,23 @@ import { afterAll, beforeAll, afterEach, vi } from "vitest"; import { BASE_CONFIGURATION } from "../src/constants/base-configuration"; import { setupCommonMocks } from "./setup-common-mocks"; +import { __testing } from "../src/init/configuration"; process.env["MODE"] = "dev"; process.env.TZ = "UTC"; beforeAll(async () => { //don't add any configuration here, add to global-setup.ts instead. - vi.mock("../src/init/configuration", () => ({ - getLiveConfiguration: () => BASE_CONFIGURATION, - getCachedConfiguration: () => BASE_CONFIGURATION, - patchConfiguration: vi.fn(), - })); + vi.mock("../src/init/configuration", async (importOriginal) => { + const orig = (await importOriginal()) as { __testing: typeof __testing }; + + return { + __testing: orig.__testing, + getLiveConfiguration: () => BASE_CONFIGURATION, + getCachedConfiguration: () => BASE_CONFIGURATION, + patchConfiguration: vi.fn(), + }; + }); vi.mock("../src/init/db", () => ({ __esModule: true, diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index 5e03db98b6f0..976402d0318f 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, afterAll, vi } from "vitest"; -import _ from "lodash"; -import * as misc from "../../src/utils/misc"; +import * as Misc from "../../src/utils/misc"; import { ObjectId } from "mongodb"; describe("Misc Utils", () => { @@ -8,36 +7,44 @@ describe("Misc Utils", () => { vi.useRealTimers(); }); - it("matchesAPattern", () => { - const testCases = { - "eng.*": { + describe("matchesAPattern", () => { + const testCases = [ + { + pattern: "eng.*", cases: ["english", "aenglish", "en", "eng"], expected: [true, false, false, true], }, - "\\d+": { + { + pattern: "\\d+", cases: ["b", "2", "331", "1a"], expected: [false, true, true, false], }, - "(hi|hello)": { + { + pattern: "(hi|hello)", cases: ["hello", "hi", "hillo", "hi hello"], expected: [true, true, false, false], }, - ".+": { + { + pattern: ".+", cases: ["a2", "b2", "c1", ""], expected: [true, true, true, false], }, - }; + ]; - _.each(testCases, (testCase, pattern) => { - const { cases, expected } = testCase; - _.each(cases, (caseValue, index) => { - expect(misc.matchesAPattern(caseValue, pattern)).toBe(expected[index]); - }); - }); + it.each(testCases)( + "matchesAPattern with $pattern", + ({ pattern, cases, expected }) => { + cases.forEach((caseValue, index) => { + expect(Misc.matchesAPattern(caseValue, pattern)).toBe( + expected[index] + ); + }); + } + ); }); - it("kogascore", () => { + describe("kogascore", () => { const testCases = [ { wpm: 214.8, @@ -79,12 +86,15 @@ describe("Misc Utils", () => { }, ]; - _.each(testCases, ({ wpm, acc, timestamp, expectedScore }) => { - expect(misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore); - }); + it.each(testCases)( + "kogascore with wpm:$wpm, acc:$acc, timestamp:$timestamp = $expectedScore", + ({ wpm, acc, timestamp, expectedScore }) => { + expect(Misc.kogascore(wpm, acc, timestamp)).toBe(expectedScore); + } + ); }); - it("identity", () => { + describe("identity", () => { const testCases = [ { input: "", @@ -107,13 +117,15 @@ describe("Misc Utils", () => { expected: "undefined", }, ]; - - _.each(testCases, ({ input, expected }) => { - expect(misc.identity(input)).toEqual(expected); - }); + it.each(testCases)( + "identity with $input = $expected", + ({ input, expected }) => { + expect(Misc.identity(input)).toBe(expected); + } + ); }); - it("flattenObjectDeep", () => { + describe("flattenObjectDeep", () => { const testCases = [ { obj: { @@ -177,9 +189,12 @@ describe("Misc Utils", () => { }, ]; - _.each(testCases, ({ obj, expected }) => { - expect(misc.flattenObjectDeep(obj)).toEqual(expected); - }); + it.each(testCases)( + "flattenObjectDeep with $obj = $expected", + ({ obj, expected }) => { + expect(Misc.flattenObjectDeep(obj)).toEqual(expected); + } + ); }); it("sanitizeString", () => { @@ -215,7 +230,7 @@ describe("Misc Utils", () => { ]; testCases.forEach(({ input, expected }) => { - expect(misc.sanitizeString(input)).toEqual(expected); + expect(Misc.sanitizeString(input)).toEqual(expected); }); }); @@ -284,7 +299,7 @@ describe("Misc Utils", () => { ]; testCases.forEach(({ input, output }) => { - expect(misc.getOrdinalNumberString(input)).toEqual(output); + expect(Misc.getOrdinalNumberString(input)).toEqual(output); }); }); it("formatSeconds", () => { @@ -298,45 +313,45 @@ describe("Misc Utils", () => { expected: "1.08 minutes", }, { - seconds: misc.HOUR_IN_SECONDS, + seconds: Misc.HOUR_IN_SECONDS, expected: "1 hour", }, { - seconds: misc.DAY_IN_SECONDS, + seconds: Misc.DAY_IN_SECONDS, expected: "1 day", }, { - seconds: misc.WEEK_IN_SECONDS, + seconds: Misc.WEEK_IN_SECONDS, expected: "1 week", }, { - seconds: misc.YEAR_IN_SECONDS, + seconds: Misc.YEAR_IN_SECONDS, expected: "1 year", }, { - seconds: 2 * misc.YEAR_IN_SECONDS, + seconds: 2 * Misc.YEAR_IN_SECONDS, expected: "2 years", }, { - seconds: 4 * misc.YEAR_IN_SECONDS, + seconds: 4 * Misc.YEAR_IN_SECONDS, expected: "4 years", }, { - seconds: 3 * misc.WEEK_IN_SECONDS, + seconds: 3 * Misc.WEEK_IN_SECONDS, expected: "3 weeks", }, { - seconds: misc.MONTH_IN_SECONDS * 4, + seconds: Misc.MONTH_IN_SECONDS * 4, expected: "4 months", }, { - seconds: misc.MONTH_IN_SECONDS * 11, + seconds: Misc.MONTH_IN_SECONDS * 11, expected: "11 months", }, ]; testCases.forEach(({ seconds, expected }) => { - expect(misc.formatSeconds(seconds)).toBe(expected); + expect(Misc.formatSeconds(seconds)).toBe(expected); }); }); @@ -347,14 +362,14 @@ describe("Misc Utils", () => { test: "test", number: 1, }; - expect(misc.replaceObjectId(fromDatabase)).toStrictEqual({ + expect(Misc.replaceObjectId(fromDatabase)).toStrictEqual({ _id: fromDatabase._id.toHexString(), test: "test", number: 1, }); }); it("ignores null values", () => { - expect(misc.replaceObjectId(null)).toBeNull(); + expect(Misc.replaceObjectId(null)).toBeNull(); }); }); @@ -371,7 +386,7 @@ describe("Misc Utils", () => { number: 2, }; expect( - misc.replaceObjectIds([fromDatabase, fromDatabase2]) + Misc.replaceObjectIds([fromDatabase, fromDatabase2]) ).toStrictEqual([ { _id: fromDatabase._id.toHexString(), @@ -386,7 +401,98 @@ describe("Misc Utils", () => { ]); }); it("handles undefined", () => { - expect(misc.replaceObjectIds(undefined as any)).toBeUndefined(); + expect(Misc.replaceObjectIds(undefined as any)).toBeUndefined(); + }); + }); + + describe("omit()", () => { + it("should omit a single key", () => { + const input = { a: 1, b: 2, c: 3 }; + const result = Misc.omit(input, ["b"]); + expect(result).toEqual({ a: 1, c: 3 }); + }); + + it("should omit multiple keys", () => { + const input = { a: 1, b: 2, c: 3, d: 4 }; + const result = Misc.omit(input, ["a", "d"]); + expect(result).toEqual({ b: 2, c: 3 }); + }); + + it("should return the same object if no keys are omitted", () => { + const input = { x: 1, y: 2 }; + const result = Misc.omit(input, []); + expect(result).toEqual({ x: 1, y: 2 }); + }); + + it("should not mutate the original object", () => { + const input = { foo: "bar", baz: "qux" }; + const copy = { ...input }; + Misc.omit(input, ["baz"]); + expect(input).toEqual(copy); + }); + + it("should ignore keys that do not exist", () => { + const input = { a: 1, b: 2 }; + const result = Misc.omit(input, "c" as any); // allow a non-existing key + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("should work with different value types", () => { + const input = { + str: "hello", + num: 123, + bool: true, + obj: { x: 1 }, + arr: [1, 2, 3], + }; + const result = Misc.omit(input, ["bool", "arr"]); + expect(result).toEqual({ + str: "hello", + num: 123, + obj: { x: 1 }, + }); + }); + }); + + describe("isPlainObject", () => { + it("should return true for plain objects", () => { + expect(Misc.isPlainObject({})).toBe(true); + expect(Misc.isPlainObject({ a: 1, b: 2 })).toBe(true); + expect(Misc.isPlainObject(Object.create(Object.prototype))).toBe(true); + }); + + it("should return false for arrays", () => { + expect(Misc.isPlainObject([])).toBe(false); + expect(Misc.isPlainObject([1, 2, 3])).toBe(false); + }); + + it("should return false for null", () => { + expect(Misc.isPlainObject(null)).toBe(false); + }); + + it("should return false for primitives", () => { + expect(Misc.isPlainObject(123)).toBe(false); + expect(Misc.isPlainObject("string")).toBe(false); + expect(Misc.isPlainObject(true)).toBe(false); + expect(Misc.isPlainObject(undefined)).toBe(false); + expect(Misc.isPlainObject(Symbol("sym"))).toBe(false); + }); + + it("should return false for objects with different prototypes", () => { + // oxlint-disable-next-line no-extraneous-class + class MyClass {} + expect(Misc.isPlainObject(new MyClass())).toBe(false); + expect(Misc.isPlainObject(Object.create(null))).toBe(false); + expect(Misc.isPlainObject(new Date())).toBe(false); + expect(Misc.isPlainObject(new Map())).toBe(false); + expect(Misc.isPlainObject(new Set())).toBe(false); + }); + + it("should return false for functions", () => { + // oxlint-disable-next-line no-empty-function + expect(Misc.isPlainObject(function () {})).toBe(false); + // oxlint-disable-next-line no-empty-function + expect(Misc.isPlainObject(() => {})).toBe(false); }); }); }); diff --git a/backend/__tests__/utils/pb.spec.ts b/backend/__tests__/utils/pb.spec.ts index 3d969e4e1879..b8400368d465 100644 --- a/backend/__tests__/utils/pb.spec.ts +++ b/backend/__tests__/utils/pb.spec.ts @@ -1,12 +1,11 @@ import { describe, it, expect } from "vitest"; -import _ from "lodash"; import * as pb from "../../src/utils/pb"; import { Mode, PersonalBests } from "@monkeytype/schemas/shared"; import { Result } from "@monkeytype/schemas/results"; import { FunboxName } from "@monkeytype/schemas/configs"; describe("Pb Utils", () => { - it("funboxCatGetPb", () => { + describe("funboxCatGetPb", () => { const testCases: { funbox: FunboxName[] | undefined; expected: boolean }[] = [ { @@ -31,16 +30,15 @@ describe("Pb Utils", () => { }, ]; - _.each(testCases, (testCase) => { - const { funbox, expected } = testCase; - //@ts-ignore ignore because this expects a whole result object - const result = pb.canFunboxGetPb({ - funbox, - }); - - expect(result).toBe(expected); - }); + it.each(testCases)( + "canFunboxGetPb with $funbox = $expected", + ({ funbox, expected }) => { + const result = pb.canFunboxGetPb({ funbox } as any); + expect(result).toBe(expected); + } + ); }); + describe("checkAndUpdatePb", () => { it("should update personal best", () => { const userPbs: PersonalBests = { @@ -175,7 +173,7 @@ describe("Pb Utils", () => { for (const lbPb of lbpbstartingvalues) { const lbPbPb = pb.updateLeaderboardPersonalBests( userPbs, - _.cloneDeep(lbPb) as pb.LbPersonalBests, + structuredClone(lbPb) as pb.LbPersonalBests, result15 ); diff --git a/backend/__tests__/workers/later-worker.spec.ts b/backend/__tests__/workers/later-worker.spec.ts new file mode 100644 index 000000000000..a63b0457df17 --- /dev/null +++ b/backend/__tests__/workers/later-worker.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import * as LaterWorker from "../../src/workers/later-worker"; +const calculateXpReward = LaterWorker.__testing.calculateXpReward; + +describe("later-worker", () => { + describe("calculateXpReward", () => { + it("should return the correct XP reward for a given rank", () => { + //GIVEN + const xpRewardBrackets = [ + { minRank: 1, maxRank: 1, minReward: 100, maxReward: 100 }, + { minRank: 2, maxRank: 10, minReward: 50, maxReward: 90 }, + ]; + + //WHEN / THEN + expect(calculateXpReward(xpRewardBrackets, 5)).toBe(75); + expect(calculateXpReward(xpRewardBrackets, 11)).toBeUndefined(); + }); + + it("should return the highest XP reward if brackets overlap", () => { + //GIVEN + const xpRewardBrackets = [ + { minRank: 1, maxRank: 5, minReward: 900, maxReward: 1000 }, + { minRank: 2, maxRank: 20, minReward: 50, maxReward: 90 }, + ]; + + //WHEN + const reward = calculateXpReward(xpRewardBrackets, 5); + + //THEN + expect(reward).toBe(900); + }); + }); +}); diff --git a/backend/package.json b/backend/package.json index 30e90af5e4bd..b15709cfc3dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,7 +45,6 @@ "firebase-admin": "12.0.0", "helmet": "4.6.0", "ioredis": "4.28.5", - "lodash": "4.17.21", "lru-cache": "7.10.1", "mjml": "4.15.0", "mongodb": "6.3.0", @@ -72,7 +71,6 @@ "@types/cron": "1.7.3", "@types/express": "5.0.3", "@types/ioredis": "4.28.10", - "@types/lodash": "4.14.178", "@types/mjml": "4.7.4", "@types/mustache": "4.2.2", "@types/node": "24.9.1", diff --git a/backend/src/api/controllers/ape-key.ts b/backend/src/api/controllers/ape-key.ts index 828d7c3a0455..d687f207ac86 100644 --- a/backend/src/api/controllers/ape-key.ts +++ b/backend/src/api/controllers/ape-key.ts @@ -1,10 +1,9 @@ -import _ from "lodash"; import { randomBytes } from "crypto"; import { hash } from "bcrypt"; import * as ApeKeysDAL from "../../dal/ape-keys"; import MonkeyError from "../../utils/error"; import { MonkeyResponse } from "../../utils/monkey-response"; -import { base64UrlEncode } from "../../utils/misc"; +import { base64UrlEncode, omit } from "../../utils/misc"; import { ObjectId } from "mongodb"; import { @@ -18,7 +17,7 @@ import { ApeKey } from "@monkeytype/schemas/ape-keys"; import { MonkeyRequest } from "../types"; function cleanApeKey(apeKey: ApeKeysDAL.DBApeKey): ApeKey { - return _.omit(apeKey, "hash", "_id", "uid", "useCount"); + return omit(apeKey, ["hash", "_id", "uid", "useCount"]); } export async function getApeKeys( @@ -27,7 +26,9 @@ export async function getApeKeys( const { uid } = req.ctx.decodedToken; const apeKeys = await ApeKeysDAL.getApeKeys(uid); - const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value(); + const cleanedKeys: Record = Object.fromEntries( + apeKeys.map((item) => [item._id.toHexString(), cleanApeKey(item)]) + ); return new MonkeyResponse("ApeKeys retrieved", cleanedKeys); } diff --git a/backend/src/api/controllers/connections.ts b/backend/src/api/controllers/connections.ts index 906f98665440..d9ee5ed0fc72 100644 --- a/backend/src/api/controllers/connections.ts +++ b/backend/src/api/controllers/connections.ts @@ -10,13 +10,13 @@ import { MonkeyRequest } from "../types"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as ConnectionsDal from "../../dal/connections"; import * as UserDal from "../../dal/user"; -import { replaceObjectId } from "../../utils/misc"; +import { replaceObjectId, omit } from "../../utils/misc"; import MonkeyError from "../../utils/error"; -import { omit } from "lodash"; + import { Connection } from "@monkeytype/schemas/connections"; function convert(db: ConnectionsDal.DBConnection): Connection { - return replaceObjectId(omit(db, "key")); + return replaceObjectId(omit(db, ["key"])); } export async function getConnections( req: MonkeyRequest diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index e754e38babf2..0e1fe7e414fa 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; import * as ConnectionsDal from "../../dal/connections"; @@ -27,6 +26,7 @@ import { MILLISECONDS_IN_DAY, } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; +import { omit } from "../../utils/misc"; export async function getLeaderboard( req: MonkeyRequest @@ -68,7 +68,7 @@ export async function getLeaderboard( language, friendsOnlyUid ); - const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); + const normalizedLeaderboard = leaderboard.map((it) => omit(it, ["_id"])); return new MonkeyResponse("Leaderboard retrieved", { count, @@ -98,7 +98,10 @@ export async function getRankFromLeaderboard( ); } - return new MonkeyResponse("Rank retrieved", _.omit(data, "_id")); + return new MonkeyResponse( + "Rank retrieved", + omit(data as LeaderboardsDAL.DBLeaderboardEntry, ["_id"]) + ); } function getDailyLeaderboardWithError( diff --git a/backend/src/api/controllers/quote.ts b/backend/src/api/controllers/quote.ts index 6862c811df1a..eeba776b60f6 100644 --- a/backend/src/api/controllers/quote.ts +++ b/backend/src/api/controllers/quote.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { v4 as uuidv4 } from "uuid"; import { getPartialUser, updateQuoteRatings } from "../../dal/user"; import * as ReportDAL from "../../dal/report"; @@ -126,7 +125,10 @@ export async function submitRating( shouldUpdateRating ); - _.setWith(userQuoteRatings, `[${language}][${quoteId}]`, rating, Object); + if (!userQuoteRatings[language]) { + userQuoteRatings[language] = {}; + } + userQuoteRatings[language][quoteId] = rating; await updateQuoteRatings(uid, userQuoteRatings); diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index e46760dd80c4..05cc53536749 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -2,6 +2,7 @@ import * as ResultDAL from "../../dal/result"; import * as PublicDAL from "../../dal/public"; import { isDevEnvironment, + omit, replaceObjectId, replaceObjectIds, } from "../../utils/misc"; @@ -26,7 +27,6 @@ import { getDailyLeaderboard } from "../../utils/daily-leaderboards"; import AutoRoleList from "../../constants/auto-roles"; import * as UserDAL from "../../dal/user"; import { buildMonkeyMail } from "../../utils/monkey-mail"; -import _, { omit } from "lodash"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; import { UAParser } from "ua-parser-js"; import { canFunboxGetPb } from "../../utils/pb"; @@ -224,7 +224,7 @@ export async function addResult( const resulthash = completedEvent.hash; if (req.ctx.configuration.results.objectHashCheckEnabled) { - const objectToHash = omit(completedEvent, "hash"); + const objectToHash = omit(completedEvent, ["hash"]); const serverhash = objectHash(objectToHash); if (serverhash !== resulthash) { void addLog( @@ -243,7 +243,7 @@ export async function addResult( Logger.warning("Object hash check is disabled, skipping hash check"); } - if (completedEvent.funbox.length !== _.uniq(completedEvent.funbox).length) { + if (completedEvent.funbox.length !== new Set(completedEvent.funbox).size) { throw new MonkeyError(400, "Duplicate funboxes"); } @@ -758,11 +758,12 @@ async function calculateXp( } if (funboxBonusConfiguration > 0 && resultFunboxes.length !== 0) { - const funboxModifier = _.sumBy(resultFunboxes, (funboxName) => { + const funboxModifier = resultFunboxes.reduce((sum, funboxName) => { const funbox = getFunbox(funboxName); const difficultyLevel = funbox?.difficultyLevel ?? 0; - return Math.max(difficultyLevel * funboxBonusConfiguration, 0); - }); + return sum + Math.max(difficultyLevel * funboxBonusConfiguration, 0); + }, 0); + if (funboxModifier > 0) { modifier += funboxModifier; breakdown.funbox = Math.round(baseXp * funboxModifier); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 93b7b5f188a0..bba8a68d441a 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import * as UserDAL from "../../dal/user"; import MonkeyError, { getErrorMessage, @@ -9,6 +8,7 @@ import * as DiscordUtils from "../../utils/discord"; import { buildAgentLog, getFrontendUrl, + omit, replaceObjectId, replaceObjectIds, sanitizeString, @@ -92,6 +92,7 @@ import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; import { tryCatch } from "@monkeytype/util/trycatch"; import * as ConnectionsDal from "../../dal/connections"; +import { PersonalBest } from "@monkeytype/schemas/shared"; async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); @@ -516,7 +517,7 @@ type RelevantUserInfo = Omit< >; function getRelevantUserInfo(user: UserDAL.DBUser): RelevantUserInfo { - return _.omit(user, [ + return omit(user, [ "bananas", "lbPersonalBests", "inbox", @@ -583,7 +584,7 @@ export async function getUser(req: MonkeyRequest): Promise { let inboxUnreadSize = 0; if (req.ctx.configuration.users.inbox.enabled) { - inboxUnreadSize = _.filter(userInfo.inbox, { read: false }).length; + inboxUnreadSize = userInfo.inbox?.filter((mail) => !mail.read).length ?? 0; } if (!userInfo.name) { @@ -934,8 +935,30 @@ export async function getProfile( lbOptOut, } = user; - const validTimePbs = _.pick(personalBests?.time, "15", "30", "60", "120"); - const validWordsPbs = _.pick(personalBests?.words, "10", "25", "50", "100"); + const extractValid = ( + src: Record, + validKeys: string[] + ): Record => { + return validKeys.reduce((obj, key) => { + if (src?.[key] !== undefined) { + obj[key] = src[key]; + } + return obj; + }, {}); + }; + + const validTimePbs = extractValid(personalBests.time, [ + "15", + "30", + "60", + "120", + ]); + const validWordsPbs = extractValid(personalBests.words, [ + "10", + "25", + "50", + "100", + ]); const typingStats = { completedTests, @@ -1017,10 +1040,12 @@ export async function updateProfile( const profileDetailsUpdates: Partial = { bio: sanitizeString(bio), keyboard: sanitizeString(keyboard), - socialProfiles: _.mapValues( - socialProfiles, - sanitizeString - ) as UserProfileDetails["socialProfiles"], + socialProfiles: Object.fromEntries( + Object.entries(socialProfiles ?? {}).map(([key, value]) => [ + key, + sanitizeString(value), + ]) + ), showActivityOnPublicProfile, }; diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index aed51685ec7d..fec7fbc969a8 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { contract } from "@monkeytype/contracts/index"; import psas from "./psas"; import publicStats from "./public"; @@ -24,7 +23,6 @@ import { IRouter, NextFunction, Response, - Router, static as expressStatic, } from "express"; import { isDevEnvironment } from "../../utils/misc"; @@ -190,8 +188,8 @@ function applyApiRoutes(app: Application): void { ); }); - _.each(API_ROUTE_MAP, (router: Router, route) => { + for (const [route, router] of Object.entries(API_ROUTE_MAP)) { const apiRoute = `${BASE_ROUTE}${route}`; app.use(apiRoute, router); - }); + } } diff --git a/backend/src/constants/monkey-status-codes.ts b/backend/src/constants/monkey-status-codes.ts index b8ae0952c077..4d24e8b4d6fb 100644 --- a/backend/src/constants/monkey-status-codes.ts +++ b/backend/src/constants/monkey-status-codes.ts @@ -1,5 +1,3 @@ -import _ from "lodash"; - type Status = { code: number; message: string; @@ -71,8 +69,8 @@ const statuses: Statuses = { }, }; -const CUSTOM_STATUS_CODES = new Set( - _.map(statuses, (status: Status) => status.code) +const CUSTOM_STATUS_CODES = new Set( + Object.values(statuses).map((status) => status.code) ); export function isCustomCode(code: number): boolean { diff --git a/backend/src/dal/ape-keys.ts b/backend/src/dal/ape-keys.ts index 78a1153a17c4..742fd029b4da 100644 --- a/backend/src/dal/ape-keys.ts +++ b/backend/src/dal/ape-keys.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import * as db from "../init/db"; import { type Filter, @@ -52,8 +51,12 @@ async function updateApeKey( const updateResult = await getApeKeysCollection().updateOne( getApeKeyFilter(uid, keyId), { - $inc: { useCount: _.has(updates, "lastUsedOn") ? 1 : 0 }, - $set: _.pickBy(updates, (value) => !_.isNil(value)), + $inc: { useCount: "lastUsedOn" in updates ? 1 : 0 }, + $set: Object.fromEntries( + Object.entries(updates).filter( + ([_, value]) => value !== null && value !== undefined + ) + ), } ); diff --git a/backend/src/dal/config.ts b/backend/src/dal/config.ts index e94ef0216750..5b9a2044e811 100644 --- a/backend/src/dal/config.ts +++ b/backend/src/dal/config.ts @@ -1,51 +1,47 @@ import { Collection, ObjectId, UpdateResult } from "mongodb"; import * as db from "../init/db"; -import _ from "lodash"; import { Config, PartialConfig } from "@monkeytype/schemas/configs"; -const configLegacyProperties = [ - "swapEscAndTab", - "quickTab", - "chartStyle", - "chartAverage10", - "chartAverage100", - "alwaysShowCPM", - "resultFilters", - "chartAccuracy", - "liveSpeed", - "extraTestColor", - "savedLayout", - "showTimerBar", - "showDiscordDot", - "maxConfidence", - "capsLockBackspace", - "showAvg", - "enableAds", -]; - -type DBConfig = { +const configLegacyProperties: Record = { + "config.swapEscAndTab": "", + "config.quickTab": "", + "config.chartStyle": "", + "config.chartAverage10": "", + "config.chartAverage100": "", + "config.alwaysShowCPM": "", + "config.resultFilters": "", + "config.chartAccuracy": "", + "config.liveSpeed": "", + "config.extraTestColor": "", + "config.savedLayout": "", + "config.showTimerBar": "", + "config.showDiscordDot": "", + "config.maxConfidence": "", + "config.capsLockBackspace": "", + "config.showAvg": "", + "config.enableAds": "", +}; + +export type DBConfig = { _id: ObjectId; uid: string; config: PartialConfig; }; -// Export for use in tests -export const getConfigCollection = (): Collection => +const getConfigCollection = (): Collection => db.collection("configs"); export async function saveConfig( uid: string, config: Partial ): Promise { - const configChanges = _.mapKeys(config, (_value, key) => `config.${key}`); - - const unset = _.fromPairs( - _.map(configLegacyProperties, (key) => [`config.${key}`, ""]) - ) as Record; + const configChanges = Object.fromEntries( + Object.entries(config).map(([key, value]) => [`config.${key}`, value]) + ); return await getConfigCollection().updateOne( { uid }, - { $set: configChanges, $unset: unset }, + { $set: configChanges, $unset: configLegacyProperties }, { upsert: true } ); } @@ -58,3 +54,7 @@ export async function getConfig(uid: string): Promise { export async function deleteConfig(uid: string): Promise { await getConfigCollection().deleteOne({ uid }); } + +export const __testing = { + getConfigCollection, +}; diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 573d6c9308eb..01302bde1ea8 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -2,7 +2,7 @@ import * as db from "../init/db"; import Logger from "../utils/logger"; import { performance } from "perf_hooks"; import { setLeaderboard } from "../utils/prometheus"; -import { isDevEnvironment } from "../utils/misc"; +import { isDevEnvironment, omit } from "../utils/misc"; import { getCachedConfiguration, getLiveConfiguration, @@ -11,7 +11,6 @@ import { import { addLog } from "./logs"; import { Collection, Document, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; -import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; import MonkeyError from "../utils/error"; import { aggregateWithAcceptedConnections } from "./connections"; @@ -81,7 +80,7 @@ export async function get( .toArray(); } if (!premiumFeaturesEnabled) { - leaderboard = leaderboard.map((it) => omit(it, "isPremium")); + leaderboard = leaderboard.map((it) => omit(it, ["isPremium"])); } return leaderboard; @@ -135,7 +134,7 @@ export async function getRank( language: string, uid: string, friendsOnly: boolean = false -): Promise { +): Promise { try { if (!friendsOnly) { const entry = await getCollection({ language, mode, mode2 }).findOne({ diff --git a/backend/src/dal/preset.ts b/backend/src/dal/preset.ts index aae2d3c42dfb..89b0095b4f38 100644 --- a/backend/src/dal/preset.ts +++ b/backend/src/dal/preset.ts @@ -2,8 +2,7 @@ import MonkeyError from "../utils/error"; import * as db from "../init/db"; import { ObjectId, type Filter, Collection, type WithId } from "mongodb"; import { EditPresetRequest, Preset } from "@monkeytype/schemas/presets"; -import { omit } from "lodash"; -import { WithObjectId } from "../utils/misc"; +import { WithObjectId, omit } from "../utils/misc"; const MAX_PRESETS = 10; @@ -62,7 +61,7 @@ export async function editPreset( uid: string, preset: EditPresetRequest ): Promise { - const update: Partial> = omit(preset, "_id"); + const update: Partial> = omit(preset, ["_id"]); if ( preset.config === undefined || preset.config === null || diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 4c6aa6b07f83..580b7737e363 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -1,7 +1,7 @@ -import _ from "lodash"; import { Collection, type DeleteResult, + Filter, ObjectId, type UpdateResult, } from "mongodb"; @@ -111,24 +111,25 @@ export async function getResults( opts?: GetResultsOpts ): Promise { const { onOrAfterTimestamp, offset, limit } = opts ?? {}; + + const condition: Filter = { uid }; + if ( + onOrAfterTimestamp !== undefined && + onOrAfterTimestamp !== null && + !isNaN(onOrAfterTimestamp) + ) { + condition.timestamp = { $gte: onOrAfterTimestamp }; + } + let query = getResultCollection() - .find( - { - uid, - ...(!_.isNil(onOrAfterTimestamp) && - !_.isNaN(onOrAfterTimestamp) && { - timestamp: { $gte: onOrAfterTimestamp }, - }), + .find(condition, { + projection: { + chartData: 0, + keySpacingStats: 0, + keyDurationStats: 0, + name: 0, }, - { - projection: { - chartData: 0, - keySpacingStats: 0, - keyDurationStats: 0, - name: 0, - }, - } - ) + }) .sort({ timestamp: -1 }); if (limit !== undefined) { diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index a96bdc67a90e..8d100012723f 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { canFunboxGetPb, checkAndUpdatePb, LbPersonalBests } from "../utils/pb"; import * as db from "../init/db"; import MonkeyError from "../utils/error"; @@ -9,7 +8,7 @@ import { type UpdateFilter, type Filter, } from "mongodb"; -import { flattenObjectDeep, WithObjectId } from "../utils/misc"; +import { flattenObjectDeep, isPlainObject, WithObjectId } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; import { getDayOfYear } from "date-fns"; import { UTCDate } from "@date-fns/utc"; @@ -56,11 +55,13 @@ export type DBUser = Omit< inbox?: MonkeyMail[]; ips?: string[]; canReport?: boolean; + nameHistory?: string[]; lastNameChange?: number; canManageApeKeys?: boolean; bananas?: number; testActivity?: CountByYearAndDay; suspicious?: boolean; + note?: string; }; const SECONDS_PER_HOUR = 3600; @@ -603,10 +604,10 @@ export async function linkDiscord( discordId: string, discordAvatar?: string ): Promise { - const updates: Partial = _.pickBy( - { discordId, discordAvatar }, - _.identity - ); + const updates: Partial = { discordId }; + if (discordAvatar !== undefined && discordAvatar !== null) + updates.discordAvatar = discordAvatar; + await updateUser({ uid }, { $set: updates }, { stack: "link discord" }); } @@ -907,10 +908,15 @@ export async function updateProfile( profileDetailUpdates: Partial, inventory?: UserInventory ): Promise { - const profileUpdates = _.omitBy( - flattenObjectDeep(profileDetailUpdates, "profileDetails"), - (value) => - value === undefined || (_.isPlainObject(value) && _.isEmpty(value)) + let profileUpdates = flattenObjectDeep( + Object.fromEntries( + Object.entries(profileDetailUpdates).filter( + ([_, value]) => + value !== undefined && + !(isPlainObject(value) && Object.keys(value).length === 0) + ) + ), + "profileDetails" ); const updates = { diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 6fc5e218c861..d08e393ac60e 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -1,8 +1,7 @@ -import _ from "lodash"; import * as db from "./db"; import { ObjectId } from "mongodb"; import Logger from "../utils/logger"; -import { identity } from "../utils/misc"; +import { identity, isPlainObject, omit } from "../utils/misc"; import { BASE_CONFIGURATION } from "../constants/base-configuration"; import { Configuration } from "@monkeytype/schemas/configuration"; import { addLog } from "../dal/logs"; @@ -15,6 +14,7 @@ import { join } from "path"; import { existsSync, readFileSync } from "fs"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; import { z } from "zod"; +import { intersect } from "@monkeytype/util/arrays"; const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes const SERVER_CONFIG_FILE_PATH = join( @@ -26,22 +26,19 @@ function mergeConfigurations( baseConfiguration: Configuration, liveConfiguration: PartialConfiguration ): void { - if ( - !_.isPlainObject(baseConfiguration) || - !_.isPlainObject(liveConfiguration) - ) { + if (!isPlainObject(baseConfiguration) || !isPlainObject(liveConfiguration)) { return; } function merge(base: object, source: object): void { - const commonKeys = _.intersection(_.keys(base), _.keys(source)); + const commonKeys = intersect(Object.keys(base), Object.keys(source), true); commonKeys.forEach((key) => { const baseValue = base[key] as object; const sourceValue = source[key] as object; - const isBaseValueObject = _.isPlainObject(baseValue); - const isSourceValueObject = _.isPlainObject(sourceValue); + const isBaseValueObject = isPlainObject(baseValue); + const isSourceValueObject = isPlainObject(sourceValue); if (isBaseValueObject && isSourceValueObject) { merge(baseValue, sourceValue); @@ -81,12 +78,11 @@ export async function getLiveConfiguration(): Promise { const liveConfiguration = await configurationCollection.findOne(); if (liveConfiguration) { - const baseConfiguration = _.cloneDeep(BASE_CONFIGURATION); + const baseConfiguration = structuredClone(BASE_CONFIGURATION); - const liveConfigurationWithoutId = _.omit( - liveConfiguration, - "_id" - ) as Configuration; + const liveConfigurationWithoutId = omit(liveConfiguration, [ + "_id", + ]) as Configuration; mergeConfigurations(baseConfiguration, liveConfigurationWithoutId); await pushConfiguration(baseConfiguration); @@ -129,7 +125,7 @@ export async function patchConfiguration( configurationUpdates: PartialConfiguration ): Promise { try { - const currentConfiguration = _.cloneDeep(configuration); + const currentConfiguration = structuredClone(configuration); mergeConfigurations(currentConfiguration, configurationUpdates); await db @@ -166,3 +162,7 @@ export async function updateFromConfigurationFile(): Promise { await patchConfiguration(data.configuration); } } + +export const __testing = { + mergeConfigurations, +}; diff --git a/backend/src/init/redis.ts b/backend/src/init/redis.ts index dc09f29aec93..3eeeed8e9257 100644 --- a/backend/src/init/redis.ts +++ b/backend/src/init/redis.ts @@ -1,10 +1,10 @@ import fs from "fs"; -import _ from "lodash"; import { join } from "path"; import IORedis, { Redis } from "ioredis"; import Logger from "../utils/logger"; import { isDevEnvironment } from "../utils/misc"; import { getErrorMessage } from "../utils/error"; +import { kebabToCamelCase } from "@monkeytype/util/strings"; // Define Redis connection with custom methods for type safety export type RedisConnectionWithCustomMethods = Redis & { @@ -64,7 +64,7 @@ function loadScripts(client: IORedis.Redis): void { scriptFiles.forEach((scriptFile) => { const scriptPath = join(REDIS_SCRIPTS_DIRECTORY_PATH, scriptFile); const scriptSource = fs.readFileSync(scriptPath, "utf-8"); - const scriptName = _.camelCase(scriptFile.split(".")[0]); + const scriptName = kebabToCamelCase(scriptFile.split(".")[0] as string); client.defineCommand(scriptName, { lua: scriptSource, diff --git a/backend/src/middlewares/permission.ts b/backend/src/middlewares/permission.ts index 4fdcdaddc42b..59787afcd56d 100644 --- a/backend/src/middlewares/permission.ts +++ b/backend/src/middlewares/permission.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import MonkeyError from "../utils/error"; import type { Response, NextFunction } from "express"; import { DBUser, getPartialUser } from "../dal/user"; diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 14340ee20780..77e04905bd9a 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import MonkeyError from "../utils/error"; import type { Response, NextFunction, Request } from "express"; import { RateLimiterMemory } from "rate-limiter-flexible"; diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index 8964f9de5d32..26c6577a2eb0 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import type { Request, Response, NextFunction, RequestHandler } from "express"; import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus"; import { isDevEnvironment } from "../utils/misc"; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index f93dd568a871..f6bad83a0584 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -9,8 +9,8 @@ import { } from "@monkeytype/schemas/leaderboards"; import { getCurrentWeekTimestamp } from "@monkeytype/util/date-and-time"; import MonkeyError from "../utils/error"; -import { omit } from "lodash"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; +import { omit } from "../utils/misc"; export type AddResultOpts = { entry: RedisXpLeaderboardEntry; @@ -202,7 +202,7 @@ export class WeeklyXpLeaderboard { ); if (!premiumFeaturesEnabled) { - resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"])); } return { entries: resultsWithRanks, count: parseInt(count) }; diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 9b3289a6c754..bc05e61bf571 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -1,7 +1,6 @@ -import _, { omit } from "lodash"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; -import { matchesAPattern, kogascore } from "./misc"; +import { matchesAPattern, kogascore, omit } from "./misc"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; import { Configuration, @@ -187,7 +186,7 @@ export class DailyLeaderboard { ); if (!premiumFeaturesEnabled) { - resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"])); } return { entries: resultsWithRanks, count: parseInt(count), minWpm }; diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 44101d1c6a10..8a48e0fbd61d 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -1,6 +1,5 @@ import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { roundTo2 } from "@monkeytype/util/numbers"; -import _, { omit } from "lodash"; import uaparser from "ua-parser-js"; import { MonkeyRequest } from "../api/types"; import { ObjectId } from "mongodb"; @@ -97,7 +96,7 @@ export function flattenObjectDeep( const newPrefix = prefix.length > 0 ? `${prefix}.${key}` : key; - if (_.isPlainObject(value)) { + if (isPlainObject(value)) { const flattened = flattenObjectDeep(value as Record); const flattenedKeys = Object.keys(flattened); @@ -220,8 +219,8 @@ export function replaceObjectId( return null; } const result = { + ...data, _id: data._id.toString(), - ...omit(data, "_id"), } as T & { _id: string }; return result; } @@ -240,3 +239,23 @@ export function replaceObjectIds( export type WithObjectId = Omit & { _id: ObjectId; }; + +export function omit( + obj: T, + keys: K[] +): Omit { + const result = { ...obj }; + for (const key of keys) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete result[key]; + } + return result; +} + +export function isPlainObject(value: unknown): boolean { + return ( + value !== null && + typeof value === "object" && + Object.getPrototypeOf(value) === Object.prototype + ); +} diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts index 80897febb6cb..027ff09d87d1 100644 --- a/backend/src/utils/pb.ts +++ b/backend/src/utils/pb.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { Mode, PersonalBest, PersonalBests } from "@monkeytype/schemas/shared"; import { Result as ResultType } from "@monkeytype/schemas/results"; import { getFunbox } from "@monkeytype/funbox"; @@ -46,7 +45,7 @@ export function checkAndUpdatePb( (userPb[mode][mode2] as PersonalBest[]).push(buildPersonalBest(result)); } - if (!_.isNil(lbPersonalBests)) { + if (lbPersonalBests !== undefined && lbPersonalBests !== null) { const newLbPb = updateLeaderboardPersonalBests( userPb, lbPersonalBests, @@ -186,9 +185,11 @@ export function updateLeaderboardPersonalBests( } } ); - _.each(bestForEveryLanguage, (pb: PersonalBest, language: string) => { + Object.entries(bestForEveryLanguage).forEach(([language, pb]) => { const languageDoesNotExist = lbPb[mode][mode2]?.[language] === undefined; - const languageIsEmpty = _.isEmpty(lbPb[mode][mode2]?.[language]); + const languageIsEmpty = + lbPb[mode][mode2]?.[language] && + Object.keys(lbPb[mode][mode2][language]).length === 0; if ( (languageDoesNotExist || diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 66bc2b4a52aa..eb5bd94e434a 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import { CompletedEvent } from "@monkeytype/schemas/results"; export function isTestTooShort(result: CompletedEvent): boolean { diff --git a/backend/src/workers/email-worker.ts b/backend/src/workers/email-worker.ts index a4f7362d75bf..59a8daccccf2 100644 --- a/backend/src/workers/email-worker.ts +++ b/backend/src/workers/email-worker.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import IORedis from "ioredis"; import { Worker, Job, type ConnectionOptions } from "bullmq"; import Logger from "../utils/logger"; diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index 57c8e34f4447..5b390e3d6513 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -1,4 +1,3 @@ -import _ from "lodash"; import IORedis from "ioredis"; import { Worker, Job, type ConnectionOptions } from "bullmq"; import Logger from "../utils/logger"; @@ -17,6 +16,7 @@ import { recordTimeToCompleteJob } from "../utils/prometheus"; import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard"; import { MonkeyMail } from "@monkeytype/schemas/users"; import { isSafeNumber, mapRange } from "@monkeytype/util/numbers"; +import { RewardBracket } from "@monkeytype/schemas/configuration"; async function handleDailyLeaderboardResults( ctx: LaterTaskContexts["daily-leaderboard-results"] @@ -61,18 +61,7 @@ async function handleDailyLeaderboardResults( const placementString = getOrdinalNumberString(rank); - const xpReward = _(xpRewardBrackets) - .filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank) - .map((bracket) => - mapRange( - rank, - bracket.minRank, - bracket.maxRank, - bracket.maxReward, - bracket.minReward - ) - ) - .max(); + const xpReward = calculateXpReward(xpRewardBrackets, rank); if (!isSafeNumber(xpReward)) return; @@ -151,18 +140,7 @@ async function handleWeeklyXpLeaderboardResults( const xp = Math.round(totalXp); const placementString = getOrdinalNumberString(rank); - const xpReward = _(xpRewardBrackets) - .filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank) - .map((bracket) => - mapRange( - rank, - bracket.minRank, - bracket.maxRank, - bracket.maxReward, - bracket.minReward - ) - ) - .max(); + const xpReward = calculateXpReward(xpRewardBrackets, rank); if (!isSafeNumber(xpReward)) return; @@ -208,6 +186,24 @@ async function jobHandler(job: Job>): Promise { Logger.success(`Job: ${taskName} - completed in ${elapsed}ms`); } +function calculateXpReward( + xpRewardBrackets: RewardBracket[], + rank: number +): number | undefined { + const rewards = xpRewardBrackets + .filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank) + .map((bracket) => + mapRange( + rank, + bracket.minRank, + bracket.maxRank, + bracket.maxReward, + bracket.minReward + ) + ); + return rewards.length ? Math.max(...rewards) : undefined; +} + export default (redisConnection?: IORedis.Redis): Worker => { const worker = new Worker(LaterQueue.queueName, jobHandler, { autorun: false, @@ -220,3 +216,7 @@ export default (redisConnection?: IORedis.Redis): Worker => { }); return worker; }; + +export const __testing = { + calculateXpReward, +}; diff --git a/packages/util/__test__/strings.spec.ts b/packages/util/__test__/strings.spec.ts new file mode 100644 index 000000000000..6b39b0ccae2e --- /dev/null +++ b/packages/util/__test__/strings.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { kebabToCamelCase } from "../src/strings"; + +describe("strings", () => { + describe("kebabToCamelCase", () => { + it("should convert kebab case to camel case", () => { + expect(kebabToCamelCase("hello-world")).toEqual("helloWorld"); + expect(kebabToCamelCase("helloWorld")).toEqual("helloWorld"); + expect( + kebabToCamelCase("one-two-three-four-five-six-seven-eight-nine-ten") + ).toEqual("oneTwoThreeFourFiveSixSevenEightNineTen"); + }); + }); +}); diff --git a/packages/util/src/strings.ts b/packages/util/src/strings.ts new file mode 100644 index 000000000000..35d53794eac5 --- /dev/null +++ b/packages/util/src/strings.ts @@ -0,0 +1,3 @@ +export function kebabToCamelCase(kebab: string): string { + return kebab.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68bf7dbe3015..3845a70ac09e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,9 +116,6 @@ importers: ioredis: specifier: 4.28.5 version: 4.28.5 - lodash: - specifier: 4.17.21 - version: 4.17.21 lru-cache: specifier: 7.10.1 version: 7.10.1 @@ -192,9 +189,6 @@ importers: '@types/ioredis': specifier: 4.28.10 version: 4.28.10 - '@types/lodash': - specifier: 4.14.178 - version: 4.14.178 '@types/mjml': specifier: 4.7.4 version: 4.7.4 @@ -3219,9 +3213,6 @@ packages: '@types/jsonwebtoken@9.0.6': resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} - '@types/lodash@4.14.178': - resolution: {integrity: sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==} - '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -12701,8 +12692,6 @@ snapshots: dependencies: '@types/node': 24.9.1 - '@types/lodash@4.14.178': {} - '@types/long@4.0.2': {} '@types/methods@1.1.4': {} From 253608727658271d4443bebd5780d90b51a5f64b Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 17 Nov 2025 12:59:56 +0100 Subject: [PATCH 2/3] refactor: use animejs instead of jquery (@miodec) (#7101) Also changes how slow timer is handled - now the animation frame rate is reduced to 30fps instead of disabling them entirely. --- frontend/package.json | 3 +- frontend/src/html/pages/settings.html | 29 ++ frontend/src/styles/account.scss | 2 +- frontend/src/styles/nav.scss | 1 - frontend/src/styles/notifications.scss | 10 +- frontend/src/styles/settings.scss | 13 +- frontend/src/styles/test.scss | 12 +- frontend/src/ts/anim.ts | 37 +++ frontend/src/ts/commandline/commandline.ts | 16 +- .../src/ts/controllers/page-controller.ts | 52 +-- frontend/src/ts/elements/account-button.ts | 8 +- frontend/src/ts/elements/alerts.ts | 37 +-- frontend/src/ts/elements/input-validation.ts | 17 +- frontend/src/ts/elements/keymap.ts | 27 +- frontend/src/ts/elements/notifications.ts | 144 ++++---- .../ts/elements/settings/fps-limit-section.ts | 56 ++++ .../src/ts/elements/settings/theme-picker.ts | 16 +- frontend/src/ts/elements/xp-bar.ts | 312 +++++++----------- frontend/src/ts/index.ts | 6 +- frontend/src/ts/pages/account-settings.ts | 4 +- frontend/src/ts/pages/account.ts | 20 +- frontend/src/ts/pages/leaderboards.ts | 14 +- frontend/src/ts/pages/loading.ts | 17 +- frontend/src/ts/pages/settings.ts | 2 + frontend/src/ts/popups/video-ad-popup.ts | 40 +-- frontend/src/ts/ready.ts | 12 +- frontend/src/ts/test/caret.ts | 2 +- .../test/funbox/layoutfluid-funbox-timer.ts | 32 +- .../src/ts/test/funbox/memory-funbox-timer.ts | 31 +- frontend/src/ts/test/live-acc.ts | 59 ++-- frontend/src/ts/test/live-burst.ts | 59 ++-- frontend/src/ts/test/live-speed.ts | 67 ++-- frontend/src/ts/test/monkey.ts | 30 +- frontend/src/ts/test/pb-crown.ts | 18 +- frontend/src/ts/test/result.ts | 10 +- frontend/src/ts/test/test-config.ts | 201 ++++++----- frontend/src/ts/test/test-logic.ts | 86 +++-- frontend/src/ts/test/test-timer.ts | 3 + frontend/src/ts/test/test-ui.ts | 63 ++-- frontend/src/ts/test/timer-progress.ts | 173 +++++----- frontend/src/ts/utils/animated-modal.ts | 214 +++++------- frontend/src/ts/utils/caret.ts | 93 +++--- frontend/src/ts/utils/misc.ts | 94 +++--- pnpm-lock.yaml | 28 +- 44 files changed, 1039 insertions(+), 1131 deletions(-) create mode 100644 frontend/src/ts/anim.ts create mode 100644 frontend/src/ts/elements/settings/fps-limit-section.ts diff --git a/frontend/package.json b/frontend/package.json index 332b82401604..680546a88b37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -93,6 +93,7 @@ "@sentry/browser": "9.14.0", "@sentry/vite-plugin": "3.3.1", "@ts-rest/core": "3.52.1", + "animejs": "4.2.2", "balloon-css": "1.2.0", "canvas-confetti": "1.5.1", "chart.js": "3.7.1", @@ -108,8 +109,6 @@ "howler": "2.2.3", "idb": "8.0.3", "jquery": "3.7.1", - "jquery-color": "2.2.0", - "jquery.easing": "1.4.1", "konami": "1.7.0", "lz-ts": "1.1.2", "modern-screenshot": "4.6.5", diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 2e1a828ba1cf..b8557e36f211 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1887,6 +1887,35 @@ +
+
+ + animation fps limit + +
+
+ Limit the maximum fps for animations. Setting this to "native" will run + the animations as fast as possible (at your monitor's refresh rate). + Setting this above your monitor's refresh rate will have no effect. +
+ +
+ +
+
+ or +
+
+ +
+
diff --git a/frontend/src/styles/account.scss b/frontend/src/styles/account.scss index 693b08be1221..1322504cf357 100644 --- a/frontend/src/styles/account.scss +++ b/frontend/src/styles/account.scss @@ -193,7 +193,7 @@ } .active { - animation: accountRowHighlight 4s linear 0s 1; + animation: accountRowHighlight 5s linear 0s 1; } .loadMoreButton { diff --git a/frontend/src/styles/nav.scss b/frontend/src/styles/nav.scss index 2c7eda15ca59..846be5224904 100644 --- a/frontend/src/styles/nav.scss +++ b/frontend/src/styles/nav.scss @@ -69,7 +69,6 @@ nav { } .level { - transition: 0.125s; width: max-content; font-size: 0.65em; line-height: 0.65em; diff --git a/frontend/src/styles/notifications.scss b/frontend/src/styles/notifications.scss index 6d7218144d8a..c2f0f7cad5b2 100644 --- a/frontend/src/styles/notifications.scss +++ b/frontend/src/styles/notifications.scss @@ -2,14 +2,17 @@ width: 350px; z-index: 99999999; display: grid; - gap: 1rem; + // gap: 1rem; + // margin-top: 1rem; + padding-top: 1rem; position: fixed; right: 1rem; - top: 1rem; + // top: 1rem; transition: 0.125s opacity; .clearAll.button { font-size: 0.75em; + margin-bottom: 1rem; } &.focus .clearAll { visibility: hidden; @@ -22,13 +25,14 @@ } .history { display: grid; - gap: 1rem; + // gap: 1rem; } .notif { --notif-border-color: rgba(0, 130, 251, 0.985); --notif-background-color: rgba(0, 77, 148, 0.9); transition: 0.125s background; + margin-bottom: 1rem; -webkit-user-select: none; user-select: none; diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss index f74bc242a16e..4c7c2d3c0413 100644 --- a/frontend/src/styles/settings.scss +++ b/frontend/src/styles/settings.scss @@ -169,9 +169,11 @@ } &[data-config-name="fontFamily"], - &[data-config-name="customBackgroundSize"] { + &[data-config-name="customBackgroundSize"], + &.fpsLimit { .separator { margin-bottom: 0.5rem; + margin-top: 0.5rem; grid-column: span 2; // color: var(--sub-color); display: grid; @@ -187,6 +189,15 @@ } } + &.fpsLimit { + .inputs { + button, + input { + width: 100%; + } + } + } + &[data-config-name="fontFamily"] { grid-template-areas: "title title" diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 5d954f1de99a..c1483b403f4a 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -1264,6 +1264,7 @@ border-radius: var(--roundness); z-index: 2; // width: max-content; + overflow: hidden; } .spacer { height: auto; @@ -1271,11 +1272,6 @@ border-radius: calc(var(--roundness) / 2); background: var(--bg-color); margin: 0.75em 0; - transition: 250ms; - &.scrolled { - opacity: 0; - width: 0; - } } .wordCount, @@ -1334,6 +1330,12 @@ transition: opacity 0.25s, right 0.25s; opacity: 0; } + + .mode { + background: var(--sub-alt-color); + z-index: 2; + } + &:hover { .shareButton { opacity: 1; diff --git a/frontend/src/ts/anim.ts b/frontend/src/ts/anim.ts new file mode 100644 index 000000000000..bafbf7d0fc56 --- /dev/null +++ b/frontend/src/ts/anim.ts @@ -0,0 +1,37 @@ +import { engine } from "animejs"; +import { LocalStorageWithSchema } from "./utils/local-storage-with-schema"; +import { z } from "zod"; + +export const fpsLimitSchema = z.number().int().min(30).max(1000); + +const fpsLimit = new LocalStorageWithSchema({ + key: "fpsLimit", + schema: fpsLimitSchema, + fallback: 1000, +}); + +export function setfpsLimit(fps: number): boolean { + const result = fpsLimit.set(fps); + applyEngineSettings(); + return result; +} + +export function getfpsLimit(): number { + return fpsLimit.get(); +} + +export function applyEngineSettings(): void { + engine.pauseOnDocumentHidden = false; + engine.fps = fpsLimit.get(); + engine.defaults.frameRate = fpsLimit.get(); +} + +export function setLowFpsMode(): void { + engine.fps = 30; + engine.defaults.frameRate = 30; +} + +export function clearLowFpsMode(): void { + engine.fps = fpsLimit.get(); + engine.defaults.frameRate = fpsLimit.get(); +} diff --git a/frontend/src/ts/commandline/commandline.ts b/frontend/src/ts/commandline/commandline.ts index 40cf8bcd58d0..86d713949c4f 100644 --- a/frontend/src/ts/commandline/commandline.ts +++ b/frontend/src/ts/commandline/commandline.ts @@ -577,6 +577,8 @@ async function updateActiveCommand(): Promise { command.hover?.(); } +let shakeTimeout: null | NodeJS.Timeout; + function handleInputSubmit(): void { if (isAnimating) return; if (inputModeParams.command === null) { @@ -587,13 +589,13 @@ function handleInputSubmit(): void { //validation ongoing, ignore the submit return; } else if (inputModeParams.validation?.status === "failed") { - const cmdLine = $("#commandLine .modal"); - cmdLine - .stop(true, true) - .addClass("hasError") - .animate({ undefined: 1 }, 500, () => { - cmdLine.removeClass("hasError"); - }); + modal.getModal().classList.add("hasError"); + if (shakeTimeout !== null) { + clearTimeout(shakeTimeout); + } + shakeTimeout = setTimeout(() => { + modal.getModal().classList.remove("hasError"); + }, 500); return; } diff --git a/frontend/src/ts/controllers/page-controller.ts b/frontend/src/ts/controllers/page-controller.ts index f32f64caf3eb..8745cea66ae4 100644 --- a/frontend/src/ts/controllers/page-controller.ts +++ b/frontend/src/ts/controllers/page-controller.ts @@ -54,11 +54,9 @@ function updateTitle(nextPage: { id: string; display?: string }): void { async function showSyncLoading({ loadingOptions, totalDuration, - easingMethod, }: { loadingOptions: LoadingOptions[]; totalDuration: number; - easingMethod: Misc.JQueryEasing; }): Promise { PageLoading.page.element.removeClass("hidden").css("opacity", 0); await PageLoading.page.beforeShow({}); @@ -67,14 +65,10 @@ async function showSyncLoading({ const fillOffset = 100 / fillDivider; //void here to run the loading promise as soon as possible - void Misc.promiseAnimation( - PageLoading.page.element, - { - opacity: "1", - }, - totalDuration / 2, - easingMethod - ); + void Misc.promiseAnimate(PageLoading.page.element[0] as HTMLElement, { + opacity: "1", + duration: totalDuration / 2, + }); for (let i = 0; i < loadingOptions.length; i++) { const currentOffset = fillOffset * i; @@ -102,14 +96,10 @@ async function showSyncLoading({ } } - await Misc.promiseAnimation( - PageLoading.page.element, - { - opacity: "0", - }, - totalDuration / 2, - easingMethod - ); + await Misc.promiseAnimate(PageLoading.page.element[0] as HTMLElement, { + opacity: "0", + duration: totalDuration / 2, + }); await PageLoading.page.afterHide(); PageLoading.page.element.addClass("hidden"); @@ -208,7 +198,6 @@ export async function change( const previousPage = pages[ActivePage.get()]; const nextPage = pages[pageName]; const totalDuration = Misc.applyReducedMotion(250); - const easingMethod: Misc.JQueryEasing = "swing"; //start PageTransition.set(true); @@ -217,14 +206,10 @@ export async function change( //previous page await previousPage?.beforeHide?.(); previousPage.element.removeClass("hidden").css("opacity", 1); - await Misc.promiseAnimation( - previousPage.element, - { - opacity: "0", - }, - totalDuration / 2, - easingMethod - ); + await Misc.promiseAnimate(previousPage.element[0] as HTMLElement, { + opacity: "0", + duration: totalDuration / 2, + }); previousPage.element.addClass("hidden"); await previousPage?.afterHide(); @@ -245,7 +230,6 @@ export async function change( await showSyncLoading({ loadingOptions: syncLoadingOptions, totalDuration, - easingMethod, }); } @@ -297,14 +281,10 @@ export async function change( } nextPage.element.removeClass("hidden").css("opacity", 0); - await Misc.promiseAnimation( - nextPage.element, - { - opacity: "1", - }, - totalDuration / 2, - easingMethod - ); + await Misc.promiseAnimate(nextPage.element[0] as HTMLElement, { + opacity: "1", + duration: totalDuration / 2, + }); nextPage.element.addClass("active"); await nextPage?.afterShow(); diff --git a/frontend/src/ts/elements/account-button.ts b/frontend/src/ts/elements/account-button.ts index c3cd2bbf7780..90f5b444cf0a 100644 --- a/frontend/src/ts/elements/account-button.ts +++ b/frontend/src/ts/elements/account-button.ts @@ -58,14 +58,14 @@ export function update(): void { `/profile/${name}` ); void Misc.swapElements( - $("nav .textButton.view-login"), - $("nav .accountButtonAndMenu"), + document.querySelector("nav .textButton.view-login") as HTMLElement, + document.querySelector("nav .accountButtonAndMenu") as HTMLElement, 250 ); } else { void Misc.swapElements( - $("nav .accountButtonAndMenu"), - $("nav .textButton.view-login"), + document.querySelector("nav .accountButtonAndMenu") as HTMLElement, + document.querySelector("nav .textButton.view-login") as HTMLElement, 250, async () => { updateName(""); diff --git a/frontend/src/ts/elements/alerts.ts b/frontend/src/ts/elements/alerts.ts index 09be7412487d..779b67f79ef8 100644 --- a/frontend/src/ts/elements/alerts.ts +++ b/frontend/src/ts/elements/alerts.ts @@ -13,6 +13,7 @@ import { MonkeyMail } from "@monkeytype/schemas/users"; import * as XPBar from "../elements/xp-bar"; import * as AuthEvent from "../observables/auth-event"; import * as ActivePage from "../states/active-page"; +import { animate } from "animejs"; let accountAlerts: MonkeyMail[] = []; let maxMail = 0; @@ -341,18 +342,16 @@ function markReadAlert(id: string): void { .append( `` ); - item.find(".rewards").animate( - { - opacity: 0, - height: 0, - marginTop: 0, - }, - 250, - "easeOutCubic", - () => { + + animate(item.find(".rewards")[0] as HTMLElement, { + opacity: 0, + height: 0, + marginTop: 0, + duration: 250, + onComplete: () => { item.find(".rewards").remove(); - } - ); + }, + }); } function updateClaimDeleteAllButton(): void { @@ -414,24 +413,12 @@ const modal = new AnimatedModal({ customAnimations: { show: { modal: { - from: { - marginRight: "-10rem", - }, - to: { - marginRight: "0", - }, - easing: "easeOutCirc", + marginRight: ["-10rem", "0"], }, }, hide: { modal: { - from: { - marginRight: "0", - }, - to: { - marginRight: "-10rem", - }, - easing: "easeInCirc", + marginRight: ["0", "-10rem"], }, }, }, diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index 0ad5554b21b8..5dce09c44705 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -206,6 +206,7 @@ export function validateWithIndicator( inputElement.value = val ?? ""; if (val === null) { indicator.hide(); + currentStatus = { status: "checking" }; } else { inputElement.dispatchEvent(new Event("input")); } @@ -270,6 +271,8 @@ export function handleConfigInput({ }); } + let shakeTimeout: null | NodeJS.Timeout; + const handleStore = (): void => { if (input.value === "" && (validation?.resetIfEmpty ?? true)) { //use last config value, clear validation @@ -277,13 +280,13 @@ export function handleConfigInput({ input.dispatchEvent(new Event("input")); } if (status === "failed") { - const parent = $(input.parentElement as HTMLElement); - parent - .stop(true, true) - .addClass("hasError") - .animate({ undefined: 1 }, 500, () => { - parent.removeClass("hasError"); - }); + input.parentElement?.classList.add("hasError"); + if (shakeTimeout !== null) { + clearTimeout(shakeTimeout); + } + shakeTimeout = setTimeout(() => { + input.parentElement?.classList.remove("hasError"); + }, 500); return; } const value = (inputValueConvert?.(input.value) ?? diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts index da8a8dae4065..2ba5d4f93d21 100644 --- a/frontend/src/ts/elements/keymap.ts +++ b/frontend/src/ts/elements/keymap.ts @@ -1,6 +1,5 @@ import Config from "../config"; import * as ThemeColors from "./theme-colors"; -import * as SlowTimer from "../states/slow-timer"; import * as ConfigEvent from "../observables/config-event"; import * as KeymapEvent from "../observables/keymap-event"; import * as Misc from "../utils/misc"; @@ -16,6 +15,7 @@ import * as KeyConverter from "../utils/key-converter"; import { getActiveFunboxNames } from "../test/funbox/list"; import { areSortedArraysEqual } from "../utils/arrays"; import { LayoutObject } from "@monkeytype/schemas/layouts"; +import { animate } from "animejs"; export const keyDataDelimiter = "~~"; @@ -100,38 +100,33 @@ async function flashKey(key: string, correct?: boolean): Promise { const themecolors = await ThemeColors.getAll(); try { - let css = { + let startingStyle = { color: themecolors.bg, backgroundColor: themecolors.sub, borderColor: themecolors.sub, }; if (correct || Config.blindMode) { - css = { + startingStyle = { color: themecolors.bg, backgroundColor: themecolors.main, borderColor: themecolors.main, }; } else { - css = { + startingStyle = { color: themecolors.bg, backgroundColor: themecolors.error, borderColor: themecolors.error, }; } - $(key) - .stop(true, true) - .css(css) - .animate( - { - color: themecolors.sub, - backgroundColor: themecolors.subAlt, - borderColor: themecolors.sub, - }, - SlowTimer.get() ? 0 : 500, - "easeOutExpo" - ); + animate(key, { + color: [startingStyle.color, themecolors.sub], + backgroundColor: [startingStyle.backgroundColor, themecolors.subAlt], + borderColor: [startingStyle.borderColor, themecolors.sub], + duration: 250, + easing: "out(5)", + }); } catch (e) {} } diff --git a/frontend/src/ts/elements/notifications.ts b/frontend/src/ts/elements/notifications.ts index 351944361152..425d9031cc01 100644 --- a/frontend/src/ts/elements/notifications.ts +++ b/frontend/src/ts/elements/notifications.ts @@ -1,9 +1,9 @@ import { debounce } from "throttle-debounce"; import * as Misc from "../utils/misc"; import * as BannerEvent from "../observables/banner-event"; -// import * as Alerts from "./alerts"; import * as NotificationEvent from "../observables/notification-event"; import { convertRemToPixels } from "../utils/numbers"; +import { animate } from "animejs"; function updateMargin(): void { const height = $("#bannerCenter").height() as number; @@ -13,6 +13,7 @@ function updateMargin(): void { let visibleStickyNotifications = 0; let id = 0; + type NotificationType = "notification" | "banner" | "psa"; class Notification { id: number; @@ -99,56 +100,43 @@ class Notification { visibleStickyNotifications++; updateClearAllButton(); } - const oldHeight = $("#notificationCenter .history").height() as number; $("#notificationCenter .history").prepend(` +
+
${icon}
${title}
${this.message}
+
+ `); + const notif = document.querySelector( + `#notificationCenter .notif[id='${this.id}']` + ); + if (notif === null) return; -
-
${icon}
${title}
${this.message}
-
- - `); - const newHeight = $("#notificationCenter .history").height() as number; - $(`#notificationCenter .notif[id='${this.id}']`).remove(); - $("#notificationCenter .history") - .css("margin-top", 0) - .animate( - { - marginTop: newHeight - oldHeight, - }, - Misc.applyReducedMotion(125), - () => { - $("#notificationCenter .history").css("margin-top", 0); - $("#notificationCenter .history").prepend(` + const notifHeight = notif.offsetHeight; + const duration = Misc.applyReducedMotion(250); -
-
${icon}
${title}
${this.message}
-
+ animate(notif, { + opacity: [0, 1], + duration: duration / 2, + delay: duration / 2, + }); + notif?.addEventListener("click", () => { + this.hide(); + this.closeCallback(); + if (this.duration === 0) { + visibleStickyNotifications--; + } + updateClearAllButton(); + }); - `); - $(`#notificationCenter .notif[id='${this.id}']`) - .css("opacity", 0) - .animate( - { - opacity: 1, - }, - Misc.applyReducedMotion(125), - () => { - $(`#notificationCenter .notif[id='${this.id}']`).css( - "opacity", - "" - ); - } - ); - $(`#notificationCenter .notif[id='${this.id}']`).on("click", () => { - this.hide(); - this.closeCallback(); - if (this.duration === 0) { - visibleStickyNotifications--; - } - updateClearAllButton(); - }); - } - ); + const historyElement = document.querySelector( + "#notificationCenter .history" + ) as HTMLElement; + animate(historyElement, { + marginTop: { + from: "-=" + notifHeight, + to: 0, + }, + duration: duration / 2, + }); $(`#notificationCenter .notif[id='${this.id}']`).on("hover", () => { $(`#notificationCenter .notif[id='${this.id}']`).toggleClass("hover"); }); @@ -214,43 +202,37 @@ class Notification { } hide(): void { if (this.type === "notification") { - $(`#notificationCenter .notif[id='${this.id}']`) - .css("opacity", 1) - .animate( - { - opacity: 0, - }, - Misc.applyReducedMotion(125), - () => { - $(`#notificationCenter .notif[id='${this.id}']`).animate( - { - height: 0, - }, - Misc.applyReducedMotion(125), - () => { - $(`#notificationCenter .notif[id='${this.id}']`).remove(); - } - ); - } - ); + const elem = document.querySelector( + `#notificationCenter .notif[id='${this.id}']` + ) as HTMLElement; + + const duration = Misc.applyReducedMotion(250); + + animate(elem, { + opacity: { + to: 0, + duration: duration, + }, + height: { + to: 0, + duration: duration / 2, + delay: duration / 2, + }, + marginBottom: { + to: 0, + duration: duration / 2, + delay: duration / 2, + }, + onComplete: () => { + elem.remove(); + }, + }); } else if (this.type === "banner" || this.type === "psa") { $( `#bannerCenter .banner[id='${this.id}'], #bannerCenter .psa[id='${this.id}']` - ) - .css("opacity", 1) - .animate( - { - opacity: 0, - }, - Misc.applyReducedMotion(125), - () => { - $( - `#bannerCenter .banner[id='${this.id}'], #bannerCenter .psa[id='${this.id}']` - ).remove(); - updateMargin(); - BannerEvent.dispatch(); - } - ); + ).remove(); + updateMargin(); + BannerEvent.dispatch(); } } } diff --git a/frontend/src/ts/elements/settings/fps-limit-section.ts b/frontend/src/ts/elements/settings/fps-limit-section.ts new file mode 100644 index 000000000000..fb1b994121b3 --- /dev/null +++ b/frontend/src/ts/elements/settings/fps-limit-section.ts @@ -0,0 +1,56 @@ +import { getfpsLimit, fpsLimitSchema, setfpsLimit } from "../../anim"; +import { validateWithIndicator } from "../input-validation"; +import * as Notifications from "../notifications"; + +const section = document.querySelector( + "#pageSettings .section.fpsLimit" +) as HTMLElement; + +const button = section.querySelector( + "button[data-fpsLimit='native']" +) as HTMLButtonElement; + +const input = validateWithIndicator( + section.querySelector('input[type="number"]') as HTMLInputElement, + { + schema: fpsLimitSchema, + inputValueConvert: (val: string) => parseInt(val, 10), + } +); + +export function update(): void { + const fpsLimit = getfpsLimit(); + if (fpsLimit >= 1000) { + input.setValue(null); + button.classList.add("active"); + } else { + input.value = fpsLimit.toString(); + button.classList.remove("active"); + } +} + +function save(value: number): void { + if (setfpsLimit(value)) { + Notifications.add("FPS limit updated", 0); + } + update(); +} + +function saveFromInput(): void { + if (input.getValidationResult().status !== "success") return; + const val = parseInt(input.value, 10); + save(val); +} + +button.addEventListener("click", () => { + save(1000); + update(); +}); + +input.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + saveFromInput(); + } +}); + +input.addEventListener("focusout", (e) => saveFromInput()); diff --git a/frontend/src/ts/elements/settings/theme-picker.ts b/frontend/src/ts/elements/settings/theme-picker.ts index 479f21d6dade..db430bda4a44 100644 --- a/frontend/src/ts/elements/settings/theme-picker.ts +++ b/frontend/src/ts/elements/settings/theme-picker.ts @@ -305,14 +305,22 @@ export function updateActiveTab(): void { if (Config.customTheme) { void Misc.swapElements( - $('.pageSettings [tabContent="preset"]'), - $('.pageSettings [tabContent="custom"]'), + document.querySelector( + '.pageSettings [tabContent="preset"]' + ) as HTMLElement, + document.querySelector( + '.pageSettings [tabContent="custom"]' + ) as HTMLElement, 250 ); } else { void Misc.swapElements( - $('.pageSettings [tabContent="custom"]'), - $('.pageSettings [tabContent="preset"]'), + document.querySelector( + '.pageSettings [tabContent="custom"]' + ) as HTMLElement, + document.querySelector( + '.pageSettings [tabContent="preset"]' + ) as HTMLElement, 250 ); } diff --git a/frontend/src/ts/elements/xp-bar.ts b/frontend/src/ts/elements/xp-bar.ts index b28ebe56600e..6c1b8c229d69 100644 --- a/frontend/src/ts/elements/xp-bar.ts +++ b/frontend/src/ts/elements/xp-bar.ts @@ -1,9 +1,9 @@ import * as Misc from "../utils/misc"; import * as Levels from "../utils/levels"; import { getAll } from "./theme-colors"; -import * as SlowTimer from "../states/slow-timer"; import { XpBreakdown } from "@monkeytype/schemas/results"; -import { isSafeNumber, mapRange } from "@monkeytype/util/numbers"; +import { isSafeNumber } from "@monkeytype/util/numbers"; +import { animate } from "animejs"; let breakdownVisible = false; let skip = false; @@ -19,11 +19,15 @@ let lastUpdate: { breakdown: undefined, }; -const xpBreakdownTotalEl = $("nav .xpBar .xpBreakdown .total"); -const xpBreakdownListEl = $("nav .xpBar .xpBreakdown .list"); -const levelEl = $("nav .level"); -const barEl = $("nav .xpBar .bar"); -const barWrapperEl = $("nav .xpBar"); +const xpBreakdownTotalEl = document.querySelector( + "nav .xpBar .xpBreakdown .total" +) as HTMLElement; +const xpBreakdownListEl = document.querySelector( + "nav .xpBar .xpBreakdown .list" +) as HTMLElement; +const levelEl = document.querySelector("nav .level") as HTMLElement; +const barEl = document.querySelector("nav .xpBar .bar") as HTMLElement; +const barWrapperEl = document.querySelector("nav .xpBar") as HTMLElement; export async function skipBreakdown(): Promise { skip = true; @@ -33,13 +37,21 @@ export async function skipBreakdown(): Promise { if (!breakdownDone) { void flashTotalXp(lastUpdate.addedXp, true); } else { - xpBreakdownTotalEl.text(`+${lastUpdate.addedXp}`); + xpBreakdownTotalEl.textContent = `+${lastUpdate.addedXp}`; } - xpBreakdownListEl.stop(true, true).empty().addClass("hidden"); - levelEl.text( - Levels.getLevelFromTotalXp(lastUpdate.currentXp + lastUpdate.addedXp) - ); + animate(xpBreakdownListEl, { + opacity: [1, 0], + duration: Misc.applyReducedMotion(250), + onComplete: () => { + xpBreakdownListEl.innerHTML = ""; + xpBreakdownListEl.classList.add("hidden"); + }, + }); + + levelEl.textContent = `${Levels.getLevelFromTotalXp( + lastUpdate.currentXp + lastUpdate.addedXp + )}`; const endingDetails = Levels.getXpDetails( lastUpdate.currentXp + lastUpdate.addedXp @@ -48,27 +60,21 @@ export async function skipBreakdown(): Promise { endingDetails.level + endingDetails.levelCurrentXp / endingDetails.levelMaxXp; - barEl.css("width", `${(endingLevel % 1) * 100}%`); + barEl.style.width = `${(endingLevel % 1) * 100}%`; await Misc.sleep(2000); breakdownVisible = false; - barWrapperEl - .stop(true, true) - .css("opacity", 1) - .animate( - { - opacity: 0, - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(250) - ); + + animate(barWrapperEl, { + opacity: [1, 0], + duration: Misc.applyReducedMotion(250), + }); } export function setXp(xp: number): void { const xpDetails = Levels.getXpDetails(xp); const levelCompletionRatio = xpDetails.levelCurrentXp / xpDetails.levelMaxXp; - levelEl.text(xpDetails.level); - barEl.css({ - width: levelCompletionRatio * 100 + "%", - }); + levelEl.textContent = `${xpDetails.level}`; + barEl.style.width = levelCompletionRatio * 100 + "%"; } export async function update( @@ -84,7 +90,7 @@ export async function update( breakdown, }; - levelEl.text(Levels.getLevelFromTotalXp(currentXp)); + levelEl.textContent = `${Levels.getLevelFromTotalXp(currentXp)}`; const startingXp = Levels.getXpDetails(currentXp); const endingXp = Levels.getXpDetails(currentXp + addedXp); @@ -93,35 +99,28 @@ export async function update( const endingLevel = endingXp.level + endingXp.levelCurrentXp / endingXp.levelMaxXp; - const breakdownList = xpBreakdownListEl; + xpBreakdownListEl.style.opacity = "0"; + xpBreakdownListEl.innerHTML = ""; + barWrapperEl.style.opacity = "0"; + xpBreakdownTotalEl.textContent = ""; - xpBreakdownListEl.stop(true, true).css("opacity", 0).empty(); - barWrapperEl.stop(true, true).css("opacity", 0); - xpBreakdownTotalEl.text(""); - - const showParent = Misc.promiseAnimation( - barWrapperEl, - { - opacity: "1", - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(125), - "linear" - ); + const showParent = Misc.promiseAnimate(barWrapperEl, { + opacity: 1, + duration: Misc.applyReducedMotion(125), + ease: "linear", + }); - const showList = Misc.promiseAnimation( - xpBreakdownListEl, - { - opacity: "1", - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(125), - "linear" - ); + const showList = Misc.promiseAnimate(xpBreakdownListEl, { + opacity: 1, + duration: Misc.applyReducedMotion(125), + ease: "linear", + }); if (breakdown !== undefined) { - breakdownList.removeClass("hidden"); + xpBreakdownListEl.classList.remove("hidden"); void Promise.all([showParent, showList]); } else { - breakdownList.addClass("hidden"); + xpBreakdownListEl.classList.add("hidden"); void showParent; } @@ -139,60 +138,28 @@ export async function update( if (skip) return; breakdownVisible = false; - levelEl.text(Levels.getLevelFromTotalXp(currentXp + addedXp)); - barWrapperEl - .stop(true, true) - .css("opacity", 1) - .animate( - { - opacity: 0, - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(250) - ); + levelEl.textContent = `${Levels.getLevelFromTotalXp(currentXp + addedXp)}`; + + animate(barWrapperEl, { + opacity: [1, 0], + duration: Misc.applyReducedMotion(250), + }); } async function flashTotalXp(totalXp: number, force = false): Promise { if (!force && skip) return; - xpBreakdownTotalEl.text(`+${totalXp}`); + xpBreakdownTotalEl.textContent = `+${totalXp}`; const rand = (Math.random() * 2 - 1) / 4; const rand2 = (Math.random() + 1) / 2; - /** - * `borderSpacing` has no visible effect on this element, - * and is used in the animation only to provide numerical - * values for the `step(step)` function. - */ - xpBreakdownTotalEl - .stop(true, true) - .css({ - transition: "initial", - borderSpacing: 100, - }) - .animate( - { - borderSpacing: 0, - }, - { - step(step) { - xpBreakdownTotalEl.css( - "transform", - `scale(${1 + (step / 200) * rand2}) rotate(${ - (step / 10) * rand - }deg)` - ); - }, - duration: Misc.applyReducedMotion(2000), - easing: "easeOutCubic", - complete: () => { - xpBreakdownTotalEl.css({ - backgroundColor: "", - transition: "", - }); - }, - } - ); + animate(xpBreakdownTotalEl, { + scale: [1 + 0.5 * rand2, 1], + rotate: [10 * rand, 0], + duration: Misc.applyReducedMotion(2000), + ease: "out(5)", + }); } async function addBreakdownListItem( @@ -203,11 +170,13 @@ async function addBreakdownListItem( if (skip) return; if (amount === undefined) { - xpBreakdownListEl.append( + xpBreakdownListEl.insertAdjacentHTML( + "beforeend", `
${string}
` ); } else if (typeof amount === "string") { - xpBreakdownListEl.append( + xpBreakdownListEl.insertAdjacentHTML( + "beforeend", `
${string}
@@ -217,29 +186,29 @@ async function addBreakdownListItem( } else { const positive = amount === undefined ? undefined : amount >= 0; - xpBreakdownListEl.append(` + xpBreakdownListEl.insertAdjacentHTML( + "beforeend", + `
${string}
${positive ? "+" : "-"}${Math.abs(amount)}
-
`); + options?.extraClass + }">${positive ? "+" : "-"}${Math.abs(amount)}
+
` + ); } if (options?.noAnimation) return; - const el = xpBreakdownListEl.find(`.line[data-string='${string}']`); + const el = xpBreakdownListEl.querySelector( + `.line[data-string='${string}']` + ) as HTMLElement; - el.css("opacity", 0); - await Misc.promiseAnimation( - el, - { - opacity: "1", - }, - Misc.applyReducedMotion(250), - "swing" - ); + await Misc.promiseAnimate(el, { + opacity: [0, 1], + duration: Misc.applyReducedMotion(250), + }); } async function animateXpBreakdown( @@ -248,20 +217,20 @@ async function animateXpBreakdown( ): Promise { if (skip) return; - xpBreakdownListEl.css("opacity", 1); + xpBreakdownListEl.style.opacity = "1"; if (!breakdown) { - xpBreakdownTotalEl.text(`+${addedXp}`); + xpBreakdownTotalEl.textContent = `+${addedXp}`; return; } const delay = Misc.applyReducedMotion(250); let total = 0; - xpBreakdownListEl.empty(); - xpBreakdownListEl.removeClass("hidden"); + xpBreakdownListEl.innerHTML = ""; + xpBreakdownListEl.classList.remove("hidden"); - xpBreakdownTotalEl.text("+0"); + xpBreakdownTotalEl.textContent = `+0`; total += breakdown.base ?? 0; - xpBreakdownTotalEl.text(`+${total}`); + xpBreakdownTotalEl.textContent = `+${total}`; await addBreakdownListItem("time typing", breakdown.base, { noAnimation: true, }); @@ -374,29 +343,27 @@ async function animateXpBar( const difference = endingLevel - startingLevel; - barEl.css("width", `${(startingLevel % 1) * 100}%`); + barEl.style.width = `${(startingLevel % 1) * 100}%`; if (endingLevel % 1 === 0) { - await Misc.promiseAnimation( - barEl, - { - width: "100%", - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(1000), - "easeOutExpo" - ); + //ending level is exactly round, meaning fill the bar to 100%, flash, set to 0 + await Misc.promiseAnimate(barEl, { + width: "100%", + duration: Misc.applyReducedMotion(1000), + ease: "out(5)", + }); if (skip) return; void flashLevel(); - barEl.css("width", `0%`); + barEl.style.width = `0%`; } else if (Math.floor(startingLevel) === Math.floor(endingLevel)) { - await Misc.promiseAnimation( - barEl, - { width: `${(endingLevel % 1) * 100}%` }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(1000), - "easeOutExpo" - ); + //ending level is the same, just animate the bar to the correct percentage + await Misc.promiseAnimate(barEl, { + width: `${(endingLevel % 1) * 100}%`, + duration: Misc.applyReducedMotion(1000), + ease: "out(5)", + }); } else { // const quickSpeed = Misc.mapRange(difference, 10, 2000, 200, 1); const quickSpeed = Math.min(1000 / difference, 200); @@ -404,29 +371,23 @@ async function animateXpBar( let firstOneDone = false; let animationDuration = quickSpeed; - let animationEasing: Misc.JQueryEasing = "linear"; let decrement = 1 - (startingLevel % 1); do { if (skip) return; - if (toAnimate - 1 < 1) { - animationDuration = mapRange(toAnimate - 1, 0, 0.5, 1000, 200); - animationEasing = "easeOutQuad"; - } if (firstOneDone) { void flashLevel(); - barEl.css("width", "0%"); + barEl.style.width = "0%"; decrement = 1; } - await Misc.promiseAnimation( - barEl, - { - width: "100%", - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(animationDuration), - animationEasing - ); + + await Misc.promiseAnimate(barEl, { + width: "100%", + duration: Misc.applyReducedMotion(animationDuration), + ease: "linear", + }); + toAnimate -= decrement; firstOneDone = true; } while (toAnimate > 1); @@ -434,18 +395,15 @@ async function animateXpBar( if (skip) return; void flashLevel(); - barEl.css("width", "0%"); + barEl.style.width = "0%"; if (skip) return; - await Misc.promiseAnimation( - barEl, - { - width: `${(toAnimate % 1) * 100}%`, - }, - SlowTimer.get() ? 0 : Misc.applyReducedMotion(1000), - "easeOutExpo" - ); + await Misc.promiseAnimate(barEl, { + width: `${(toAnimate % 1) * 100}%`, + duration: Misc.applyReducedMotion(1000), + ease: "out(5)", + }); } return; } @@ -453,7 +411,7 @@ async function animateXpBar( async function flashLevel(): Promise { const themecolors = await getAll(); - levelEl.text(parseInt(levelEl.text()) + 1); + levelEl.textContent = `${parseInt(levelEl.textContent ?? "0") + 1}`; const rand = Math.random() * 2 - 1; const rand2 = Math.random() + 1; @@ -463,36 +421,12 @@ async function flashLevel(): Promise { * and is used in the animation only to provide numerical * values for the `step(step)` function. */ - levelEl - .stop(true, true) - .css({ - backgroundColor: themecolors.main, - // transform: "scale(1.5) rotate(10deg)", - borderSpacing: 100, - transition: "initial", - }) - .animate( - { - backgroundColor: themecolors.sub, - borderSpacing: 0, - }, - { - step(step) { - levelEl.css( - "transform", - `scale(${1 + (step / 200) * rand2}) rotate(${ - (step / 10) * rand - }deg)` - ); - }, - duration: Misc.applyReducedMotion(2000), - easing: "easeOutCubic", - complete: () => { - levelEl.css({ - backgroundColor: "", - transition: "", - }); - }, - } - ); + + animate(levelEl, { + scale: [1 + 0.5 * rand2, 1], + backgroundColor: [themecolors.main, themecolors.sub], + rotate: [10 * rand, 0], + duration: Misc.applyReducedMotion(2000), + ease: "out(5)", + }); } diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 67d4fc521b22..ca12b70244d1 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -1,7 +1,3 @@ -// this file should be concatenated at the top of the legacy ts files -import "jquery-color"; -import "jquery.easing"; - import "./event-handlers/global"; import "./event-handlers/footer"; import "./event-handlers/keymap"; @@ -51,6 +47,7 @@ import * as Cookies from "./cookies"; import "./elements/psa"; import "./utils/url-handler"; import "./modals/last-signed-out-result"; +import { applyEngineSettings } from "./anim"; // Lock Math.random Object.defineProperty(Math, "random", { @@ -71,6 +68,7 @@ Object.defineProperty(window, "Math", { enumerable: true, }); +applyEngineSettings(); void loadFromLocalStorage(); void VersionButton.update(); Focus.set(true, true); diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index d8199ae929b2..402b4bec8ee6 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -133,8 +133,8 @@ function updateIntegrationSections(): void { function updateTabs(): void { void swapElements( - pageElement.find(".tab.active"), - pageElement.find(`.tab[data-tab="${state.tab}"]`), + pageElement.find(".tab.active")[0] as HTMLElement, + pageElement.find(`.tab[data-tab="${state.tab}"]`)[0] as HTMLElement, 250, async () => { // diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 82a880c86e4b..0f5f24cfb4be 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -1070,7 +1070,6 @@ $(".pageAccount #accountHistoryChart").on("click", () => { const index: number = ChartController.accountHistoryActiveIndex; loadMoreLines(index); if (window === undefined) return; - const windowHeight = $(window).height() ?? 0; const resultId = filteredResults[index]?._id; if (resultId === undefined) { @@ -1079,20 +1078,11 @@ $(".pageAccount #accountHistoryChart").on("click", () => { const element = $(`.resultRow[data-id="${resultId}"`); $(".resultRow").removeClass("active"); - const offset = element.offset()?.top ?? 0; - const scrollTo = offset - windowHeight / 2; - $([document.documentElement, document.body]) - .stop(true) - .animate( - { scrollTop: scrollTo }, - { - duration: Misc.applyReducedMotion(500), - done: () => { - $(".resultRow").removeClass("active"); - requestAnimationFrame(() => element.addClass("active")); - }, - } - ); + element[0]?.scrollIntoView({ + block: "center", + }); + + element.addClass("active"); }); $(".pageAccount").on("click", ".miniResultChartButton", async (event) => { diff --git a/frontend/src/ts/pages/leaderboards.ts b/frontend/src/ts/pages/leaderboards.ts index 2c489faf8d0a..282793bdb83f 100644 --- a/frontend/src/ts/pages/leaderboards.ts +++ b/frontend/src/ts/pages/leaderboards.ts @@ -27,7 +27,7 @@ import { differenceInSeconds } from "date-fns/differenceInSeconds"; import * as DateTime from "../utils/date-and-time"; import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller"; -import { applyReducedMotion, isDevEnvironment } from "../utils/misc"; +import { isDevEnvironment } from "../utils/misc"; import { abbreviateNumber } from "../utils/numbers"; import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; import { z } from "zod"; @@ -898,15 +898,9 @@ function updateContent(): void { } if (state.scrollToUserAfterFill) { - const windowHeight = $(window).height() ?? 0; - const offset = $(`.tableAndUser .me`).offset()?.top ?? 0; - const scrollTo = offset - windowHeight / 2; - $([document.documentElement, document.body]).animate( - { - scrollTop: scrollTo, - }, - applyReducedMotion(500) - ); + document.querySelector(".tableAndUser .me")?.scrollIntoView({ + block: "center", + }); state.scrollToUserAfterFill = false; } } diff --git a/frontend/src/ts/pages/loading.ts b/frontend/src/ts/pages/loading.ts index 18137583e9c8..2aa0b084ff04 100644 --- a/frontend/src/ts/pages/loading.ts +++ b/frontend/src/ts/pages/loading.ts @@ -1,5 +1,6 @@ import Page from "./page"; import * as Skeleton from "../utils/skeleton"; +import { promiseAnimate } from "../utils/misc"; const pageEl = $(".page.pageLoading"); const barEl = pageEl.find(".bar"); @@ -11,19 +12,9 @@ export async function updateBar( percentage: number, duration: number ): Promise { - return new Promise((resolve) => { - barEl - .find(".fill") - .stop(true, false) - .animate( - { - width: percentage + "%", - }, - duration, - () => { - resolve(); - } - ); + await promiseAnimate(barEl[0]?.querySelector(".fill") as HTMLElement, { + width: percentage + "%", + duration, }); } diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 63e5fa7b5c09..338dc063a732 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -42,6 +42,7 @@ import { Fonts } from "../constants/fonts"; import * as CustomBackgroundPicker from "../elements/settings/custom-background-picker"; import * as CustomFontPicker from "../elements/settings/custom-font-picker"; import * as AuthEvent from "../observables/auth-event"; +import * as FpsLimitSection from "../elements/settings/fps-limit-section"; let settingsInitialized = false; @@ -857,6 +858,7 @@ export async function update( await CustomBackgroundPicker.updateUI(); await updateFilterSectionVisibility(); await CustomFontPicker.updateUI(); + FpsLimitSection.update(); const setInputValue = ( key: ConfigKey, diff --git a/frontend/src/ts/popups/video-ad-popup.ts b/frontend/src/ts/popups/video-ad-popup.ts index 7745274246e8..138277db8549 100644 --- a/frontend/src/ts/popups/video-ad-popup.ts +++ b/frontend/src/ts/popups/video-ad-popup.ts @@ -4,6 +4,7 @@ import * as Notifications from "../elements/notifications"; import * as AdController from "../controllers/ad-controller"; import * as Skeleton from "../utils/skeleton"; import { isPopupVisible } from "../utils/misc"; +import { animate } from "animejs"; const wrapperId = "videoAdPopupWrapper"; @@ -34,32 +35,33 @@ export async function show(): Promise { } if (!isPopupVisible(wrapperId)) { - $("#videoAdPopupWrapper") - .stop(true, true) - .css("opacity", 0) - .removeClass("hidden") - .animate({ opacity: 1 }, 125, () => { + const el = document.querySelector("#videoAdPopupWrapper") as HTMLElement; + + animate(el, { + opacity: [0, 1], + duration: 125, + onBegin: () => { + el.classList.remove("hidden"); + }, + onComplete: () => { //@ts-expect-error 3rd party ad code window.dataLayer.push({ event: "EG_Video" }); - }); + }, + }); } } function hide(): void { if (isPopupVisible(wrapperId)) { - $("#videoAdPopupWrapper") - .stop(true, true) - .css("opacity", 1) - .animate( - { - opacity: 0, - }, - 125, - () => { - $("#videoAdPopupWrapper").addClass("hidden"); - Skeleton.remove(wrapperId); - } - ); + const el = document.querySelector("#videoAdPopupWrapper") as HTMLElement; + animate(el, { + opacity: [1, 0], + duration: 125, + onComplete: () => { + el.classList.add("hidden"); + Skeleton.remove(wrapperId); + }, + }); } } diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts index 6600e7ba041e..8655d03001bf 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/ready.ts @@ -9,6 +9,7 @@ import * as ServerConfiguration from "./ape/server-configuration"; import { getActiveFunboxesWithFunction } from "./test/funbox/list"; import { loadPromise } from "./config"; import { authPromise } from "./firebase"; +import { animate } from "animejs"; $(async (): Promise => { await loadPromise; @@ -23,11 +24,12 @@ $(async (): Promise => { fb.functions.applyGlobalCSS(); } - $("#app") - .css("opacity", "0") - .removeClass("hidden") - .stop(true, true) - .animate({ opacity: 1 }, Misc.applyReducedMotion(250)); + const app = document.querySelector("#app") as HTMLElement; + app?.classList.remove("hidden"); + animate(app, { + opacity: [0, 1], + duration: Misc.applyReducedMotion(250), + }); if (ConnectionState.get()) { void ServerConfiguration.sync().then(() => { if (!ServerConfiguration.get()?.users.signUp) { diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index 88ff3063927f..60164f8c8307 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -17,8 +17,8 @@ export function hide(): void { } export function resetPosition(): void { - caret.clearMargins(); caret.stopAllAnimations(); + caret.clearMargins(); caret.goTo({ wordIndex: 0, letterIndex: 0, diff --git a/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts b/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts index 95df4a5cf4ad..487a74a3d992 100644 --- a/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts +++ b/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts @@ -1,27 +1,27 @@ +import { animate } from "animejs"; import { capitalizeFirstLetter } from "../../utils/strings"; +import { applyReducedMotion } from "../../utils/misc"; + +const timerEl = document.querySelector( + "#typingTest #layoutfluidTimer" +) as HTMLElement; export function show(): void { - $("#typingTest #layoutfluidTimer").stop(true, true).animate( - { - opacity: 1, - }, - 125 - ); + animate(timerEl, { + opacity: 1, + duration: applyReducedMotion(125), + }); } export function hide(): void { - $("#typingTest #layoutfluidTimer").stop(true, true).animate( - { - opacity: 0, - }, - 125 - ); + animate(timerEl, { + opacity: 0, + duration: applyReducedMotion(125), + }); } export function updateTime(sec: number, layout: string): void { - $("#typingTest #layoutfluidTimer").text( - `${capitalizeFirstLetter(layout)} in: ${sec}s` - ); + timerEl.textContent = `${capitalizeFirstLetter(layout)} in: ${sec}s`; } export function updateWords(words: number, layout: string): void { @@ -30,5 +30,5 @@ export function updateWords(words: number, layout: string): void { if (words === 1) { str = `${layoutName} starting next word`; } - $("#typingTest #layoutfluidTimer").text(str); + timerEl.textContent = str; } diff --git a/frontend/src/ts/test/funbox/memory-funbox-timer.ts b/frontend/src/ts/test/funbox/memory-funbox-timer.ts index e09cdb1d2680..1ad28480a64d 100644 --- a/frontend/src/ts/test/funbox/memory-funbox-timer.ts +++ b/frontend/src/ts/test/funbox/memory-funbox-timer.ts @@ -1,22 +1,25 @@ +import { animate } from "animejs"; +import { applyReducedMotion } from "../../utils/misc"; + let memoryTimer: number | null = null; let memoryInterval: NodeJS.Timeout | null = null; +const timerEl = document.querySelector( + "#typingTest #memoryTimer" +) as HTMLElement; + export function show(): void { - $("#typingTest #memoryTimer").stop(true, true).animate( - { - opacity: 1, - }, - 125 - ); + animate(timerEl, { + opacity: 1, + duration: applyReducedMotion(125), + }); } export function hide(): void { - $("#typingTest #memoryTimer").stop(true, true).animate( - { - opacity: 0, - }, - 125 - ); + animate(timerEl, { + opacity: 0, + duration: applyReducedMotion(125), + }); } export function reset(): void { @@ -45,7 +48,5 @@ export function start(time: number): void { } export function update(sec: number): void { - $("#typingTest #memoryTimer").text( - `Timer left to memorise all words: ${sec}s` - ); + timerEl.textContent = `Timer left to memorise all words: ${sec}s`; } diff --git a/frontend/src/ts/test/live-acc.ts b/frontend/src/ts/test/live-acc.ts index 04329cf4098f..ac0df78df148 100644 --- a/frontend/src/ts/test/live-acc.ts +++ b/frontend/src/ts/test/live-acc.ts @@ -2,6 +2,7 @@ import Config from "../config"; import * as TestState from "../test/test-state"; import * as ConfigEvent from "../observables/config-event"; import { applyReducedMotion } from "../utils/misc"; +import { animate } from "animejs"; const textEl = document.querySelector( "#liveStatsTextBottom .liveAcc" @@ -29,47 +30,37 @@ export function show(): void { if (!TestState.isActive) return; if (state) return; if (Config.liveAccStyle === "mini") { - $(miniEl).stop(true, false).removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + miniEl.classList.remove("hidden"); + animate(miniEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); } else { - $(textEl).stop(true, false).removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + textEl.classList.remove("hidden"); + animate(textEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); } state = true; } export function hide(): void { if (!state) return; - $(textEl) - .stop(true, false) - .animate( - { - opacity: 0, - }, - applyReducedMotion(125), - () => { - $(textEl).addClass("hidden"); - } - ); - $(miniEl) - .stop(true, false) - .animate( - { - opacity: 0, - }, - applyReducedMotion(125), - () => { - $(miniEl).addClass("hidden"); - } - ); + animate(textEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + textEl.classList.add("hidden"); + }, + }); + animate(miniEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + miniEl.classList.add("hidden"); + }, + }); state = false; } diff --git a/frontend/src/ts/test/live-burst.ts b/frontend/src/ts/test/live-burst.ts index 165bdedd259f..1c0d79831087 100644 --- a/frontend/src/ts/test/live-burst.ts +++ b/frontend/src/ts/test/live-burst.ts @@ -3,6 +3,7 @@ import * as TestState from "../test/test-state"; import * as ConfigEvent from "../observables/config-event"; import Format from "../utils/format"; import { applyReducedMotion } from "../utils/misc"; +import { animate } from "animejs"; const textEl = document.querySelector( "#liveStatsTextBottom .liveBurst" @@ -27,47 +28,37 @@ export function show(): void { if (!TestState.isActive) return; if (state) return; if (Config.liveBurstStyle === "mini") { - $(miniEl).stop(true, false).removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + miniEl.classList.remove("hidden"); + animate(miniEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); } else { - $(textEl).stop(true, false).removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + textEl.classList.remove("hidden"); + animate(textEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); } state = true; } export function hide(): void { if (!state) return; - $(textEl) - .stop(true, false) - .animate( - { - opacity: 0, - }, - applyReducedMotion(125), - () => { - $(textEl).addClass("hidden"); - } - ); - $(miniEl) - .stop(true, false) - .animate( - { - opacity: 0, - }, - applyReducedMotion(125), - () => { - $(miniEl).addClass("hidden"); - } - ); + animate(textEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + textEl.classList.add("hidden"); + }, + }); + animate(miniEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + miniEl.classList.add("hidden"); + }, + }); state = false; } diff --git a/frontend/src/ts/test/live-speed.ts b/frontend/src/ts/test/live-speed.ts index 88b5f55fa531..415e7b5f51d6 100644 --- a/frontend/src/ts/test/live-speed.ts +++ b/frontend/src/ts/test/live-speed.ts @@ -3,6 +3,7 @@ import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; import Format from "../utils/format"; import { applyReducedMotion } from "../utils/misc"; +import { animate } from "animejs"; const textElement = document.querySelector( "#liveStatsTextBottom .liveSpeed" @@ -31,55 +32,37 @@ export function show(): void { if (!TestState.isActive) return; if (state) return; if (Config.liveSpeedStyle === "mini") { - $(miniElement) - .stop(true, false) - .removeClass("hidden") - .css("opacity", 0) - .animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + miniElement.classList.remove("hidden"); + animate(miniElement, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); } else { - $(textElement) - .stop(true, false) - .removeClass("hidden") - .css("opacity", 0) - .animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + textElement.classList.remove("hidden"); + animate(textElement, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); } state = true; } export function hide(): void { if (!state) return; - $(textElement) - .stop(true, false) - .animate( - { - opacity: 0, - }, - applyReducedMotion(125), - () => { - textElement.classList.add("hidden"); - } - ); - $(miniElement) - .stop(true, false) - .animate( - { - opacity: 0, - }, - applyReducedMotion(125), - () => { - miniElement.classList.add("hidden"); - } - ); + animate(miniElement, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + miniElement.classList.add("hidden"); + }, + }); + animate(textElement, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + textElement.classList.add("hidden"); + }, + }); state = false; } diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index 63597d3b6109..549cf0eb9c21 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -3,6 +3,7 @@ import Config from "../config"; import * as ConfigEvent from "../observables/config-event"; import * as TestState from "../test/test-state"; import * as KeyConverter from "../utils/key-converter"; +import { animate } from "animejs"; ConfigEvent.subscribe((eventKey) => { if (eventKey === "monkey" && TestState.isActive) { @@ -64,7 +65,10 @@ function update(): void { export function updateFastOpacity(num: number): void { if (!Config.monkey) return; const opacity = mapRange(num, 130, 180, 0, 1); - $("#monkey .fast").animate({ opacity: opacity }, 1000); + animate("#monkey .fast", { + opacity: opacity, + duration: 1000, + }); let animDuration = mapRange(num, 130, 180, 0.25, 0.01); if (animDuration === 0.25) animDuration = 0; $("#monkey").css({ animationDuration: animDuration + "s" }); @@ -137,18 +141,20 @@ export function stop(event: JQuery.KeyUpEvent): void { export function show(): void { if (!Config.monkey) return; - $("#monkey") - .css("opacity", 0) - .removeClass("hidden") - .animate({ opacity: 1 }, 125); + $("#monkey").removeClass("hidden"); + animate("#monkey", { + opacity: [0, 1], + duration: 125, + }); } export function hide(): void { - $("#monkey") - .css("opacity", 1) - .animate({ opacity: 1 }, 125, () => { - $("#monkey").addClass("hidden"); - $("#monkey .fast").stop(true, true).css("opacity", 0); - $("#monkey").stop(true, true).css({ animationDuration: "0s" }); - }); + animate("#monkey", { + opacity: [1, 0], + duration: 125, + onComplete: () => { + $("#monkey").addClass("hidden").css({ animationDuration: "0s" }); + $("#monkey .fast").css("opacity", 0); + }, + }); } diff --git a/frontend/src/ts/test/pb-crown.ts b/frontend/src/ts/test/pb-crown.ts index ece051767069..bb6462541262 100644 --- a/frontend/src/ts/test/pb-crown.ts +++ b/frontend/src/ts/test/pb-crown.ts @@ -1,3 +1,4 @@ +import { animate } from "animejs"; import { applyReducedMotion } from "../utils/misc"; export function hide(): void { @@ -22,14 +23,17 @@ export function getCurrentType(): CrownType { export function show(): void { if (visible) return; visible = true; - const el = $("#result .stats .wpm .crown"); - el.removeClass("hidden").css("opacity", "0").animate( - { - opacity: 1, + const el = document.querySelector( + "#result .stats .wpm .crown" + ) as HTMLElement; + + animate(el, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + el.classList.remove("hidden"); }, - applyReducedMotion(250), - "easeOutCubic" - ); + }); } export function update(type: CrownType): void { diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 52c5a9789b0e..b81dc6c75a3d 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1072,8 +1072,8 @@ export async function update( TestConfig.hide(); void Misc.swapElements( - $("#typingTest"), - $("#result"), + document.querySelector("#typingTest") as HTMLElement, + document.querySelector("#result") as HTMLElement, 250, async () => { const result = document.querySelector("#result"); @@ -1088,12 +1088,6 @@ export async function update( }, async () => { Focus.set(false); - $("#resultExtraButtons").removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, - }, - Misc.applyReducedMotion(125) - ); const canQuickRestart = canQuickRestartFn( Config.mode, diff --git a/frontend/src/ts/test/test-config.ts b/frontend/src/ts/test/test-config.ts index 97f291bd2533..73c2deef8b0a 100644 --- a/frontend/src/ts/test/test-config.ts +++ b/frontend/src/ts/test/test-config.ts @@ -3,9 +3,10 @@ import { Mode } from "@monkeytype/schemas/shared"; import Config from "../config"; import * as ConfigEvent from "../observables/config-event"; import * as ActivePage from "../states/active-page"; -import { applyReducedMotion } from "../utils/misc"; +import { applyReducedMotion, promiseAnimate } from "../utils/misc"; import { areUnsortedArraysEqual } from "../utils/arrays"; import * as AuthEvent from "../observables/auth-event"; +import { animate } from "animejs"; export function show(): void { $("#testConfig").removeClass("invisible"); @@ -24,7 +25,7 @@ export async function instantUpdate(): Promise { ); $("#testConfig .puncAndNum").addClass("hidden"); - $("#testConfig .spacer").css("transition", "none").addClass("scrolled"); + $("#testConfig .spacer").addClass("hidden"); $("#testConfig .time").addClass("hidden"); $("#testConfig .wordCount").addClass("hidden"); $("#testConfig .customText").addClass("hidden"); @@ -36,8 +37,8 @@ export async function instantUpdate(): Promise { width: "", opacity: "", }); - $("#testConfig .leftSpacer").removeClass("scrolled"); - $("#testConfig .rightSpacer").removeClass("scrolled"); + $("#testConfig .leftSpacer").removeClass("hidden"); + $("#testConfig .rightSpacer").removeClass("hidden"); $("#testConfig .time").removeClass("hidden"); updateActiveExtraButtons("time", Config.time); @@ -46,13 +47,13 @@ export async function instantUpdate(): Promise { width: "", opacity: "", }); - $("#testConfig .leftSpacer").removeClass("scrolled"); - $("#testConfig .rightSpacer").removeClass("scrolled"); + $("#testConfig .leftSpacer").removeClass("hidden"); + $("#testConfig .rightSpacer").removeClass("hidden"); $("#testConfig .wordCount").removeClass("hidden"); updateActiveExtraButtons("words", Config.words); } else if (Config.mode === "quote") { - $("#testConfig .rightSpacer").removeClass("scrolled"); + $("#testConfig .rightSpacer").removeClass("hidden"); $("#testConfig .quoteLength").removeClass("hidden"); updateActiveExtraButtons("quoteLength", Config.quoteLength); @@ -61,18 +62,14 @@ export async function instantUpdate(): Promise { width: "", opacity: "", }); - $("#testConfig .leftSpacer").removeClass("scrolled"); - $("#testConfig .rightSpacer").removeClass("scrolled"); + $("#testConfig .leftSpacer").removeClass("hidden"); + $("#testConfig .rightSpacer").removeClass("hidden"); $("#testConfig .customText").removeClass("hidden"); } updateActiveExtraButtons("quoteLength", Config.quoteLength); updateActiveExtraButtons("numbers", Config.numbers); updateActiveExtraButtons("punctuation", Config.punctuation); - - setTimeout(() => { - $("#testConfig .spacer").css("transition", ""); - }, 125); } async function update(previous: Mode, current: Mode): Promise { @@ -100,10 +97,12 @@ async function update(previous: Mode, current: Mode): Promise { }; const animTime = applyReducedMotion(250); + + const scale = 2; const easing = { - both: "easeInOutSine", - in: "easeInSine", - out: "easeOutSine", + both: `inOut(${scale})`, + in: `in(${scale})`, + out: `out(${scale})`, }; const puncAndNumVisible = { @@ -117,12 +116,6 @@ async function update(previous: Mode, current: Mode): Promise { const puncAndNumEl = $("#testConfig .puncAndNum"); if (puncAndNumVisible[current] !== puncAndNumVisible[previous]) { - if (!puncAndNumVisible[current]) { - $("#testConfig .leftSpacer").addClass("scrolled"); - } else { - $("#testConfig .leftSpacer").removeClass("scrolled"); - } - puncAndNumEl .css({ width: "unset", @@ -134,35 +127,88 @@ async function update(previous: Mode, current: Mode): Promise { puncAndNumEl[0]?.getBoundingClientRect().width ?? 0 ); - puncAndNumEl - .stop(true, false) - .css({ - width: puncAndNumVisible[previous] ? width : 0, - opacity: puncAndNumVisible[previous] ? 1 : 0, - }) - .animate( - { - width: puncAndNumVisible[current] ? width : 0, - opacity: puncAndNumVisible[current] ? 1 : 0, - }, - animTime, - easing.both, - () => { - if (puncAndNumVisible[current]) { - puncAndNumEl.css("width", "unset"); - } else { - puncAndNumEl.addClass("hidden"); - } + animate(puncAndNumEl[0] as HTMLElement, { + width: [ + (puncAndNumVisible[previous] ? width : 0) + "px", + (puncAndNumVisible[current] ? width : 0) + "px", + ], + opacity: { + duration: animTime / 2, + delay: puncAndNumVisible[current] ? animTime / 2 : 0, + from: puncAndNumVisible[previous] ? 1 : 0, + to: puncAndNumVisible[current] ? 1 : 0, + }, + duration: animTime, + ease: easing.both, + onComplete: () => { + if (puncAndNumVisible[current]) { + puncAndNumEl.css("width", "unset"); + } else { + puncAndNumEl.addClass("hidden"); } - ); - } + }, + }); - if (current === "zen") { - $("#testConfig .rightSpacer").addClass("scrolled"); - } else { - $("#testConfig .rightSpacer").removeClass("scrolled"); + const leftSpacerEl = document.querySelector( + "#testConfig .leftSpacer" + ) as HTMLElement; + + leftSpacerEl.style.width = "0.5em"; + leftSpacerEl.style.opacity = "1"; + leftSpacerEl.classList.remove("hidden"); + + animate(leftSpacerEl, { + width: [ + puncAndNumVisible[previous] ? "0.5em" : 0, + puncAndNumVisible[current] ? "0.5em" : 0, + ], + // opacity: { + // duration: animTime / 2, + // // delay: puncAndNumVisible[current] ? animTime / 2 : 0, + // from: puncAndNumVisible[previous] ? 1 : 0, + // to: puncAndNumVisible[current] ? 1 : 0, + // }, + duration: animTime, + ease: easing.both, + onComplete: () => { + if (puncAndNumVisible[current]) { + leftSpacerEl.style.width = ""; + } else { + leftSpacerEl.classList.add("hidden"); + } + }, + }); } + const rightSpacerEl = document.querySelector( + "#testConfig .rightSpacer" + ) as HTMLElement; + + rightSpacerEl.style.width = "0.5em"; + rightSpacerEl.style.opacity = "1"; + rightSpacerEl.classList.remove("hidden"); + + animate(rightSpacerEl, { + width: [ + previous === "zen" ? "0px" : "0.5em", + current === "zen" ? "0px" : "0.5em", + ], + // opacity: { + // duration: animTime / 2, + // from: previous === "zen" ? 0 : 1, + // to: current === "zen" ? 0 : 1, + // }, + duration: animTime, + ease: easing.both, + onComplete: () => { + if (current === "zen") { + rightSpacerEl.classList.add("hidden"); + } else { + rightSpacerEl.style.width = ""; + } + }, + }); + const currentEl = $(`#testConfig .${submenu[current]}`); const previousEl = $(`#testConfig .${submenu[previous]}`); @@ -171,7 +217,6 @@ async function update(previous: Mode, current: Mode): Promise { ); previousEl.addClass("hidden"); - currentEl.removeClass("hidden"); const currentWidth = Math.round( @@ -179,53 +224,37 @@ async function update(previous: Mode, current: Mode): Promise { ); previousEl.removeClass("hidden"); - currentEl.addClass("hidden"); const widthDifference = currentWidth - previousWidth; - const widthStep = widthDifference / 2; + await promiseAnimate(previousEl[0] as HTMLElement, { + opacity: [1, 0], + width: [previousWidth + "px", previousWidth + widthStep + "px"], + duration: animTime / 2, + ease: easing.in, + }); + previousEl - .stop(true, false) .css({ opacity: 1, - width: previousWidth, + width: "unset", }) - .animate( - { - width: previousWidth + widthStep, - opacity: 0, - }, - animTime / 2, - easing.in, - () => { - previousEl - .css({ - opacity: 1, - width: "unset", - }) - .addClass("hidden"); - currentEl - .css({ - opacity: 0, - width: previousWidth + widthStep, - }) - .removeClass("hidden") - .stop(true, false) - .animate( - { - opacity: 1, - width: currentWidth, - }, - animTime / 2, - easing.out, - () => { - currentEl.css("width", "unset"); - } - ); - } - ); + .addClass("hidden"); + currentEl + .css({ + opacity: 0, + width: previousWidth + widthStep + "px", + }) + .removeClass("hidden"); + + await promiseAnimate(currentEl[0] as HTMLElement, { + opacity: [0, 1], + width: [previousWidth + widthStep + "px", currentWidth + "px"], + duration: animTime / 2, + ease: easing.out, + }); } function updateActiveModeButtons(mode: Mode): void { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index d92e2af0d3cb..e5ce842d5af0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -82,6 +82,7 @@ import * as Sentry from "../sentry"; import * as Loader from "../elements/loader"; import * as TestInitFailed from "../elements/test-init-failed"; import { canQuickRestart } from "../utils/quick-restart"; +import { animate } from "animejs"; let failReason = ""; const koInputVisual = document.getElementById("koInputVisual") as HTMLElement; @@ -307,22 +308,21 @@ export function restart(options = {} as RestartOptions): void { ConnectionState.showOfflineBanner(); } - let el = null; + let el: HTMLElement; if (TestState.resultVisible) { //results are being displayed - el = $("#result"); + el = document.querySelector("#result") as HTMLElement; } else { //words are being displayed - el = $("#typingTest"); + el = document.querySelector("#typingTest") as HTMLElement; } TestState.setResultVisible(false); TestState.setTestRestarting(true); - el.stop(true, true).animate( - { - opacity: 0, - }, - animationTime, - async () => { + + animate(el, { + opacity: 0, + duration: animationTime, + onComplete: async () => { $("#result").addClass("hidden"); $("#typingTest").css("opacity", 0).removeClass("hidden"); $("#wordsInput").css({ left: 0 }).val(" "); @@ -382,27 +382,26 @@ export function restart(options = {} as RestartOptions): void { if (isWordsFocused) OutOfFocus.hide(); TestUI.focusWords(true); - $("#typingTest") - .css("opacity", 0) - .removeClass("hidden") - .stop(true, true) - .animate( - { - opacity: 1, - }, - animationTime, - () => { - TimerProgress.reset(); - LiveSpeed.reset(); - LiveAcc.reset(); - LiveBurst.reset(); - TestUI.updatePremid(); - ManualRestart.reset(); - TestState.setTestRestarting(false); - } - ); - } - ); + const typingTestEl = document.querySelector("#typingTest") as HTMLElement; + + animate(typingTestEl, { + opacity: [0, 1], + onBegin: () => { + typingTestEl.classList.remove("hidden"); + }, + duration: animationTime, + onComplete: () => { + TimerProgress.reset(); + LiveSpeed.reset(); + LiveAcc.reset(); + LiveBurst.reset(); + TestUI.updatePremid(); + ManualRestart.reset(); + TestState.setTestRestarting(false); + }, + }); + }, + }); ResultWordHighlight.destroy(); } @@ -1396,22 +1395,21 @@ async function saveResult( Result.showErrorCrownIfNeeded(); } + const dailyLeaderboardEl = document.querySelector( + "#result .stats .dailyLeaderboard" + ) as HTMLElement; + if (data.dailyLeaderboardRank === undefined) { - $("#result .stats .dailyLeaderboard").addClass("hidden"); + dailyLeaderboardEl.classList.add("hidden"); } else { - $("#result .stats .dailyLeaderboard") - .css({ - maxWidth: "13rem", - opacity: 0, - }) - .removeClass("hidden") - .animate( - { - // maxWidth: "10rem", - opacity: 1, - }, - Misc.applyReducedMotion(500) - ); + dailyLeaderboardEl.classList.remove("hidden"); + dailyLeaderboardEl.style.maxWidth = "13rem"; + + animate(dailyLeaderboardEl, { + opacity: [0, 1], + duration: Misc.applyReducedMotion(250), + }); + $("#result .stats .dailyLeaderboard .bottom").html( Format.rank(data.dailyLeaderboardRank, { fallback: "" }) ); diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 60dbd97ae021..56d0004f9243 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -18,6 +18,7 @@ import * as TimerEvent from "../observables/timer-event"; import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; import { KeymapLayout, Layout } from "@monkeytype/schemas/configs"; import * as SoundController from "../controllers/sound-controller"; +import { clearLowFpsMode, setLowFpsMode } from "../anim"; type TimerStats = { dateNow: number; @@ -37,6 +38,7 @@ export function enableTimerDebug(): void { } export function clear(): void { + clearLowFpsMode(); Time.set(0); if (timer !== null) clearTimeout(timer); } @@ -239,6 +241,7 @@ export async function start(): Promise { if (delay < interval / 2) { //slow timer SlowTimer.set(); + setLowFpsMode(); } if (delay < interval / 10) { slowTimerCount++; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 226d62f2676e..6667e667ad5c 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -11,7 +11,6 @@ import * as Strings from "../utils/strings"; import * as JSONData from "../utils/json-data"; import { blendTwoHexColors } from "../utils/colors"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; -import * as SlowTimer from "../states/slow-timer"; import * as CompositionState from "../states/composition"; import * as ConfigEvent from "../observables/config-event"; import * as Hangul from "hangul-js"; @@ -25,6 +24,7 @@ import { findSingleActiveFunboxWithFunction } from "./funbox/list"; import * as TestState from "./test-state"; import * as PaceCaret from "./pace-caret"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; +import { animate } from "animejs"; const debouncedZipfCheck = debounce(250, async () => { const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); @@ -1053,32 +1053,32 @@ export async function scrollTape(noAnimation = false): Promise { newMargin = wordRightMargin - newMargin; } - const duration = noAnimation ? 0 : SlowTimer.get() ? 0 : 125; + const duration = noAnimation ? 0 : 125; + const ease = "inOut(1.25)"; + const caretScrollOptions = { newValue: newMarginOffset * -1, duration: Config.smoothLineScroll ? duration : 0, + ease, }; Caret.caret.handleTapeScroll(caretScrollOptions); PaceCaret.caret.handleTapeScroll(caretScrollOptions); if (Config.smoothLineScroll) { - const jqWords = $(wordsEl).stop("marginLeft", true, false); - jqWords.animate( - { - marginLeft: newMargin, - }, - { - duration, - queue: "marginLeft", - } - ); - jqWords.dequeue("marginLeft"); + animate(wordsEl, { + marginLeft: newMargin, + duration, + ease, + }); + for (let i = 0; i < afterNewlinesNewMargins.length; i++) { const newMargin = afterNewlinesNewMargins[i] ?? 0; - $(afterNewLineEls[i] as Element) - .stop(true, false) - .animate({ marginLeft: newMargin }, duration); + animate(afterNewLineEls[i] as Element, { + marginLeft: newMargin, + duration, + ease, + }); } } else { wordsEl.style.marginLeft = `${newMargin}px`; @@ -1166,7 +1166,7 @@ export async function lineJump( const wordHeight = $(activeWordEl).outerHeight(true) as number; const newMarginTop = -1 * wordHeight * currentLinesJumping; - const duration = SlowTimer.get() ? 0 : 125; + const duration = 125; const caretLineJumpOptions = { newMarginTop, @@ -1177,23 +1177,18 @@ export async function lineJump( if (Config.smoothLineScroll) { lineTransition = true; - const jqWords = $(wordsEl); - jqWords.stop("marginTop", true, false).animate( - { marginTop: `${newMarginTop}px` }, - { - duration, - queue: "marginTop", - complete: () => { - currentLinesJumping = 0; - activeWordTop = activeWordEl.offsetTop; - removeTestElements(lastElementIndexToRemove); - wordsEl.style.marginTop = "0"; - lineTransition = false; - resolve(); - }, - } - ); - jqWords.dequeue("marginTop"); + animate(wordsEl, { + marginTop: newMarginTop, + duration, + onComplete: () => { + currentLinesJumping = 0; + activeWordTop = activeWordEl.offsetTop; + removeTestElements(lastElementIndexToRemove); + wordsEl.style.marginTop = "0"; + lineTransition = false; + resolve(); + }, + }); } else { currentLinesJumping = 0; removeTestElements(lastElementIndexToRemove); diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index f3d33cf8345d..ffba8b5572b0 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -4,42 +4,46 @@ import * as DateTime from "../utils/date-and-time"; import * as TestWords from "./test-words"; import * as TestInput from "./test-input"; import * as Time from "../states/time"; -import * as SlowTimer from "../states/slow-timer"; import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; import { applyReducedMotion } from "../utils/misc"; +import { animate } from "animejs"; -const barEl = $("#barTimerProgress .bar"); -const barOpacityEl = $("#barTimerProgress .opacityWrapper"); -const textEl = $("#liveStatsTextTop .timerProgress"); -const miniEl = $("#liveStatsMini .time"); +const barEl = document.querySelector("#barTimerProgress .bar") as HTMLElement; +const barOpacityEl = document.querySelector( + "#barTimerProgress .opacityWrapper" +) as HTMLElement; +const textEl = document.querySelector( + "#liveStatsTextTop .timerProgress" +) as HTMLElement; +const miniEl = document.querySelector("#liveStatsMini .time") as HTMLElement; export function show(): void { + if (!TestState.isActive) return; if (Config.mode !== "zen" && Config.timerStyle === "bar") { - barOpacityEl - .stop(true, true) - .removeClass("hidden") - .css("opacity", 0) - .animate( - { - opacity: 1, - }, - applyReducedMotion(125) - ); + animate(barOpacityEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + barOpacityEl.classList.remove("hidden"); + }, + }); } else if (Config.timerStyle === "text") { - textEl.stop(true, true).removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, + animate(textEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + textEl.classList.remove("hidden"); }, - applyReducedMotion(125) - ); + }); } else if (Config.mode === "zen" || Config.timerStyle === "mini") { - miniEl.stop(true, true).removeClass("hidden").css("opacity", 0).animate( - { - opacity: 1, + animate(miniEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + miniEl.classList.remove("hidden"); }, - applyReducedMotion(125) - ); + }); } } @@ -51,43 +55,38 @@ export function reset(): void { ) { width = "100vw"; } - barEl.stop(true, true).animate( - { - width, - }, - 0 - ); - miniEl.text("0"); - textEl.text("0"); + + animate(barEl, { + width, + duration: 0, + }); + miniEl.textContent = "0"; + textEl.textContent = "0"; } export function hide(): void { - barOpacityEl.stop(true, true).animate( - { - opacity: 0, - }, - applyReducedMotion(125) - ); - miniEl.stop(true, true).animate( - { - opacity: 0, + animate(barOpacityEl, { + opacity: 0, + duration: applyReducedMotion(125), + }); + + animate(miniEl, { + opacity: 0, + duration: applyReducedMotion(125), + onComplete: () => { + miniEl.classList.add("hidden"); }, - applyReducedMotion(125), - () => { - miniEl.addClass("hidden"); - } - ); - textEl.stop(true, true).animate( - { - opacity: 0, + }); + + animate(textEl, { + opacity: 0, + duration: applyReducedMotion(125), + onComplete: () => { + textEl.classList.add("hidden"); }, - applyReducedMotion(125) - ); + }); } -const timerNumberElement = textEl[0] as HTMLElement; -const miniTimerNumberElement = miniEl[0] as HTMLElement; - function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { return ( @@ -111,28 +110,27 @@ export function update(): void { } if (Config.timerStyle === "bar") { const percent = 100 - ((time + 1) / maxtime) * 100; - barEl.stop(true, true).animate( - { - width: percent + "vw", - }, - SlowTimer.get() ? 0 : 1000, - "linear" - ); + + animate(barEl, { + width: percent + "vw", + duration: 1000, + ease: "linear", + }); } else if (Config.timerStyle === "text") { let displayTime = DateTime.secondsToString(maxtime - time); if (maxtime === 0) { displayTime = DateTime.secondsToString(time); } - if (timerNumberElement !== null) { - timerNumberElement.innerHTML = "
" + displayTime + "
"; + if (textEl !== null) { + textEl.innerHTML = "
" + displayTime + "
"; } } else if (Config.timerStyle === "mini") { let displayTime = DateTime.secondsToString(maxtime - time); if (maxtime === 0) { displayTime = DateTime.secondsToString(time); } - if (miniTimerNumberElement !== null) { - miniTimerNumberElement.innerHTML = displayTime; + if (miniEl !== null) { + miniEl.innerHTML = displayTime; } } } else if ( @@ -154,50 +152,29 @@ export function update(): void { const percent = Math.floor( ((TestState.activeWordIndex + 1) / outof) * 100 ); - barEl.stop(true, true).animate( - { - width: percent + "vw", - }, - SlowTimer.get() ? 0 : 250 - ); + + animate(barEl, { + width: percent + "vw", + duration: 250, + }); } else if (Config.timerStyle === "text") { if (outof === 0) { - if (timerNumberElement !== null) { - timerNumberElement.innerHTML = `
${ - TestInput.input.getHistory().length - }
`; - } + textEl.innerHTML = `
${TestInput.input.getHistory().length}
`; } else { - if (timerNumberElement !== null) { - timerNumberElement.innerHTML = `
${getCurrentCount()}/${outof}
`; - } + textEl.innerHTML = `
${getCurrentCount()}/${outof}
`; } } else if (Config.timerStyle === "mini") { if (outof === 0) { - if (miniTimerNumberElement !== null) { - miniTimerNumberElement.innerHTML = `${ - TestInput.input.getHistory().length - }`; - } + miniEl.innerHTML = `${TestInput.input.getHistory().length}`; } else { - if (miniTimerNumberElement !== null) { - miniTimerNumberElement.innerHTML = `${getCurrentCount()}/${outof}`; - } + miniEl.innerHTML = `${getCurrentCount()}/${outof}`; } } } else if (Config.mode === "zen") { if (Config.timerStyle === "text") { - if (timerNumberElement !== null) { - timerNumberElement.innerHTML = `
${ - TestInput.input.getHistory().length - }
`; - } + textEl.innerHTML = `
${TestInput.input.getHistory().length}
`; } else { - if (miniTimerNumberElement !== null) { - miniTimerNumberElement.innerHTML = `${ - TestInput.input.getHistory().length - }`; - } + miniEl.innerHTML = `${TestInput.input.getHistory().length}`; } } } diff --git a/frontend/src/ts/utils/animated-modal.ts b/frontend/src/ts/utils/animated-modal.ts index 45b374f8ff51..d3ca0fd0fe80 100644 --- a/frontend/src/ts/utils/animated-modal.ts +++ b/frontend/src/ts/utils/animated-modal.ts @@ -1,16 +1,14 @@ +import { animate, AnimationParams } from "animejs"; import { applyReducedMotion, isPopupVisible } from "./misc"; import * as Skeleton from "./skeleton"; -type CustomAnimation = { - from: Record; - to: Record; - easing?: string; - durationMs?: number; -}; - type CustomWrapperAndModalAnimations = { - wrapper?: CustomAnimation; - modal?: CustomAnimation; + wrapper?: AnimationParams & { + duration?: number; + }; + modal?: AnimationParams & { + duration?: number; + }; }; type ConstructorCustomAnimations = { @@ -217,9 +215,9 @@ export default class AnimatedModal< } const modalAnimationDuration = applyReducedMotion( - (options?.customAnimation?.modal?.durationMs ?? + (options?.customAnimation?.modal?.duration ?? options?.animationDurationMs ?? - this.customShowAnimations?.modal?.durationMs ?? + this.customShowAnimations?.modal?.duration ?? DEFAULT_ANIMATION_DURATION) * (options?.modalChain !== undefined ? MODAL_ONLY_ANIMATION_MULTIPLIER @@ -250,17 +248,18 @@ export default class AnimatedModal< this.focusFirstInput(options?.focusFirstInput); }, 1); - const modalAnimation = - options?.customAnimation?.modal ?? this.customShowAnimations?.modal; + const modalAnimation = options?.customAnimation?.modal ?? + this.customShowAnimations?.modal ?? { + opacity: [0, 1], + marginTop: ["1rem", 0], + }; const wrapperAnimation = options?.customAnimation?.wrapper ?? this.customShowAnimations?.wrapper ?? { - from: { opacity: "0" }, - to: { opacity: "1" }, - easing: "swing", + opacity: [0, 1], }; const wrapperAnimationDuration = applyReducedMotion( - options?.customAnimation?.wrapper?.durationMs ?? - this.customShowAnimations?.wrapper?.durationMs ?? + options?.customAnimation?.wrapper?.duration ?? + this.customShowAnimations?.wrapper?.duration ?? DEFAULT_ANIMATION_DURATION ); @@ -269,59 +268,42 @@ export default class AnimatedModal< ? "modalOnly" : options?.animationMode ?? "both"; - $(this.modalEl).stop(true, false); - $(this.wrapperEl).stop(true, false); - if (animationMode === "both" || animationMode === "none") { - if (modalAnimation?.from) { - $(this.modalEl).css(modalAnimation.from); - $(this.modalEl).animate( - modalAnimation.to, - animationMode === "none" ? 0 : modalAnimationDuration, - modalAnimation.easing ?? "swing" - ); - } else { - $(this.modalEl).css("opacity", "1"); - } + animate(this.modalEl, { + ...modalAnimation, + duration: animationMode === "none" ? 0 : modalAnimationDuration, + }); - $(this.wrapperEl).css(wrapperAnimation.from); - $(this.wrapperEl) - .removeClass("hidden") - .css("opacity", "0") - .animate( - wrapperAnimation.to ?? { opacity: 1 }, - animationMode === "none" ? 0 : wrapperAnimationDuration, - wrapperAnimation.easing ?? "swing", - async () => { - this.focusFirstInput(options?.focusFirstInput); - await options?.afterAnimation?.( - this.modalEl, - options?.modalChainData - ); - resolve(); - } - ); + animate(this.wrapperEl, { + ...wrapperAnimation, + duration: animationMode === "none" ? 0 : wrapperAnimationDuration, + onBegin: () => { + this.wrapperEl.classList.remove("hidden"); + }, + onComplete: async () => { + this.focusFirstInput(options?.focusFirstInput); + await options?.afterAnimation?.( + this.modalEl, + options?.modalChainData + ); + resolve(); + }, + }); } else if (animationMode === "modalOnly") { $(this.wrapperEl).removeClass("hidden").css("opacity", "1"); - if (modalAnimation?.from) { - $(this.modalEl).css(modalAnimation.from); - } else { - $(this.modalEl).css("opacity", "0"); - } - $(this.modalEl).animate( - modalAnimation?.to ?? { opacity: 1 }, - modalAnimationDuration, - modalAnimation?.easing ?? "swing", - async () => { + animate(this.modalEl, { + ...modalAnimation, + duration: modalAnimationDuration, + onComplete: async () => { this.focusFirstInput(options?.focusFirstInput); await options?.afterAnimation?.( this.modalEl, options?.modalChainData ); resolve(); - } - ); + }, + }); } }); } @@ -340,12 +322,15 @@ export default class AnimatedModal< await options?.beforeAnimation?.(this.modalEl); - const modalAnimation = - options?.customAnimation?.modal ?? this.customHideAnimations?.modal; + const modalAnimation = options?.customAnimation?.modal ?? + this.customHideAnimations?.modal ?? { + opacity: [1, 0], + marginTop: [0, "1rem"], + }; const modalAnimationDuration = applyReducedMotion( - (options?.customAnimation?.modal?.durationMs ?? + (options?.customAnimation?.modal?.duration ?? options?.animationDurationMs ?? - this.customHideAnimations?.modal?.durationMs ?? + this.customHideAnimations?.modal?.duration ?? DEFAULT_ANIMATION_DURATION) * (this.previousModalInChain !== undefined ? MODAL_ONLY_ANIMATION_MULTIPLIER @@ -353,13 +338,11 @@ export default class AnimatedModal< ); const wrapperAnimation = options?.customAnimation?.wrapper ?? this.customHideAnimations?.wrapper ?? { - from: { opacity: "1" }, - to: { opacity: "0" }, - easing: "swing", + opacity: [1, 0], }; const wrapperAnimationDuration = applyReducedMotion( - options?.customAnimation?.wrapper?.durationMs ?? - this.customHideAnimations?.wrapper?.durationMs ?? + options?.customAnimation?.wrapper?.duration ?? + this.customHideAnimations?.wrapper?.duration ?? DEFAULT_ANIMATION_DURATION ); const animationMode = @@ -367,66 +350,47 @@ export default class AnimatedModal< ? "modalOnly" : options?.animationMode ?? "both"; - $(this.modalEl).stop(true, false); - $(this.wrapperEl).stop(true, false); - if (animationMode === "both" || animationMode === "none") { - if (modalAnimation?.from) { - $(this.modalEl).css(modalAnimation.from); - $(this.modalEl).animate( - modalAnimation.to, - animationMode === "none" ? 0 : modalAnimationDuration, - modalAnimation.easing ?? "swing" - ); - } else { - $(this.modalEl).css("opacity", "1"); - } + animate(this.modalEl, { + ...modalAnimation, + duration: animationMode === "none" ? 0 : modalAnimationDuration, + }); - $(this.wrapperEl).css(wrapperAnimation.from); - $(this.wrapperEl) - .css("opacity", "1") - .animate( - wrapperAnimation?.to ?? { opacity: 0 }, - animationMode === "none" ? 0 : wrapperAnimationDuration, - wrapperAnimation?.easing ?? "swing", - async () => { - this.wrapperEl.close(); - this.wrapperEl.classList.add("hidden"); - Skeleton.remove(this.dialogId); - this.open = false; - await options?.afterAnimation?.(this.modalEl); - void this.cleanup?.(); - - if ( - this.previousModalInChain !== undefined && - !options?.dontShowPreviousModalInchain - ) { - await this.previousModalInChain.show({ - animationMode: "modalOnly", - modalChainData: options?.modalChainData, - animationDurationMs: - modalAnimationDuration * MODAL_ONLY_ANIMATION_MULTIPLIER, - ...this.previousModalInChain.showOptionsWhenInChain, - }); - this.previousModalInChain = undefined; - } - - resolve(); + animate(this.wrapperEl, { + ...wrapperAnimation, + duration: animationMode === "none" ? 0 : wrapperAnimationDuration, + onComplete: async () => { + this.wrapperEl.close(); + this.wrapperEl.classList.add("hidden"); + Skeleton.remove(this.dialogId); + this.open = false; + await options?.afterAnimation?.(this.modalEl); + void this.cleanup?.(); + + if ( + this.previousModalInChain !== undefined && + !options?.dontShowPreviousModalInchain + ) { + await this.previousModalInChain.show({ + animationMode: "modalOnly", + modalChainData: options?.modalChainData, + animationDurationMs: + modalAnimationDuration * MODAL_ONLY_ANIMATION_MULTIPLIER, + ...this.previousModalInChain.showOptionsWhenInChain, + }); + this.previousModalInChain = undefined; } - ); + + resolve(); + }, + }); } else if (animationMode === "modalOnly") { $(this.wrapperEl).removeClass("hidden").css("opacity", "1"); - if (modalAnimation?.from) { - $(this.modalEl).css(modalAnimation.from); - } else { - $(this.modalEl).css("opacity", "1"); - } - $(this.modalEl).animate( - modalAnimation?.to ?? { opacity: 0 }, - modalAnimationDuration, - modalAnimation?.easing ?? "swing", - async () => { + animate(this.modalEl, { + ...modalAnimation, + duration: modalAnimationDuration, + onComplete: async () => { this.wrapperEl.close(); $(this.wrapperEl).addClass("hidden").css("opacity", "0"); Skeleton.remove(this.dialogId); @@ -449,8 +413,8 @@ export default class AnimatedModal< } resolve(); - } - ); + }, + }); } }); } diff --git a/frontend/src/ts/utils/caret.ts b/frontend/src/ts/utils/caret.ts index 7dd3e0c113d3..d8c7b35d6661 100644 --- a/frontend/src/ts/utils/caret.ts +++ b/frontend/src/ts/utils/caret.ts @@ -1,10 +1,10 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import Config from "../config"; -import * as SlowTimer from "../states/slow-timer"; import * as TestWords from "../test/test-words"; import { getTotalInlineMargin } from "./misc"; import { isWordRightToLeft } from "./strings"; import { requestDebouncedAnimationFrame } from "./debounced-animation-frame"; +import { animate, EasingParam, JSAnimation } from "animejs"; const wordsCache = document.querySelector("#words") as HTMLElement; const wordsWrapperCache = document.querySelector( @@ -38,6 +38,10 @@ export class Caret { private isMainCaret: boolean = false; private cumulativeTapeMarginCorrection: number = 0; + private posAnimation: JSAnimation | null = null; + private marginTopAnimation: JSAnimation | null = null; + private marginLeftAnimation: JSAnimation | null = null; + constructor(element: HTMLElement, style: CaretStyle) { this.id = element.id; this.element = element; @@ -102,7 +106,7 @@ export class Caret { top: number; width?: number; }): void { - $(this.element).stop("pos", true, false); + this.posAnimation?.cancel(); this.element.style.left = `${options.left}px`; this.element.style.top = `${options.top}px`; if (options.width !== undefined) { @@ -111,7 +115,7 @@ export class Caret { } public startBlinking(): void { - if (Config.smoothCaret !== "off" && !SlowTimer.get()) { + if (Config.smoothCaret !== "off") { this.element.style.animationName = "caretFlashSmooth"; } else { this.element.style.animationName = "caretFlashHard"; @@ -132,7 +136,9 @@ export class Caret { } public stopAllAnimations(): void { - $(this.element).stop(true, false); + this.posAnimation?.cancel(); + this.marginTopAnimation?.cancel(); + this.marginLeftAnimation?.cancel(); } public clearMargins(): void { @@ -152,6 +158,7 @@ export class Caret { public handleTapeScroll(options: { newValue: number; duration: number; + ease: EasingParam; }): void { if (this.isMainCaret && lockedMainCaretInTape) return; this.readyToResetMarginLeft = false; @@ -170,30 +177,20 @@ export class Caret { options.newValue - this.cumulativeTapeMarginCorrection; if (options.duration === 0) { - $(this.element).stop("marginLeft", true, false).css({ - marginLeft: newMarginLeft, - }); + this.marginLeftAnimation?.cancel(); + this.element.style.marginLeft = `${newMarginLeft}px`; this.readyToResetMarginLeft = true; return; } - $(this.element) - .stop("marginLeft", true, false) - .animate( - { - marginLeft: newMarginLeft, - }, - { - // this NEEDS to be the same duration as the - // line scroll otherwise it will look weird - duration: options.duration, - queue: "marginLeft", - complete: () => { - this.readyToResetMarginLeft = true; - }, - } - ); - $(this.element).dequeue("marginLeft"); + this.marginLeftAnimation = animate(this.element, { + marginLeft: newMarginLeft, + duration: options.duration, + ease: options.ease, + onComplete: () => { + this.readyToResetMarginLeft = true; + }, + }); } public handleLineJump(options: { @@ -212,37 +209,26 @@ export class Caret { this.readyToResetMarginTop = false; if (options.duration === 0) { - $(this.element).stop("marginTop", true, false).css({ - marginTop: options.newMarginTop, - }); + this.marginTopAnimation?.cancel(); + this.element.style.marginTop = `${options.newMarginTop}px`; this.readyToResetMarginTop = true; return; } - $(this.element) - .stop("marginTop", true, false) - .animate( - { - marginTop: options.newMarginTop, - }, - { - // this NEEDS to be the same duration as the - // line scroll otherwise it will look weird - duration: options.duration, - queue: "marginTop", - complete: () => { - this.readyToResetMarginTop = true; - }, - } - ); - $(this.element).dequeue("marginTop"); + this.marginTopAnimation = animate(this.element, { + marginTop: options.newMarginTop, + duration: options.duration, + onComplete: () => { + this.readyToResetMarginTop = true; + }, + }); } public animatePosition(options: { left: number; top: number; duration?: number; - easing?: string; + easing?: EasingParam; width?: number; }): void { const smoothCaretSpeed = @@ -256,9 +242,7 @@ export class Caret { ? 85 : 0; - const finalDuration = SlowTimer.get() - ? 0 - : options.duration ?? smoothCaretSpeed; + const finalDuration = options.duration ?? smoothCaretSpeed; const animation: Record = { left: options.left, @@ -269,14 +253,11 @@ export class Caret { animation["width"] = options.width; } - $(this.element) - .stop("pos", true, false) - .animate(animation, { - duration: finalDuration, - easing: options.easing ?? "swing", - queue: "pos", - }); - $(this.element).dequeue("pos"); + this.posAnimation = animate(this.element, { + ...animation, + duration: finalDuration, + ease: options.easing ?? "inOut(1.25)", + }); } public goTo(options: { diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 3138492a81a8..f4abae6998b5 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -6,6 +6,7 @@ import { Mode, Mode2, PersonalBests } from "@monkeytype/schemas/shared"; import { Result } from "@monkeytype/schemas/results"; import { RankAndCount } from "@monkeytype/schemas/users"; import { roundTo2 } from "@monkeytype/util/numbers"; +import { animate, AnimationParams } from "animejs"; export function whorf(speed: number, wordlen: number): number { return Math.min( @@ -226,8 +227,8 @@ type LastIndex = { export const trailingComposeChars = /[\u02B0-\u02FF`´^¨~]+$|⎄.*$/; export async function swapElements( - el1: JQuery, - el2: JQuery, + el1: HTMLElement, + el2: HTMLElement, totalDuration: number, callback = async function (): Promise { return Promise.resolve(); @@ -236,57 +237,49 @@ export async function swapElements( return Promise.resolve(); } ): Promise { + if (el1 === null || el2 === null) { + return; + } + totalDuration = applyReducedMotion(totalDuration); if ( - (el1.hasClass("hidden") && !el2.hasClass("hidden")) || - (!el1.hasClass("hidden") && el2.hasClass("hidden")) + (el1.classList.contains("hidden") && !el2.classList.contains("hidden")) || + (!el1.classList.contains("hidden") && el2.classList.contains("hidden")) ) { //one of them is hidden and the other is visible - if (el1.hasClass("hidden")) { + if (el1.classList.contains("hidden")) { await middleCallback(); await callback(); return false; } - $(el1) - .removeClass("hidden") - .css("opacity", 1) - .animate( - { - opacity: 0, - }, - totalDuration / 2, - async () => { - await middleCallback(); - $(el1).addClass("hidden"); - $(el2) - .removeClass("hidden") - .css("opacity", 0) - .animate( - { - opacity: 1, - }, - totalDuration / 2, - async () => { - await callback(); - } - ); - } - ); - } else if (el1.hasClass("hidden") && el2.hasClass("hidden")) { + + el1.classList.remove("hidden"); + await promiseAnimate(el1, { + opacity: [1, 0], + duration: totalDuration / 2, + }); + el1.classList.add("hidden"); + await middleCallback(); + el2.classList.remove("hidden"); + await promiseAnimate(el2, { + opacity: [0, 1], + duration: totalDuration / 2, + }); + await callback(); + } else if ( + el1.classList.contains("hidden") && + el2.classList.contains("hidden") + ) { //both are hidden, only fade in the second await middleCallback(); - $(el2) - .removeClass("hidden") - .css("opacity", 0) - .animate( - { - opacity: 1, - }, - totalDuration, - async () => { - await callback(); - } - ); + + el2.classList.remove("hidden"); + await promiseAnimate(el2, { + opacity: [0, 1], + duration: totalDuration / 2, + }); + + await callback(); } else { await middleCallback(); await callback(); @@ -489,14 +482,17 @@ export type JQueryEasing = | "easeOutBounce" | "easeInOutBounce"; -export async function promiseAnimation( - el: JQuery, - animation: Record, - duration: number, - easing: JQueryEasing = "swing" +export async function promiseAnimate( + el: HTMLElement, + options: AnimationParams ): Promise { return new Promise((resolve) => { - el.animate(animation, applyReducedMotion(duration), easing, resolve); + animate(el, { + ...options, + onComplete: () => { + resolve(); + }, + }); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3845a70ac09e..a2bc605cd83a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,6 +285,9 @@ importers: '@ts-rest/core': specifier: 3.52.1 version: 3.52.1(@types/node@24.9.1)(zod@3.23.8) + animejs: + specifier: 4.2.2 + version: 4.2.2 balloon-css: specifier: 1.2.0 version: 1.2.0 @@ -330,12 +333,6 @@ importers: jquery: specifier: 3.7.1 version: 3.7.1 - jquery-color: - specifier: 2.2.0 - version: 2.2.0(jquery@3.7.1) - jquery.easing: - specifier: 1.4.1 - version: 1.4.1 konami: specifier: 1.7.0 version: 1.7.0 @@ -3545,6 +3542,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + animejs@4.2.2: + resolution: {integrity: sha512-Ys3RuvLdAeI14fsdKCQy7ytu4057QX6Bb7m4jwmfd6iKmUmLquTwk1ut0e4NtRQgCeq/s2Lv5+oMBjz6c7ZuIg==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -6329,14 +6329,6 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - jquery-color@2.2.0: - resolution: {integrity: sha512-4VoxsLMw860EQGNT/TmP3Lbr7/1OCQlBPS4ILj7bxRApJrPQfpqzdIOTY8Ll9nGY7UHtWqDuzR7cUcS1lcWjVw==} - peerDependencies: - jquery: '>=1.11.0' - - jquery.easing@1.4.1: - resolution: {integrity: sha512-BVpRacWCbNfo/ALWxnLkIY/WRa4Ydg/LtwzIJZvDm7vrhV8Txv+ACi6EGnU11zT19sTc3KEPathWx6CtjWLD1w==} - jquery@3.7.1: resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} @@ -13139,6 +13131,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + animejs@4.2.2: {} + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -16590,12 +16584,6 @@ snapshots: joycon@3.1.1: {} - jquery-color@2.2.0(jquery@3.7.1): - dependencies: - jquery: 3.7.1 - - jquery.easing@1.4.1: {} - jquery@3.7.1: {} js-beautify@1.15.1: From 4cc1c095616ce54a33c8fe226a29dad55742f25f Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 17 Nov 2025 13:02:15 +0100 Subject: [PATCH 3/3] chore: move files --- frontend/src/ts/{ => types}/virtual-env-config.d.ts | 0 frontend/src/ts/{ => types}/virtual-language-hashes.d.ts | 0 frontend/tsconfig.json | 6 ++++-- 3 files changed, 4 insertions(+), 2 deletions(-) rename frontend/src/ts/{ => types}/virtual-env-config.d.ts (100%) rename frontend/src/ts/{ => types}/virtual-language-hashes.d.ts (100%) diff --git a/frontend/src/ts/virtual-env-config.d.ts b/frontend/src/ts/types/virtual-env-config.d.ts similarity index 100% rename from frontend/src/ts/virtual-env-config.d.ts rename to frontend/src/ts/types/virtual-env-config.d.ts diff --git a/frontend/src/ts/virtual-language-hashes.d.ts b/frontend/src/ts/types/virtual-language-hashes.d.ts similarity index 100% rename from frontend/src/ts/virtual-language-hashes.d.ts rename to frontend/src/ts/types/virtual-language-hashes.d.ts diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index faaa7559e331..86b316e33875 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -9,8 +9,10 @@ "target": "ES6", "noEmit": true, "paths": { - "virtual:language-hashes": ["./src/ts/virtual-language-hashes.d.ts"], - "virtual:env-config": ["./src/ts/virtual-env-config.d.ts"] + "virtual:language-hashes": [ + "./src/ts/types/virtual-language-hashes.d.ts" + ], + "virtual:env-config": ["./src/ts/types/virtual-env-config.d.ts"] } }, "include": ["./src/**/*.ts", "./scripts/**/*.ts", "vite-plugins/**/*.ts"],