From 1509a675b85aa407eb04118e37743cd165b9a474 Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 20 Sep 2024 11:54:14 +0200 Subject: [PATCH] refactor: move common util functions to a package (@miodec) (#5894) Also moves esbuild to a package. --- backend/__tests__/utils/misc.spec.ts | 282 ------------------ backend/package.json | 1 + backend/scripts/openapi.ts | 5 +- backend/src/api/controllers/dev.ts | 2 +- backend/src/api/controllers/leaderboard.ts | 10 +- backend/src/api/controllers/result.ts | 15 +- backend/src/api/controllers/user.ts | 2 +- backend/src/dal/public.ts | 2 +- backend/src/dal/user.ts | 3 +- backend/src/queues/later-queue.ts | 5 +- backend/src/services/weekly-xp-leaderboard.ts | 2 +- backend/src/utils/daily-leaderboards.ts | 3 +- backend/src/utils/misc.ts | 123 +------- backend/src/utils/validation.ts | 2 +- backend/src/workers/later-worker.ts | 3 +- backend/tsconfig.json | 3 +- frontend/package.json | 1 + frontend/src/ts/config.ts | 2 +- frontend/src/ts/controllers/ad-controller.ts | 2 +- .../src/ts/controllers/chart-controller.ts | 2 +- .../src/ts/controllers/input-controller.ts | 2 +- .../src/ts/controllers/sound-controller.ts | 2 +- frontend/src/ts/elements/account-button.ts | 3 +- frontend/src/ts/elements/fps-counter.ts | 2 +- frontend/src/ts/elements/last-10-average.ts | 2 +- frontend/src/ts/elements/leaderboards.ts | 2 +- frontend/src/ts/elements/notifications.ts | 4 +- frontend/src/ts/elements/profile.ts | 12 +- frontend/src/ts/elements/result-batches.ts | 2 +- frontend/src/ts/pages/about.ts | 13 +- frontend/src/ts/pages/account.ts | 5 +- frontend/src/ts/test/caret.ts | 4 +- .../src/ts/test/funbox/funbox-validation.ts | 6 +- frontend/src/ts/test/funbox/funbox.ts | 8 +- frontend/src/ts/test/monkey.ts | 2 +- frontend/src/ts/test/pace-caret.ts | 4 +- frontend/src/ts/test/result.ts | 2 +- frontend/src/ts/test/test-input.ts | 2 +- frontend/src/ts/test/test-logic.ts | 8 +- frontend/src/ts/test/test-stats.ts | 2 +- frontend/src/ts/test/test-timer.ts | 2 +- frontend/src/ts/test/test-ui.ts | 12 +- frontend/src/ts/utils/arrays.ts | 18 +- frontend/src/ts/utils/date-and-time.ts | 60 +--- frontend/src/ts/utils/format.ts | 2 +- frontend/src/ts/utils/generate.ts | 2 +- frontend/src/ts/utils/ip-addresses.ts | 2 +- frontend/src/ts/utils/misc.ts | 31 -- frontend/src/ts/utils/numbers.ts | 79 +---- packages/contracts/package.json | 6 +- packages/esbuild-config/.eslintrc.cjs | 5 + .../index.js} | 0 packages/esbuild-config/package.json | 14 + packages/util/.eslintrc.cjs | 5 + packages/util/__test__/arrays.spec.ts | 48 +++ packages/util/__test__/date-and-time.spec.ts | 222 ++++++++++++++ packages/util/__test__/numbers.spec.ts | 100 +++++++ packages/util/__test__/tsconfig.json | 12 + packages/util/package.json | 35 +++ packages/util/src/arrays.ts | 15 + packages/util/src/date-and-time.ts | 80 +++++ packages/util/src/numbers.ts | 127 ++++++++ packages/util/tsconfig.json | 15 + packages/util/vitest.config.js | 11 + pnpm-lock.yaml | 51 +++- 65 files changed, 836 insertions(+), 675 deletions(-) create mode 100644 packages/esbuild-config/.eslintrc.cjs rename packages/{contracts/esbuild.config.js => esbuild-config/index.js} (100%) mode change 100644 => 100755 create mode 100644 packages/esbuild-config/package.json create mode 100644 packages/util/.eslintrc.cjs create mode 100644 packages/util/__test__/arrays.spec.ts create mode 100644 packages/util/__test__/date-and-time.spec.ts create mode 100644 packages/util/__test__/numbers.spec.ts create mode 100644 packages/util/__test__/tsconfig.json create mode 100644 packages/util/package.json create mode 100644 packages/util/src/arrays.ts create mode 100644 packages/util/src/date-and-time.ts create mode 100644 packages/util/src/numbers.ts create mode 100644 packages/util/tsconfig.json create mode 100644 packages/util/vitest.config.js diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index 95d5d2dabcb8..c4fb2c6ff600 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -7,14 +7,6 @@ describe("Misc Utils", () => { vi.useRealTimers(); }); - it("getCurrentDayTimestamp", () => { - vi.useFakeTimers(); - vi.setSystemTime(1652743381); - - const currentDay = misc.getCurrentDayTimestamp(); - expect(currentDay).toBe(1641600000); - }); - it("matchesAPattern", () => { const testCases = { "eng.*": { @@ -280,280 +272,6 @@ describe("Misc Utils", () => { expect(misc.getOrdinalNumberString(input)).toEqual(output); }); }); - - it("getStartOfWeekTimestamp", () => { - const testCases = [ - { - input: 1662400184017, // Mon Sep 05 2022 17:49:44 GMT+0000 - expected: 1662336000000, // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: 1559771456000, // Wed Jun 05 2019 21:50:56 GMT+0000 - expected: 1559520000000, // Mon Jun 03 2019 00:00:00 GMT+0000 - }, - { - input: 1465163456000, // Sun Jun 05 2016 21:50:56 GMT+0000 - expected: 1464566400000, // Mon May 30 2016 00:00:00 GMT+0000 - }, - { - input: 1491515456000, // Thu Apr 06 2017 21:50:56 GMT+0000 - expected: 1491177600000, // Mon Apr 03 2017 00:00:00 GMT+0000 - }, - { - input: 1462507200000, // Fri May 06 2016 04:00:00 GMT+0000 - expected: 1462147200000, // Mon May 02 2016 00:00:00 GMT+0000 - }, - { - input: 1231218000000, // Tue Jan 06 2009 05:00:00 GMT+0000, - expected: 1231113600000, // Mon Jan 05 2009 00:00:00 GMT+0000 - }, - { - input: 1709420681000, // Sat Mar 02 2024 23:04:41 GMT+0000 - expected: 1708905600000, // Mon Feb 26 2024 00:00:00 GMT+0000 - }, - ]; - - testCases.forEach(({ input, expected }) => { - expect(misc.getStartOfWeekTimestamp(input)).toEqual(expected); - }); - }); - - it("getCurrentWeekTimestamp", () => { - Date.now = vi.fn(() => 825289481000); // Sun Feb 25 1996 23:04:41 GMT+0000 - - const currentWeek = misc.getCurrentWeekTimestamp(); - expect(currentWeek).toBe(824688000000); // Mon Feb 19 1996 00:00:00 GMT+0000 - }); - - it("getStartOfDayTimestamp", () => { - const testCases = [ - { - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 0, - expected: new Date("2023/06/16 00:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 1, - expected: new Date("2023/06/16 01:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: -1, - expected: new Date("2023/06/15 23:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: -4, - expected: new Date("2023/06/15 20:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 4, - expected: new Date("2023/06/16 04:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/17 03:00 UTC").getTime(), - offset: 4, - expected: new Date("2023/06/16 04:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 3, - expected: new Date("2023/06/16 03:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - { - input: new Date("2023/06/17 01:00 UTC").getTime(), - offset: 3, - expected: new Date("2023/06/16 03:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 - }, - ]; - - testCases.forEach(({ input, offset, expected }) => { - expect(misc.getStartOfDayTimestamp(input, offset * 3600000)).toEqual( - expected - ); - }); - }); - - it("isToday", () => { - const testCases = [ - { - now: new Date("2023/06/16 15:00 UTC").getTime(), - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 0, - expected: true, - }, - { - now: new Date("2023/06/16 15:00 UTC").getTime(), - input: new Date("2023/06/17 1:00 UTC").getTime(), - offset: 0, - expected: false, - }, - { - now: new Date("2023/06/16 15:00 UTC").getTime(), - input: new Date("2023/06/16 01:00 UTC").getTime(), - offset: 1, - expected: true, - }, - { - now: new Date("2023/06/16 15:00 UTC").getTime(), - input: new Date("2023/06/17 01:00 UTC").getTime(), - offset: 2, - expected: true, - }, - { - now: new Date("2023/06/16 15:00 UTC").getTime(), - input: new Date("2023/06/16 01:00 UTC").getTime(), - offset: 2, - expected: false, - }, - { - now: new Date("2023/06/17 01:00 UTC").getTime(), - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 2, - expected: true, - }, - { - now: new Date("2023/06/17 01:00 UTC").getTime(), - input: new Date("2023/06/17 02:00 UTC").getTime(), - offset: 2, - expected: false, - }, - ]; - - testCases.forEach(({ now, input, offset, expected }) => { - Date.now = vi.fn(() => now); - expect(misc.isToday(input, offset)).toEqual(expected); - }); - }); - - it("isYesterday", () => { - const testCases = [ - { - now: new Date("2023/06/15 15:00 UTC").getTime(), - input: new Date("2023/06/14 15:00 UTC").getTime(), - offset: 0, - expected: true, - }, - { - now: new Date("2023/06/15 15:00 UTC").getTime(), - input: new Date("2023/06/15 15:00 UTC").getTime(), - offset: 0, - expected: false, - }, - { - now: new Date("2023/06/15 15:00 UTC").getTime(), - input: new Date("2023/06/16 15:00 UTC").getTime(), - offset: 0, - expected: false, - }, - { - now: new Date("2023/06/15 15:00 UTC").getTime(), - input: new Date("2023/06/13 15:00 UTC").getTime(), - offset: 0, - expected: false, - }, - { - now: new Date("2023/06/16 02:00 UTC").getTime(), - input: new Date("2023/06/15 02:00 UTC").getTime(), - offset: 4, - expected: true, - }, - { - now: new Date("2023/06/16 02:00 UTC").getTime(), - input: new Date("2023/06/16 01:00 UTC").getTime(), - offset: 4, - expected: false, - }, - { - now: new Date("2023/06/16 02:00 UTC").getTime(), - input: new Date("2023/06/15 22:00 UTC").getTime(), - offset: 4, - expected: false, - }, - { - now: new Date("2023/06/16 04:00 UTC").getTime(), - input: new Date("2023/06/16 03:00 UTC").getTime(), - offset: 4, - expected: true, - }, - { - now: new Date("2023/06/16 14:00 UTC").getTime(), - input: new Date("2023/06/16 12:00 UTC").getTime(), - offset: -11, - expected: true, - }, - ]; - - testCases.forEach(({ now, input, offset, expected }) => { - Date.now = vi.fn(() => now); - expect(misc.isYesterday(input, offset)).toEqual(expected); - }); - }); - - it("mapRange", () => { - const testCases = [ - { - input: { - value: 123, - inMin: 0, - inMax: 200, - outMin: 0, - outMax: 1000, - clamp: false, - }, - expected: 615, - }, - { - input: { - value: 123, - inMin: 0, - inMax: 200, - outMin: 1000, - outMax: 0, - clamp: false, - }, - expected: 385, - }, - { - input: { - value: 10001, - inMin: 0, - inMax: 10000, - outMin: 0, - outMax: 1000, - clamp: false, - }, - expected: 1000.1, - }, - { - input: { - value: 10001, - inMin: 0, - inMax: 10000, - outMin: 0, - outMax: 1000, - clamp: true, - }, - expected: 1000, - }, - ]; - - testCases.forEach(({ input, expected }) => { - expect( - misc.mapRange( - input.value, - input.inMin, - input.inMax, - input.outMin, - input.outMax, - input.clamp - ) - ).toEqual(expected); - }); - }); - it("formatSeconds", () => { const testCases = [ { diff --git a/backend/package.json b/backend/package.json index 1a63da44a4e3..c18d21dd769d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@date-fns/utc": "1.2.0", + "@monkeytype/util": "workspace:*", "@monkeytype/contracts": "workspace:*", "@ts-rest/core": "3.51.0", "@ts-rest/express": "3.51.0", diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 744420b206d8..80b6aaf35934 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -192,7 +192,7 @@ function addAuth( function getRequiredPermissions( metadata: EndpointMetadata | undefined -): Permission[] | undefined { +): PermissionId[] | undefined { if (metadata === undefined || metadata.requirePermission === undefined) return undefined; @@ -216,11 +216,13 @@ function addRateLimit( metadata: EndpointMetadata | undefined ): void { if (metadata === undefined || metadata.rateLimit === undefined) return; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const okResponse = operation.responses["200"]; if (okResponse === undefined) return; operation.description += getRateLimitDescription(metadata.rateLimit); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment okResponse["headers"] = { ...okResponse["headers"], "x-ratelimit-limit": { @@ -275,6 +277,7 @@ function addRequiredConfiguration( if (metadata === undefined || metadata.requireConfiguration === undefined) return; + //@ts-expect-error operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`; } diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts index 1d80824e5ab2..2b4eff576f62 100644 --- a/backend/src/api/controllers/dev.ts +++ b/backend/src/api/controllers/dev.ts @@ -5,7 +5,6 @@ import Logger from "../../utils/logger"; import * as DateUtils from "date-fns"; import { UTCDate } from "@date-fns/utc"; import * as ResultDal from "../../dal/result"; -import { roundTo2 } from "../../utils/misc"; import { ObjectId } from "mongodb"; import * as LeaderboardDal from "../../dal/leaderboards"; import MonkeyError from "../../utils/error"; @@ -19,6 +18,7 @@ import { GenerateDataRequest, GenerateDataResponse, } from "@monkeytype/contracts/dev"; +import { roundTo2 } from "@monkeytype/util/numbers"; const CREATE_RESULT_DEFAULT_OPTIONS = { firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())).valueOf(), diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index a07144e9acab..720af47846e1 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,9 +1,4 @@ import _ from "lodash"; -import { - getCurrentDayTimestamp, - MILLISECONDS_IN_DAY, - getCurrentWeekTimestamp, -} from "../../utils/misc"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; import MonkeyError from "../../utils/error"; @@ -22,6 +17,11 @@ import { LanguageAndModeQuery, } from "@monkeytype/contracts/leaderboards"; import { Configuration } from "@monkeytype/contracts/schemas/configuration"; +import { + getCurrentDayTimestamp, + getCurrentWeekTimestamp, + MILLISECONDS_IN_DAY, +} from "@monkeytype/util/date-and-time"; export async function getLeaderboard( req: MonkeyTypes.Request diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index bd5dabae90d6..27ef994653e3 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -1,14 +1,6 @@ import * as ResultDAL from "../../dal/result"; import * as PublicDAL from "../../dal/public"; -import { - getCurrentDayTimestamp, - getStartOfDayTimestamp, - isDevEnvironment, - mapRange, - replaceObjectId, - roundTo2, - stdDev, -} from "../../utils/misc"; +import { isDevEnvironment, replaceObjectId } from "../../utils/misc"; import objectHash from "object-hash"; import Logger from "../../utils/logger"; import "dotenv/config"; @@ -55,6 +47,11 @@ import { XpBreakdown, } from "@monkeytype/contracts/schemas/results"; import { Mode } from "@monkeytype/contracts/schemas/shared"; +import { mapRange, roundTo2, stdDev } from "@monkeytype/util/numbers"; +import { + getCurrentDayTimestamp, + getStartOfDayTimestamp, +} from "@monkeytype/util/date-and-time"; try { if (!anticheatImplemented()) throw new Error("undefined"); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index b8b48b70245e..2b7c785176c0 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -7,7 +7,6 @@ import MonkeyError, { import { MonkeyResponse } from "../../utils/monkey-response"; import * as DiscordUtils from "../../utils/discord"; import { - MILLISECONDS_IN_DAY, buildAgentLog, isDevEnvironment, replaceObjectId, @@ -86,6 +85,7 @@ import { UpdateUserProfileRequest, UpdateUserProfileResponse, } from "@monkeytype/contracts/users"; +import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; async function verifyCaptcha(captcha: string): Promise { let verified = false; diff --git a/backend/src/dal/public.ts b/backend/src/dal/public.ts index 50a230a7867c..7791935fdf90 100644 --- a/backend/src/dal/public.ts +++ b/backend/src/dal/public.ts @@ -1,5 +1,5 @@ +import { roundTo2 } from "@monkeytype/util/numbers"; import * as db from "../init/db"; -import { roundTo2 } from "../utils/misc"; import MonkeyError from "../utils/error"; import { TypingStats, diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 21d9805d1b01..da652c2ccc69 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -9,7 +9,7 @@ import { type UpdateFilter, type Filter, } from "mongodb"; -import { flattenObjectDeep, isToday, isYesterday } from "../utils/misc"; +import { flattenObjectDeep } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; import { getDayOfYear } from "date-fns"; import { UTCDate } from "@date-fns/utc"; @@ -32,6 +32,7 @@ import { import { addImportantLog } from "./logs"; import { Result as ResultType } from "@monkeytype/contracts/schemas/results"; import { Configuration } from "@monkeytype/contracts/schemas/configuration"; +import { isToday, isYesterday } from "@monkeytype/util/date-and-time"; const SECONDS_PER_HOUR = 3600; diff --git a/backend/src/queues/later-queue.ts b/backend/src/queues/later-queue.ts index 71b5b2633dec..7108cf60cafb 100644 --- a/backend/src/queues/later-queue.ts +++ b/backend/src/queues/later-queue.ts @@ -1,8 +1,11 @@ import LRUCache from "lru-cache"; import Logger from "../utils/logger"; import { MonkeyQueue } from "./monkey-queue"; -import { getCurrentDayTimestamp, getCurrentWeekTimestamp } from "../utils/misc"; import { ValidModeRule } from "@monkeytype/contracts/schemas/configuration"; +import { + getCurrentDayTimestamp, + getCurrentWeekTimestamp, +} from "@monkeytype/util/date-and-time"; const QUEUE_NAME = "later"; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 11f09178f9d6..149f64b63381 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -1,11 +1,11 @@ import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; -import { getCurrentWeekTimestamp } from "../utils/misc"; import { XpLeaderboardEntry, XpLeaderboardRank, } from "@monkeytype/contracts/schemas/leaderboards"; +import { getCurrentWeekTimestamp } from "@monkeytype/util/date-and-time"; type AddResultOpts = { entry: Pick< diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 41b01c2634e4..46330a16f7d1 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -1,7 +1,7 @@ import _, { omit } from "lodash"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; -import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc"; +import { matchesAPattern, kogascore } from "./misc"; import { Configuration, ValidModeRule, @@ -12,6 +12,7 @@ import { } from "@monkeytype/contracts/schemas/leaderboards"; import MonkeyError from "./error"; import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; +import { getCurrentDayTimestamp } from "@monkeytype/util/date-and-time"; const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard"; const scoresNamespace = `${dailyLeaderboardNamespace}:scores`; diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 1db2bcafcca9..e69c47083910 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -1,35 +1,10 @@ +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"; //todo split this file into smaller util files (grouped by functionality) -export function roundTo2(num: number): number { - return _.round(num, 2); -} - -export function stdDev(population: number[]): number { - const n = population.length; - if (n === 0) { - return 0; - } - - const populationMean = mean(population); - const variance = _.sumBy(population, (x) => (x - populationMean) ** 2) / n; - - return Math.sqrt(variance); -} - -export function mean(population: number[]): number { - const n = population.length; - return n > 0 ? _.sum(population) / n : 0; -} - -export function kogasa(cov: number): number { - return ( - 100 * (1 - Math.tanh(cov + Math.pow(cov, 3) / 3 + Math.pow(cov, 5) / 5)) - ); -} - export function identity(value: unknown): string { return Object.prototype.toString .call(value) @@ -84,21 +59,6 @@ export function padNumbers( ); } -export const MILISECONDS_IN_HOUR = 3600000; -export const MILLISECONDS_IN_DAY = 86400000; - -export function getStartOfDayTimestamp( - timestamp: number, - offsetMilis = 0 -): number { - return timestamp - ((timestamp - offsetMilis) % MILLISECONDS_IN_DAY); -} - -export function getCurrentDayTimestamp(): number { - const currentTime = Date.now(); - return getStartOfDayTimestamp(currentTime); -} - export function matchesAPattern(text: string, pattern: string): boolean { const regex = new RegExp(`^${pattern}$`); return regex.test(text); @@ -173,65 +133,6 @@ export function getOrdinalNumberString(number: number): string { return `${number}${suffix}`; } -export function isYesterday(timestamp: number, hourOffset = 0): boolean { - const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; - const yesterday = getStartOfDayTimestamp( - Date.now() - MILLISECONDS_IN_DAY, - offsetMilis - ); - const date = getStartOfDayTimestamp(timestamp, offsetMilis); - - return yesterday === date; -} - -export function isToday(timestamp: number, hourOffset = 0): boolean { - const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; - const today = getStartOfDayTimestamp(Date.now(), offsetMilis); - const date = getStartOfDayTimestamp(timestamp, offsetMilis); - - return today === date; -} - -export function mapRange( - value: number, - inMin: number, - inMax: number, - outMin: number, - outMax: number, - clamp = false -): number { - if (inMin === inMax) { - return outMin; - } - - const result = - ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; - - if (clamp) { - if (outMin < outMax) { - return Math.min(Math.max(result, outMin), outMax); - } else { - return Math.max(Math.min(result, outMin), outMax); - } - } - - return result; -} - -export function getStartOfWeekTimestamp(timestamp: number): number { - const date = new Date(getStartOfDayTimestamp(timestamp)); - - const monday = date.getDate() - (date.getDay() || 7) + 1; - date.setDate(monday); - - return getStartOfDayTimestamp(date.getTime()); -} - -export function getCurrentWeekTimestamp(): number { - const currentTime = Date.now(); - return getStartOfWeekTimestamp(currentTime); -} - type TimeUnit = | "second" | "minute" @@ -284,26 +185,6 @@ export function formatSeconds( return `${normalized} ${unit}${normalized > 1 ? "s" : ""}`; } -export function intersect(a: T[], b: T[], removeDuplicates = false): T[] { - let t: T[]; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter - const filtered = a.filter(function (e) { - return b.includes(e); - }); - return removeDuplicates ? [...new Set(filtered)] : filtered; -} - -export function stringToNumberOrDefault( - string: string, - defaultValue: number -): number { - if (string === undefined) return defaultValue; - const value = parseInt(string, 10); - if (!Number.isFinite(value)) return defaultValue; - return value; -} - export function isDevEnvironment(): boolean { return process.env["MODE"] === "dev"; } diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 0cb56a2b7d93..a36ea508c962 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -1,7 +1,7 @@ import _ from "lodash"; -import { intersect } from "./misc"; import { default as FunboxList } from "../constants/funbox-list"; import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; +import { intersect } from "@monkeytype/util/arrays"; export function isTestTooShort(result: CompletedEvent): boolean { const { mode, mode2, customText, testDuration, bailedOut } = result; diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index a669a3b3ead2..293370bcf7fa 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -7,7 +7,7 @@ import GeorgeQueue from "../queues/george-queue"; import { buildMonkeyMail } from "../utils/monkey-mail"; import { DailyLeaderboard } from "../utils/daily-leaderboards"; import { getCachedConfiguration } from "../init/configuration"; -import { formatSeconds, getOrdinalNumberString, mapRange } from "../utils/misc"; +import { formatSeconds, getOrdinalNumberString } from "../utils/misc"; import LaterQueue, { type LaterTask, type LaterTaskContexts, @@ -16,6 +16,7 @@ import LaterQueue, { import { recordTimeToCompleteJob } from "../utils/prometheus"; import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard"; import { MonkeyMail } from "@monkeytype/contracts/schemas/users"; +import { mapRange } from "@monkeytype/util/numbers"; async function handleDailyLeaderboardResults( ctx: LaterTaskContexts["daily-leaderboard-results"] diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 45c6de4521b7..50b0b883af2c 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -11,8 +11,7 @@ "ts-node": { "files": true }, - "files": ["./src/types/types.d.ts"], - "include": ["./src/**/*"], + "include": ["src"], "exclude": [ "node_modules", "build", diff --git a/frontend/package.json b/frontend/package.json index 7fc3c0ce9abc..0aca4e0d4e76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,7 @@ "vitest": "2.0.5" }, "dependencies": { + "@monkeytype/util": "workspace:*", "@date-fns/utc": "1.2.0", "@monkeytype/contracts": "workspace:*", "@ts-rest/core": "3.51.0", diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index ad611459b43f..381b1c7a98ce 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -24,11 +24,11 @@ import { } from "./utils/misc"; import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs"; import { Config } from "@monkeytype/contracts/schemas/configs"; -import { roundTo1 } from "./utils/numbers"; import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared"; import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util"; import { LocalStorageWithSchema } from "./utils/local-storage-with-schema"; import { migrateConfig } from "./utils/config"; +import { roundTo1 } from "@monkeytype/util/numbers"; const configLS = new LocalStorageWithSchema({ key: "config", diff --git a/frontend/src/ts/controllers/ad-controller.ts b/frontend/src/ts/controllers/ad-controller.ts index f751f3ee79c9..e636135062e8 100644 --- a/frontend/src/ts/controllers/ad-controller.ts +++ b/frontend/src/ts/controllers/ad-controller.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { debounce } from "throttle-debounce"; -// import * as Numbers from "../utils/numbers"; +// import * as Numbers from "@monkeytype/util/numbers"; import * as ConfigEvent from "../observables/config-event"; import * as BannerEvent from "../observables/banner-event"; import Config from "../config"; diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index e4ea43c8132c..c64826130ed8 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -65,7 +65,7 @@ import * as ConfigEvent from "../observables/config-event"; import * as TestInput from "../test/test-input"; import * as DateTime from "../utils/date-and-time"; import * as Arrays from "../utils/arrays"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import { blendTwoHexColors } from "../utils/colors"; class ChartWithUpdateColors< diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts index 8504746b80bf..5cc1975ae770 100644 --- a/frontend/src/ts/controllers/input-controller.ts +++ b/frontend/src/ts/controllers/input-controller.ts @@ -5,7 +5,7 @@ import * as Monkey from "../test/monkey"; import Config from "../config"; import * as Misc from "../utils/misc"; import * as JSONData from "../utils/json-data"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import * as LiveAcc from "../test/live-acc"; import * as LiveBurst from "../test/live-burst"; import * as Funbox from "../test/funbox/funbox"; diff --git a/frontend/src/ts/controllers/sound-controller.ts b/frontend/src/ts/controllers/sound-controller.ts index 61a16e48ba77..a69274495b8d 100644 --- a/frontend/src/ts/controllers/sound-controller.ts +++ b/frontend/src/ts/controllers/sound-controller.ts @@ -2,7 +2,7 @@ import Config from "../config"; import * as ConfigEvent from "../observables/config-event"; import { createErrorMessage } from "../utils/misc"; import { randomElementFromArray } from "../utils/arrays"; -import { randomIntFromRange } from "../utils/numbers"; +import { randomIntFromRange } from "@monkeytype/util/numbers"; import { leftState, rightState } from "../test/shift-tracker"; import { capsState } from "../test/caps-warning"; import * as Notifications from "../elements/notifications"; diff --git a/frontend/src/ts/elements/account-button.ts b/frontend/src/ts/elements/account-button.ts index 9887b4dba4b5..6749055dfafd 100644 --- a/frontend/src/ts/elements/account-button.ts +++ b/frontend/src/ts/elements/account-button.ts @@ -8,6 +8,7 @@ import { SupportsFlags, } from "../controllers/user-flag-controller"; import { isAuthenticated } from "../firebase"; +import { mapRange } from "@monkeytype/util/numbers"; let usingAvatar = false; @@ -429,7 +430,7 @@ async function animateXpBar( do { if (toAnimate - 1 < 1) { - animationDuration = Misc.mapRange(toAnimate - 1, 0, 0.5, 1000, 200); + animationDuration = mapRange(toAnimate - 1, 0, 0.5, 1000, 200); animationEasing = "easeOutQuad"; } if (firstOneDone) { diff --git a/frontend/src/ts/elements/fps-counter.ts b/frontend/src/ts/elements/fps-counter.ts index 994fce9c51eb..1616dd6e2351 100644 --- a/frontend/src/ts/elements/fps-counter.ts +++ b/frontend/src/ts/elements/fps-counter.ts @@ -1,4 +1,4 @@ -import { roundTo2 } from "../utils/numbers"; +import { roundTo2 } from "@monkeytype/util/numbers"; let frameCount = 0; let fpsInterval: number; diff --git a/frontend/src/ts/elements/last-10-average.ts b/frontend/src/ts/elements/last-10-average.ts index fb5087669592..88411a4eac5c 100644 --- a/frontend/src/ts/elements/last-10-average.ts +++ b/frontend/src/ts/elements/last-10-average.ts @@ -1,6 +1,6 @@ import * as DB from "../db"; import * as Misc from "../utils/misc"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import Config from "../config"; import * as TestWords from "../test/test-words"; diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts index f80c47632501..4a5c8521fce0 100644 --- a/frontend/src/ts/elements/leaderboards.ts +++ b/frontend/src/ts/elements/leaderboards.ts @@ -4,7 +4,7 @@ import Config from "../config"; import * as DateTime from "../utils/date-and-time"; import * as Misc from "../utils/misc"; import * as Arrays from "../utils/arrays"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import * as Notifications from "./notifications"; import { format } from "date-fns/format"; import { isAuthenticated } from "../firebase"; diff --git a/frontend/src/ts/elements/notifications.ts b/frontend/src/ts/elements/notifications.ts index 17e22424dd08..e8e876d8baeb 100644 --- a/frontend/src/ts/elements/notifications.ts +++ b/frontend/src/ts/elements/notifications.ts @@ -1,13 +1,13 @@ import { debounce } from "throttle-debounce"; import * as Misc from "../utils/misc"; -import * as Numbers from "../utils/numbers"; import * as BannerEvent from "../observables/banner-event"; // import * as Alerts from "./alerts"; import * as NotificationEvent from "../observables/notification-event"; +import { convertRemToPixels } from "../utils/numbers"; function updateMargin(): void { const height = $("#bannerCenter").height() as number; - $("#app").css("padding-top", height + Numbers.convertRemToPixels(2) + "px"); + $("#app").css("padding-top", height + convertRemToPixels(2) + "px"); $("#notificationCenter").css("margin-top", height + "px"); } diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index f4cf7ef7e226..199fe0372dd8 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -2,9 +2,9 @@ import * as DB from "../db"; import { format } from "date-fns/format"; import { differenceInDays } from "date-fns/differenceInDays"; import * as Misc from "../utils/misc"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import * as Levels from "../utils/levels"; -import * as DateTime from "../utils/date-and-time"; +import * as DateTime from "@monkeytype/util/date-and-time"; import { getHTMLById } from "../controllers/badge-controller"; import { throttle } from "throttle-debounce"; import * as ActivePage from "../states/active-page"; @@ -12,6 +12,8 @@ import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict"; import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; import Format from "../utils/format"; import { UserProfile, RankAndCount } from "@monkeytype/contracts/schemas/users"; +import { abbreviateNumber, convertRemToPixels } from "../utils/numbers"; +import { secondsToString } from "../utils/date-and-time"; type ProfileViewPaths = "profile" | "account"; type UserProfileOrSnapshot = UserProfile | MonkeyTypes.Snapshot; @@ -227,7 +229,7 @@ export async function update( typingStatsEl .find(".timeTyping .value") .text( - DateTime.secondsToString( + secondsToString( Math.round(profile.typingStats?.timeTyping ?? 0), true, true @@ -416,7 +418,7 @@ export function updateNameFontSize(where: ProfileViewPaths): void { const nameFieldjQ = details.find(".user"); const nameFieldParent = nameFieldjQ.parent()[0]; const nameField = nameFieldjQ[0]; - const upperLimit = Numbers.convertRemToPixels(2); + const upperLimit = convertRemToPixels(2); if (!nameField || !nameFieldParent) return; @@ -450,6 +452,6 @@ function formatXp(xp: number): string { if (xp < 1000) { return Math.round(xp).toString(); } else { - return Numbers.abbreviateNumber(xp); + return abbreviateNumber(xp); } } diff --git a/frontend/src/ts/elements/result-batches.ts b/frontend/src/ts/elements/result-batches.ts index 9a2c20c66e8e..624d98ce5117 100644 --- a/frontend/src/ts/elements/result-batches.ts +++ b/frontend/src/ts/elements/result-batches.ts @@ -1,8 +1,8 @@ import * as DB from "../db"; import * as ServerConfiguration from "../ape/server-configuration"; -import { mapRange } from "../utils/misc"; import { blendTwoHexColors } from "../utils/colors"; import * as ThemeColors from "../elements/theme-colors"; +import { mapRange } from "@monkeytype/util/numbers"; export function hide(): void { $(".pageAccount .resultBatches").addClass("hidden"); diff --git a/frontend/src/ts/pages/about.ts b/frontend/src/ts/pages/about.ts index df85802de421..ea4d5fb8a1bc 100644 --- a/frontend/src/ts/pages/about.ts +++ b/frontend/src/ts/pages/about.ts @@ -1,6 +1,5 @@ import * as Misc from "../utils/misc"; import * as JSONData from "../utils/json-data"; -import * as Numbers from "../utils/numbers"; import Page from "./page"; import Ape from "../ape"; import * as Notifications from "../elements/notifications"; @@ -12,6 +11,7 @@ import { TypingStats, SpeedHistogram, } from "@monkeytype/contracts/schemas/public"; +import { getNumberWithMagnitude, numberWithSpaces } from "../utils/numbers"; function reset(): void { $(".pageAbout .contributors").empty(); @@ -50,10 +50,10 @@ function updateStatsAndHistogram(): void { $(".pageAbout #totalTimeTypingStat .valSmall").text("years"); $(".pageAbout #totalTimeTypingStat").attr( "aria-label", - Numbers.numberWithSpaces(Math.round(secondsRounded / 3600)) + " hours" + numberWithSpaces(Math.round(secondsRounded / 3600)) + " hours" ); - const startedWithMagnitude = Numbers.getNumberWithMagnitude( + const startedWithMagnitude = getNumberWithMagnitude( typingStatsResponseData.testsStarted ); @@ -67,10 +67,10 @@ function updateStatsAndHistogram(): void { ); $(".pageAbout #totalStartedTestsStat").attr( "aria-label", - Numbers.numberWithSpaces(typingStatsResponseData.testsStarted) + " tests" + numberWithSpaces(typingStatsResponseData.testsStarted) + " tests" ); - const completedWIthMagnitude = Numbers.getNumberWithMagnitude( + const completedWIthMagnitude = getNumberWithMagnitude( typingStatsResponseData.testsCompleted ); @@ -84,8 +84,7 @@ function updateStatsAndHistogram(): void { ); $(".pageAbout #totalCompletedTestsStat").attr( "aria-label", - Numbers.numberWithSpaces(typingStatsResponseData.testsCompleted) + - " tests" + numberWithSpaces(typingStatsResponseData.testsCompleted) + " tests" ); } } diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index d5d7fd621fd7..d4470095d260 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -13,7 +13,7 @@ import Page from "./page"; import * as DateTime from "../utils/date-and-time"; import * as Misc from "../utils/misc"; import * as Arrays from "../utils/arrays"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as Profile from "../elements/profile"; import { format } from "date-fns/format"; @@ -30,6 +30,7 @@ import * as TestActivity from "../elements/test-activity"; import { ChartData } from "@monkeytype/contracts/schemas/results"; import { Mode, Mode2, Mode2Custom } from "@monkeytype/contracts/schemas/shared"; import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users"; +import { findLineByLeastSquares } from "../utils/numbers"; let filterDebug = false; //toggle filterdebug @@ -918,7 +919,7 @@ async function fillContent(): Promise { const wpmPoints = filteredResults.map((r) => r.wpm).reverse(); - const trend = Numbers.findLineByLeastSquares(wpmPoints); + const trend = findLineByLeastSquares(wpmPoints); if (trend) { const wpmChange = trend[1][1] - trend[0][1]; const wpmChangePerHour = wpmChange * (3600 / totalSecondsFiltered); diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index f68be0c76c04..2c73cbfe5bde 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,4 +1,3 @@ -import * as Numbers from "../utils/numbers"; import * as JSONData from "../utils/json-data"; import Config from "../config"; import * as TestInput from "./test-input"; @@ -6,6 +5,7 @@ import * as SlowTimer from "../states/slow-timer"; import * as TestState from "../test/test-state"; import * as TestWords from "./test-words"; import { prefersReducedMotion } from "../utils/misc"; +import { convertRemToPixels } from "../utils/numbers"; export let caretAnimating = true; const caret = document.querySelector("#caret") as HTMLElement; @@ -165,7 +165,7 @@ export async function updatePosition(noAnim = false): Promise { const letterHeight = currentLetter?.offsetHeight || lastWordLetter?.offsetHeight || - Config.fontSize * Numbers.convertRemToPixels(1); + Config.fontSize * convertRemToPixels(1); const letterPosTop = currentLetter?.offsetTop ?? lastWordLetter?.offsetTop ?? 0; diff --git a/frontend/src/ts/test/funbox/funbox-validation.ts b/frontend/src/ts/test/funbox/funbox-validation.ts index 11dfac309157..9a9f37033ad8 100644 --- a/frontend/src/ts/test/funbox/funbox-validation.ts +++ b/frontend/src/ts/test/funbox/funbox-validation.ts @@ -1,8 +1,8 @@ import * as FunboxList from "./funbox-list"; import * as Notifications from "../../elements/notifications"; -import * as Arrays from "../../utils/arrays"; import * as Strings from "../../utils/strings"; import { Config, ConfigValue } from "@monkeytype/contracts/schemas/configs"; +import { intersect } from "@monkeytype/util/arrays"; export function checkFunboxForcedConfigs( key: string, @@ -43,7 +43,7 @@ export function checkFunboxForcedConfigs( if (forcedConfigs[key] === undefined) { forcedConfigs[key] = fb.forcedConfig[key] as ConfigValue[]; } else { - forcedConfigs[key] = Arrays.intersect( + forcedConfigs[key] = intersect( forcedConfigs[key], fb.forcedConfig[key] as ConfigValue[], true @@ -302,7 +302,7 @@ export function areFunboxesCompatible( for (const key in f.forcedConfig) { if (allowedConfig[key]) { if ( - Arrays.intersect( + intersect( allowedConfig[key], f.forcedConfig[key] as ConfigValue[], true diff --git a/frontend/src/ts/test/funbox/funbox.ts b/frontend/src/ts/test/funbox/funbox.ts index 795930673779..b020cb2dfdfc 100644 --- a/frontend/src/ts/test/funbox/funbox.ts +++ b/frontend/src/ts/test/funbox/funbox.ts @@ -2,7 +2,6 @@ import * as Notifications from "../../elements/notifications"; import * as Misc from "../../utils/misc"; import * as JSONData from "../../utils/json-data"; import * as GetText from "../../utils/generate"; -import * as Numbers from "../../utils/numbers"; import * as Arrays from "../../utils/arrays"; import * as Strings from "../../utils/strings"; import * as ManualRestart from "../manual-restart-tracker"; @@ -28,6 +27,7 @@ import * as LayoutfluidFunboxTimer from "./layoutfluid-funbox-timer"; import * as DDR from "../../utils/ddr"; import { HighlightMode } from "@monkeytype/contracts/schemas/configs"; import { Mode } from "@monkeytype/contracts/schemas/shared"; +import { randomIntFromRange } from "@monkeytype/util/numbers"; const prefixSize = 2; @@ -49,7 +49,7 @@ class CharDistribution { } public randomChar(): string { - const randomIndex = Numbers.randomIntFromRange(0, this.count - 1); + const randomIndex = randomIntFromRange(0, this.count - 1); let runningCount = 0; for (const [char, charCount] of Object.entries(this.chars)) { runningCount += charCount; @@ -335,12 +335,12 @@ FunboxList.setFunboxFunctions("58008", { if (Math.random() < 0.5) { word = Strings.replaceCharAt( word, - Numbers.randomIntFromRange(1, word.length - 2), + randomIntFromRange(1, word.length - 2), "." ); } if (Math.random() < 0.75) { - const index = Numbers.randomIntFromRange(1, word.length - 2); + const index = randomIntFromRange(1, word.length - 2); if ( word[index - 1] !== "." && word[index + 1] !== "." && diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index f62f2e823f69..1873c4c5666f 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -1,4 +1,4 @@ -import { mapRange } from "../utils/misc"; +import { mapRange } from "@monkeytype/util/numbers"; import Config from "../config"; import * as ConfigEvent from "../observables/config-event"; import * as TestState from "../test/test-state"; diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 4f48f47846b9..13cf7a15f8d3 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -4,10 +4,10 @@ import Config from "../config"; import * as DB from "../db"; import * as SlowTimer from "../states/slow-timer"; import * as Misc from "../utils/misc"; -import * as Numbers from "../utils/numbers"; import * as JSONData from "../utils/json-data"; import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; +import { convertRemToPixels } from "../utils/numbers"; type Settings = { wpm: number; @@ -235,7 +235,7 @@ export async function update(expectedStepEnd: number): Promise { newTop = word.offsetTop + currentLetter.offsetTop - - Config.fontSize * Numbers.convertRemToPixels(1) * 0.1; + Config.fontSize * convertRemToPixels(1) * 0.1; if (settings.currentLetterIndex === -1) { newLeft = word.offsetLeft + diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 9b8a292eceb5..5f8feabc7724 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -16,7 +16,7 @@ import * as DateTime from "../utils/date-and-time"; import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as JSONData from "../utils/json-data"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import * as Arrays from "../utils/arrays"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as FunboxList from "./funbox/funbox-list"; diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 540facc0b4e3..24606d56c1b7 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -1,6 +1,6 @@ import * as TestWords from "./test-words"; import { lastElementFromArray } from "../utils/arrays"; -import { mean, roundTo2 } from "../utils/numbers"; +import { mean, roundTo2 } from "@monkeytype/util/numbers"; const keysToTrack = [ "NumpadMultiply", diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index cd94e5efceb9..2a293240412f 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -6,7 +6,7 @@ import * as Strings from "../utils/strings"; import * as Misc from "../utils/misc"; import * as Arrays from "../utils/arrays"; import * as JSONData from "../utils/json-data"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import * as Notifications from "../elements/notifications"; import * as CustomText from "./custom-text"; import * as CustomTextState from "../states/custom-text-name"; @@ -704,7 +704,7 @@ function buildCompletedEvent( const stddev = Numbers.stdDev(rawPerSecond); const avg = Numbers.mean(rawPerSecond); - let consistency = Numbers.roundTo2(Misc.kogasa(stddev / avg)); + let consistency = Numbers.roundTo2(Numbers.kogasa(stddev / avg)); let keyConsistencyArray = TestInput.keypressTimings.spacing.array.slice(); if (keyConsistencyArray.length > 0) { keyConsistencyArray = keyConsistencyArray.slice( @@ -713,7 +713,7 @@ function buildCompletedEvent( ); } let keyConsistency = Numbers.roundTo2( - Misc.kogasa( + Numbers.kogasa( Numbers.stdDev(keyConsistencyArray) / Numbers.mean(keyConsistencyArray) ) ); @@ -738,7 +738,7 @@ function buildCompletedEvent( //wpm consistency const stddev3 = Numbers.stdDev(chartData.wpm ?? []); const avg3 = Numbers.mean(chartData.wpm ?? []); - const wpmCons = Numbers.roundTo2(Misc.kogasa(stddev3 / avg3)); + const wpmCons = Numbers.roundTo2(Numbers.kogasa(stddev3 / avg3)); const wpmConsistency = isNaN(wpmCons) ? 0 : wpmCons; let customText: CustomTextDataWithTextLen | undefined = undefined; diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index afcede7a0a26..33b52db43b5a 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -5,7 +5,7 @@ import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as FunboxList from "./funbox/funbox-list"; import * as TestState from "./test-state"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import { CompletedEvent, IncompleteTest, diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 0cc6b60f1bea..0303089bba61 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -9,7 +9,7 @@ import * as TestStats from "./test-stats"; import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as Monkey from "./monkey"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import * as Notifications from "../elements/notifications"; import * as Caret from "./caret"; import * as SlowTimer from "../states/slow-timer"; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 18a04d85c61b..09d1701fe6d9 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -11,7 +11,6 @@ import * as Replay from "./replay"; import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as JSONData from "../utils/json-data"; -import * as Numbers from "../utils/numbers"; import { blendTwoHexColors } from "../utils/colors"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as SlowTimer from "../states/slow-timer"; @@ -32,6 +31,7 @@ import { TimerColor, TimerOpacity, } from "@monkeytype/contracts/schemas/configs"; +import { convertRemToPixels } from "../utils/numbers"; async function gethtml2canvas(): Promise { return (await import("html2canvas")).default; @@ -457,7 +457,7 @@ export async function updateWordsInputPosition(initial = false): Promise { const activeWordMargin = parseInt(computed.marginTop) + parseInt(computed.marginBottom); - const letterHeight = Numbers.convertRemToPixels(Config.fontSize); + const letterHeight = convertRemToPixels(Config.fontSize); const targetTop = activeWord.offsetTop + letterHeight / 2 - el.offsetHeight / 2 + 1; //+1 for half of border @@ -518,7 +518,7 @@ function updateWordsHeight(force = false): void { } $(".outOfFocusWarning").css( "margin-top", - wordHeight + Numbers.convertRemToPixels(1) / 2 + "px" + wordHeight + convertRemToPixels(1) / 2 + "px" ); } else { let finalWordsHeight: number, finalWrapperHeight: number; @@ -578,7 +578,7 @@ function updateWordsHeight(force = false): void { $("#wordsWrapper").css("height", finalWrapperHeight + "px"); $(".outOfFocusWarning").css( "margin-top", - finalWrapperHeight / 2 - Numbers.convertRemToPixels(1) / 2 + "px" + finalWrapperHeight / 2 - convertRemToPixels(1) / 2 + "px" ); }, 0); } @@ -699,8 +699,8 @@ export async function screenshot(): Promise { true ) as number; /*clientHeight/offsetHeight from div#target*/ try { - const paddingX = Numbers.convertRemToPixels(2); - const paddingY = Numbers.convertRemToPixels(2); + const paddingX = convertRemToPixels(2); + const paddingY = convertRemToPixels(2); const canvas = await ( await gethtml2canvas() diff --git a/frontend/src/ts/utils/arrays.ts b/frontend/src/ts/utils/arrays.ts index b7b857ab49a1..f7129181d3e7 100644 --- a/frontend/src/ts/utils/arrays.ts +++ b/frontend/src/ts/utils/arrays.ts @@ -1,4 +1,4 @@ -import { randomIntFromRange } from "./numbers"; +import { randomIntFromRange } from "@monkeytype/util/numbers"; /** * Applies a smoothing algorithm to an array of numbers. @@ -99,19 +99,3 @@ export function nthElementFromArray( index = index < 0 ? array.length + index : index; return array[index]; } - -/** - * Returns the intersection of two arrays, i.e., the elements that are present in both arrays. - * @param a First array. - * @param b Second array. - * @returns An array containing the elements that are present in both input arrays. - */ -export function intersect(a: T[], b: T[], removeDuplicates = false): T[] { - let t; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter - const filtered = a.filter(function (e) { - return b.includes(e); - }); - return removeDuplicates ? [...new Set(filtered)] : filtered; -} diff --git a/frontend/src/ts/utils/date-and-time.ts b/frontend/src/ts/utils/date-and-time.ts index a5f8cbcd1bfd..d04a107644c3 100644 --- a/frontend/src/ts/utils/date-and-time.ts +++ b/frontend/src/ts/utils/date-and-time.ts @@ -1,62 +1,4 @@ -import { roundTo2 } from "./numbers"; - -/** - * Returns the current day's timestamp adjusted by the hour offset. - * @param hourOffset The offset in hours. Default is 0. - * @returns The timestamp of the start of the current day adjusted by the hour offset. - */ -export function getCurrentDayTimestamp(hourOffset = 0): number { - const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; - const currentTime = Date.now(); - return getStartOfDayTimestamp(currentTime, offsetMilis); -} - -const MILISECONDS_IN_HOUR = 3600000; -const MILLISECONDS_IN_DAY = 86400000; - -/** - * Returns the timestamp of the start of the day for the given timestamp adjusted by the offset. - * @param timestamp The timestamp for which to get the start of the day. - * @param offsetMilis The offset in milliseconds. Default is 0. - * @returns The timestamp of the start of the day for the given timestamp adjusted by the offset. - */ -export function getStartOfDayTimestamp( - timestamp: number, - offsetMilis = 0 -): number { - return timestamp - ((timestamp - offsetMilis) % MILLISECONDS_IN_DAY); -} - -/** - * Checks if the given timestamp is from yesterday, adjusted by the hour offset. - * @param timestamp The timestamp to check. - * @param hourOffset The offset in hours. Default is 0. - * @returns True if the timestamp is from yesterday, false otherwise. - */ -export function isYesterday(timestamp: number, hourOffset = 0): boolean { - const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; - const yesterday = getStartOfDayTimestamp( - Date.now() - MILLISECONDS_IN_DAY, - offsetMilis - ); - const date = getStartOfDayTimestamp(timestamp, offsetMilis); - - return yesterday === date; -} - -/** - * Checks if the given timestamp is from today, adjusted by the hour offset. - * @param timestamp The timestamp to check. - * @param hourOffset The offset in hours. Default is 0. - * @returns True if the timestamp is from today, false otherwise. - */ -export function isToday(timestamp: number, hourOffset = 0): boolean { - const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; - const today = getStartOfDayTimestamp(Date.now(), offsetMilis); - const date = getStartOfDayTimestamp(timestamp, offsetMilis); - - return today === date; -} +import { roundTo2 } from "@monkeytype/util/numbers"; /** * Converts seconds to a human-readable string representation of time. diff --git a/frontend/src/ts/utils/format.ts b/frontend/src/ts/utils/format.ts index 1fb4066591de..8c908daf8103 100644 --- a/frontend/src/ts/utils/format.ts +++ b/frontend/src/ts/utils/format.ts @@ -1,5 +1,5 @@ import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; -import * as Numbers from "../utils/numbers"; +import * as Numbers from "@monkeytype/util/numbers"; import { Config as ConfigType } from "@monkeytype/contracts/schemas/configs"; import Config from "../config"; diff --git a/frontend/src/ts/utils/generate.ts b/frontend/src/ts/utils/generate.ts index b894abbeddfc..3c9b0678268b 100644 --- a/frontend/src/ts/utils/generate.ts +++ b/frontend/src/ts/utils/generate.ts @@ -1,4 +1,4 @@ -import { randomIntFromRange } from "./numbers"; +import { randomIntFromRange } from "@monkeytype/util/numbers"; import * as Arrays from "./arrays"; import * as Strings from "./strings"; diff --git a/frontend/src/ts/utils/ip-addresses.ts b/frontend/src/ts/utils/ip-addresses.ts index c147d339db5d..cf88850cd030 100644 --- a/frontend/src/ts/utils/ip-addresses.ts +++ b/frontend/src/ts/utils/ip-addresses.ts @@ -1,4 +1,4 @@ -import { randomIntFromRange } from "../utils/numbers"; +import { randomIntFromRange } from "@monkeytype/util/numbers"; function getRandomIPvXaddress( bits: number, diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index fa550f2c9d00..bc785a17299d 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -10,12 +10,6 @@ import { } from "@monkeytype/contracts/schemas/shared"; import { ZodError, ZodSchema } from "zod"; -export function kogasa(cov: number): number { - return ( - 100 * (1 - Math.tanh(cov + Math.pow(cov, 3) / 3 + Math.pow(cov, 5) / 5)) - ); -} - export function whorf(speed: number, wordlen: number): number { return Math.min( speed, @@ -198,31 +192,6 @@ export function isUsernameValid(name: string): boolean { return /^[0-9a-zA-Z_.-]+$/.test(name); } -export function mapRange( - x: number, - in_min: number, - in_max: number, - out_min: number, - out_max: number -): number { - let num = ((x - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min; - - if (out_min > out_max) { - if (num > out_min) { - num = out_min; - } else if (num < out_max) { - num = out_max; - } - } else { - if (num < out_min) { - num = out_min; - } else if (num > out_max) { - num = out_max; - } - } - return num; -} - export function canQuickRestart( mode: string, words: number, diff --git a/frontend/src/ts/utils/numbers.ts b/frontend/src/ts/utils/numbers.ts index 847ae3f62c44..d4a831f8e91f 100644 --- a/frontend/src/ts/utils/numbers.ts +++ b/frontend/src/ts/utils/numbers.ts @@ -1,70 +1,5 @@ -/** - * Calculates the standard deviation of an array of numbers. - * @param array An array of numbers. - * @returns The standard deviation of the input array. - */ -export function stdDev(array: number[]): number { - try { - const n = array.length; - const mean = array.reduce((a, b) => a + b) / n; - return Math.sqrt( - array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n - ); - } catch (e) { - return 0; - } -} +import { roundTo2 } from "@monkeytype/util/numbers"; -/** - * Calculates the mean (average) of an array of numbers. - * @param array An array of numbers. - * @returns The mean of the input array. - */ -export function mean(array: number[]): number { - try { - return ( - array.reduce((previous, current) => (current += previous)) / array.length - ); - } catch (e) { - return 0; - } -} - -/** - * Calculates the median of an array of numbers. - * https://www.w3resource.com/javascript-exercises/fundamental/javascript-fundamental-exercise-88.php - * @param arr An array of numbers. - * @returns The median of the input array. - */ -export function median(arr: number[]): number { - try { - const mid = Math.floor(arr.length / 2), - nums = [...arr].sort((a, b) => a - b); - return arr.length % 2 !== 0 - ? (nums[mid] as number) - : ((nums[mid - 1] as number) + (nums[mid] as number)) / 2; - } catch (e) { - return 0; - } -} - -/** - * Rounds a number to one decimal places. - * @param num The number to round. - * @returns The input number rounded to one decimal places. - */ -export function roundTo1(num: number): number { - return Math.round((num + Number.EPSILON) * 10) / 10; -} - -/** - * Rounds a number to two decimal places. - * @param num The number to round. - * @returns The input number rounded to two decimal places. - */ -export function roundTo2(num: number): number { - return Math.round((num + Number.EPSILON) * 100) / 100; -} /** * Converts a value in rem units to pixels based on the root element's font size. * https://stackoverflow.com/questions/36532307/rem-px-in-javascript @@ -84,18 +19,6 @@ export function numberWithSpaces(x: number): string { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); } -/** - * Gets an integer between min and max, both are inclusive. - * @param min - * @param max - * @returns Random integer betwen min and max. - */ -export function randomIntFromRange(min: number, max: number): number { - const minNorm = Math.ceil(min); - const maxNorm = Math.floor(max); - return Math.floor(Math.random() * (maxNorm - minNorm + 1) + minNorm); -} - /** * Converts a number into a rounded form with its order of magnitude. * @param num The number to convert. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d5b181ea6ecd..bef3d167b56b 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -2,8 +2,8 @@ "name": "@monkeytype/contracts", "private": true, "scripts": { - "dev": "rimraf ./dist && node esbuild.config.js --watch", - "build": "rimraf ./dist && npm run madge && node esbuild.config.js", + "dev": "rimraf ./dist && monkeytype-esbuild --watch", + "build": "rimraf ./dist && npm run madge && monkeytype-esbuild", "test": "vitest run", "madge": " madge --circular --extensions ts ./src", "ts-check": "tsc --noEmit", @@ -14,10 +14,10 @@ "zod": "3.23.8" }, "devDependencies": { + "@monkeytype/esbuild": "workspace:*", "@monkeytype/eslint-config": "workspace:*", "@monkeytype/typescript-config": "workspace:*", "chokidar": "3.6.0", - "esbuild": "0.23.0", "eslint": "8.57.0", "madge": "8.0.0", "rimraf": "6.0.1", diff --git a/packages/esbuild-config/.eslintrc.cjs b/packages/esbuild-config/.eslintrc.cjs new file mode 100644 index 000000000000..922de4abe568 --- /dev/null +++ b/packages/esbuild-config/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@monkeytype/eslint-config"], +}; diff --git a/packages/contracts/esbuild.config.js b/packages/esbuild-config/index.js old mode 100644 new mode 100755 similarity index 100% rename from packages/contracts/esbuild.config.js rename to packages/esbuild-config/index.js diff --git a/packages/esbuild-config/package.json b/packages/esbuild-config/package.json new file mode 100644 index 000000000000..5cc769fb361c --- /dev/null +++ b/packages/esbuild-config/package.json @@ -0,0 +1,14 @@ +{ + "name": "@monkeytype/esbuild", + "private": true, + "scripts": { + "lint": "eslint \"./**/*.js\"" + }, + "bin": { + "monkeytype-esbuild": "./index.js" + }, + "devDependencies": { + "esbuild": "0.23.0", + "eslint": "8.57.0" + } +} diff --git a/packages/util/.eslintrc.cjs b/packages/util/.eslintrc.cjs new file mode 100644 index 000000000000..922de4abe568 --- /dev/null +++ b/packages/util/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@monkeytype/eslint-config"], +}; diff --git a/packages/util/__test__/arrays.spec.ts b/packages/util/__test__/arrays.spec.ts new file mode 100644 index 000000000000..2fbb64d10aac --- /dev/null +++ b/packages/util/__test__/arrays.spec.ts @@ -0,0 +1,48 @@ +import * as Arrays from "../src/arrays"; + +describe("arrays", () => { + it("intersect", () => { + const testCases = [ + { + a: [1], + b: [2], + removeDuplicates: false, + expected: [], + }, + { + a: [1], + b: [1], + removeDuplicates: false, + expected: [1], + }, + { + a: [1, 1], + b: [1], + removeDuplicates: true, + expected: [1], + }, + { + a: [1, 1], + b: [1], + removeDuplicates: false, + expected: [1, 1], + }, + { + a: [1], + b: [1, 2, 3], + removeDuplicates: false, + expected: [1], + }, + { + a: [1, 1], + b: [1, 2, 3], + removeDuplicates: true, + expected: [1], + }, + ]; + + testCases.forEach(({ a, b, removeDuplicates, expected }) => { + expect(Arrays.intersect(a, b, removeDuplicates)).toEqual(expected); + }); + }); +}); diff --git a/packages/util/__test__/date-and-time.spec.ts b/packages/util/__test__/date-and-time.spec.ts new file mode 100644 index 000000000000..e199dc238a54 --- /dev/null +++ b/packages/util/__test__/date-and-time.spec.ts @@ -0,0 +1,222 @@ +import * as DateAndTime from "../src/date-and-time"; + +describe("date-and-time", () => { + afterAll(() => { + vi.useRealTimers(); + }); + it("getCurrentDayTimestamp", () => { + vi.useFakeTimers(); + vi.setSystemTime(1652743381); + + const currentDay = DateAndTime.getCurrentDayTimestamp(); + expect(currentDay).toBe(1641600000); + }); + it("getStartOfWeekTimestamp", () => { + const testCases = [ + { + input: 1662400184017, // Mon Sep 05 2022 17:49:44 GMT+0000 + expected: 1662336000000, // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: 1559771456000, // Wed Jun 05 2019 21:50:56 GMT+0000 + expected: 1559520000000, // Mon Jun 03 2019 00:00:00 GMT+0000 + }, + { + input: 1465163456000, // Sun Jun 05 2016 21:50:56 GMT+0000 + expected: 1464566400000, // Mon May 30 2016 00:00:00 GMT+0000 + }, + { + input: 1491515456000, // Thu Apr 06 2017 21:50:56 GMT+0000 + expected: 1491177600000, // Mon Apr 03 2017 00:00:00 GMT+0000 + }, + { + input: 1462507200000, // Fri May 06 2016 04:00:00 GMT+0000 + expected: 1462147200000, // Mon May 02 2016 00:00:00 GMT+0000 + }, + { + input: 1231218000000, // Tue Jan 06 2009 05:00:00 GMT+0000, + expected: 1231113600000, // Mon Jan 05 2009 00:00:00 GMT+0000 + }, + { + input: 1709420681000, // Sat Mar 02 2024 23:04:41 GMT+0000 + expected: 1708905600000, // Mon Feb 26 2024 00:00:00 GMT+0000 + }, + ]; + + testCases.forEach(({ input, expected }) => { + expect(DateAndTime.getStartOfWeekTimestamp(input)).toEqual(expected); + }); + }); + it("getCurrentWeekTimestamp", () => { + Date.now = vi.fn(() => 825289481000); // Sun Feb 25 1996 23:04:41 GMT+0000 + + const currentWeek = DateAndTime.getCurrentWeekTimestamp(); + expect(currentWeek).toBe(824688000000); // Mon Feb 19 1996 00:00:00 GMT+0000 + }); + it("getStartOfDayTimestamp", () => { + const testCases = [ + { + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 0, + expected: new Date("2023/06/16 00:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 1, + expected: new Date("2023/06/16 01:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: -1, + expected: new Date("2023/06/15 23:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: -4, + expected: new Date("2023/06/15 20:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 4, + expected: new Date("2023/06/16 04:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/17 03:00 UTC").getTime(), + offset: 4, + expected: new Date("2023/06/16 04:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 3, + expected: new Date("2023/06/16 03:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: new Date("2023/06/17 01:00 UTC").getTime(), + offset: 3, + expected: new Date("2023/06/16 03:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + ]; + + testCases.forEach(({ input, offset, expected }) => { + expect( + DateAndTime.getStartOfDayTimestamp(input, offset * 3600000) + ).toEqual(expected); + }); + }); + + it("isToday", () => { + const testCases = [ + { + now: new Date("2023/06/16 15:00 UTC").getTime(), + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 0, + expected: true, + }, + { + now: new Date("2023/06/16 15:00 UTC").getTime(), + input: new Date("2023/06/17 1:00 UTC").getTime(), + offset: 0, + expected: false, + }, + { + now: new Date("2023/06/16 15:00 UTC").getTime(), + input: new Date("2023/06/16 01:00 UTC").getTime(), + offset: 1, + expected: true, + }, + { + now: new Date("2023/06/16 15:00 UTC").getTime(), + input: new Date("2023/06/17 01:00 UTC").getTime(), + offset: 2, + expected: true, + }, + { + now: new Date("2023/06/16 15:00 UTC").getTime(), + input: new Date("2023/06/16 01:00 UTC").getTime(), + offset: 2, + expected: false, + }, + { + now: new Date("2023/06/17 01:00 UTC").getTime(), + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 2, + expected: true, + }, + { + now: new Date("2023/06/17 01:00 UTC").getTime(), + input: new Date("2023/06/17 02:00 UTC").getTime(), + offset: 2, + expected: false, + }, + ]; + + testCases.forEach(({ now, input, offset, expected }) => { + Date.now = vi.fn(() => now); + expect(DateAndTime.isToday(input, offset)).toEqual(expected); + }); + }); + + it("isYesterday", () => { + const testCases = [ + { + now: new Date("2023/06/15 15:00 UTC").getTime(), + input: new Date("2023/06/14 15:00 UTC").getTime(), + offset: 0, + expected: true, + }, + { + now: new Date("2023/06/15 15:00 UTC").getTime(), + input: new Date("2023/06/15 15:00 UTC").getTime(), + offset: 0, + expected: false, + }, + { + now: new Date("2023/06/15 15:00 UTC").getTime(), + input: new Date("2023/06/16 15:00 UTC").getTime(), + offset: 0, + expected: false, + }, + { + now: new Date("2023/06/15 15:00 UTC").getTime(), + input: new Date("2023/06/13 15:00 UTC").getTime(), + offset: 0, + expected: false, + }, + { + now: new Date("2023/06/16 02:00 UTC").getTime(), + input: new Date("2023/06/15 02:00 UTC").getTime(), + offset: 4, + expected: true, + }, + { + now: new Date("2023/06/16 02:00 UTC").getTime(), + input: new Date("2023/06/16 01:00 UTC").getTime(), + offset: 4, + expected: false, + }, + { + now: new Date("2023/06/16 02:00 UTC").getTime(), + input: new Date("2023/06/15 22:00 UTC").getTime(), + offset: 4, + expected: false, + }, + { + now: new Date("2023/06/16 04:00 UTC").getTime(), + input: new Date("2023/06/16 03:00 UTC").getTime(), + offset: 4, + expected: true, + }, + { + now: new Date("2023/06/16 14:00 UTC").getTime(), + input: new Date("2023/06/16 12:00 UTC").getTime(), + offset: -11, + expected: true, + }, + ]; + + testCases.forEach(({ now, input, offset, expected }) => { + Date.now = vi.fn(() => now); + expect(DateAndTime.isYesterday(input, offset)).toEqual(expected); + }); + }); +}); diff --git a/packages/util/__test__/numbers.spec.ts b/packages/util/__test__/numbers.spec.ts new file mode 100644 index 000000000000..5ad69b73dca9 --- /dev/null +++ b/packages/util/__test__/numbers.spec.ts @@ -0,0 +1,100 @@ +import * as Numbers from "../src/numbers"; + +describe("numbers", () => { + describe("roundTo1", () => { + it("should correctly round", () => { + const tests = [ + { + in: 0.0, + out: 0, + }, + { + in: 0.01, + out: 0.0, + }, + { + in: 0.09, + out: 0.1, + }, + { + in: 0.123, + out: 0.1, + }, + { + in: 0.456, + out: 0.5, + }, + { + in: 0.789, + out: 0.8, + }, + ]; + + tests.forEach((test) => { + expect(Numbers.roundTo1(test.in)).toBe(test.out); + }); + }); + + it("mapRange", () => { + const testCases = [ + { + input: { + value: 123, + inMin: 0, + inMax: 200, + outMin: 0, + outMax: 1000, + clamp: false, + }, + expected: 615, + }, + { + input: { + value: 123, + inMin: 0, + inMax: 200, + outMin: 1000, + outMax: 0, + clamp: false, + }, + expected: 385, + }, + { + input: { + value: 10001, + inMin: 0, + inMax: 10000, + outMin: 0, + outMax: 1000, + clamp: false, + }, + expected: 1000.1, + }, + { + input: { + value: 10001, + inMin: 0, + inMax: 10000, + outMin: 0, + outMax: 1000, + clamp: true, + }, + expected: 1000, + }, + ]; + + testCases.forEach(({ input, expected }) => { + expect( + Numbers.mapRange( + input.value, + input.inMin, + input.inMax, + input.outMin, + input.outMax, + input.clamp + ) + ).toEqual(expected); + }); + }); + }); +}); diff --git a/packages/util/__test__/tsconfig.json b/packages/util/__test__/tsconfig.json new file mode 100644 index 000000000000..8d8a39621301 --- /dev/null +++ b/packages/util/__test__/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@monkeytype/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "types": ["vitest/globals"] + }, + "ts-node": { + "files": true + }, + // "files": ["../src/types/types.d.ts"], + "include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"] +} diff --git a/packages/util/package.json b/packages/util/package.json new file mode 100644 index 000000000000..d16623d1fde1 --- /dev/null +++ b/packages/util/package.json @@ -0,0 +1,35 @@ +{ + "name": "@monkeytype/util", + "private": true, + "scripts": { + "dev": "rimraf ./dist && monkeytype-esbuild --watch", + "build": "rimraf ./dist && npm run madge && monkeytype-esbuild", + "test": "vitest run", + "madge": " madge --circular --extensions ts ./src", + "ts-check": "tsc --noEmit", + "lint": "eslint \"./**/*.ts\"" + }, + "devDependencies": { + "@monkeytype/esbuild": "workspace:*", + "@monkeytype/eslint-config": "workspace:*", + "@monkeytype/typescript-config": "workspace:*", + "chokidar": "3.6.0", + "eslint": "8.57.0", + "madge": "8.0.0", + "rimraf": "6.0.1", + "typescript": "5.5.4", + "vitest": "2.0.5" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./*": { + "types": "./src/*.ts", + "import": "./dist/*.mjs", + "require": "./dist/*.cjs" + } + } +} diff --git a/packages/util/src/arrays.ts b/packages/util/src/arrays.ts new file mode 100644 index 000000000000..95349f66d87f --- /dev/null +++ b/packages/util/src/arrays.ts @@ -0,0 +1,15 @@ +/** + * Returns the intersection of two arrays, i.e., the elements that are present in both arrays. + * @param a First array. + * @param b Second array. + * @returns An array containing the elements that are present in both input arrays. + */ +export function intersect(a: T[], b: T[], removeDuplicates = false): T[] { + let t; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter + const filtered = a.filter(function (e) { + return b.includes(e); + }); + return removeDuplicates ? [...new Set(filtered)] : filtered; +} diff --git a/packages/util/src/date-and-time.ts b/packages/util/src/date-and-time.ts new file mode 100644 index 000000000000..0dfa2d5a9425 --- /dev/null +++ b/packages/util/src/date-and-time.ts @@ -0,0 +1,80 @@ +export const MILISECONDS_IN_HOUR = 3600000; +export const MILLISECONDS_IN_DAY = 86400000; + +/** + * Returns the current day's start timestamp adjusted by the hour offset. + * @param hourOffset The offset in hours. Default is 0. + * @returns The timestamp of the start of the current day adjusted by the hour offset. + */ +export function getCurrentDayTimestamp(hourOffset = 0): number { + const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; + const currentTime = Date.now(); + return getStartOfDayTimestamp(currentTime, offsetMilis); +} + +/** + * Returns the timestamp of the start of the day for the given timestamp adjusted by the offset. + * @param timestamp The timestamp for which to get the start of the day. + * @param offsetMilis The offset in milliseconds. Default is 0. + * @returns The timestamp of the start of the day for the given timestamp adjusted by the offset. + */ +export function getStartOfDayTimestamp( + timestamp: number, + offsetMilis = 0 +): number { + return timestamp - ((timestamp - offsetMilis) % MILLISECONDS_IN_DAY); +} + +/** + * Checks if the given timestamp is from yesterday, adjusted by the hour offset. + * @param timestamp The timestamp to check. + * @param hourOffset The offset in hours. Default is 0. + * @returns True if the timestamp is from yesterday, false otherwise. + */ +export function isYesterday(timestamp: number, hourOffset = 0): boolean { + const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; + const yesterday = getStartOfDayTimestamp( + Date.now() - MILLISECONDS_IN_DAY, + offsetMilis + ); + const date = getStartOfDayTimestamp(timestamp, offsetMilis); + + return yesterday === date; +} + +/** + * Checks if the given timestamp is from today, adjusted by the hour offset. + * @param timestamp The timestamp to check. + * @param hourOffset The offset in hours. Default is 0. + * @returns True if the timestamp is from today, false otherwise. + */ +export function isToday(timestamp: number, hourOffset = 0): boolean { + const offsetMilis = hourOffset * MILISECONDS_IN_HOUR; + const today = getStartOfDayTimestamp(Date.now(), offsetMilis); + const date = getStartOfDayTimestamp(timestamp, offsetMilis); + + return today === date; +} + +/** + * Gets the timestamp of the start of the week for the given timestamp. + * @param timestamp The timestamp for which to get the start of the week. + * @returns The timestamp of the start of the week for the given timestamp. + */ +export function getStartOfWeekTimestamp(timestamp: number): number { + const date = new Date(getStartOfDayTimestamp(timestamp)); + + const monday = date.getDate() - (date.getDay() || 7) + 1; + date.setDate(monday); + + return getStartOfDayTimestamp(date.getTime()); +} + +/** + * Gets the current week's start timestamp. + * @returns The timestamp of the start of the current week. + */ +export function getCurrentWeekTimestamp(): number { + const currentTime = Date.now(); + return getStartOfWeekTimestamp(currentTime); +} diff --git a/packages/util/src/numbers.ts b/packages/util/src/numbers.ts new file mode 100644 index 000000000000..aa090ca6b30a --- /dev/null +++ b/packages/util/src/numbers.ts @@ -0,0 +1,127 @@ +/** + * Rounds a number to one decimal places. + * @param num The number to round. + * @returns The input number rounded to one decimal places. + */ +export function roundTo1(num: number): number { + return Math.round((num + Number.EPSILON) * 10) / 10; +} + +/** + * Rounds a number to two decimal places. + * @param num The number to round. + * @returns The input number rounded to two decimal places. + */ +export function roundTo2(num: number): number { + return Math.round((num + Number.EPSILON) * 100) / 100; +} + +/** + * Calculates the standard deviation of an array of numbers. + * @param array An array of numbers. + * @returns The standard deviation of the input array. + */ +export function stdDev(array: number[]): number { + try { + const n = array.length; + const mean = array.reduce((a, b) => a + b) / n; + return Math.sqrt( + array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n + ); + } catch (e) { + return 0; + } +} + +/** + * Calculates the mean (average) of an array of numbers. + * @param array An array of numbers. + * @returns The mean of the input array. + */ +export function mean(array: number[]): number { + try { + return ( + array.reduce((previous, current) => (current += previous)) / array.length + ); + } catch (e) { + return 0; + } +} + +/** + * Calculates the median of an array of numbers. + * https://www.w3resource.com/javascript-exercises/fundamental/javascript-fundamental-exercise-88.php + * @param arr An array of numbers. + * @returns The median of the input array. + */ +export function median(arr: number[]): number { + try { + const mid = Math.floor(arr.length / 2), + nums = [...arr].sort((a, b) => a - b); + return arr.length % 2 !== 0 + ? (nums[mid] as number) + : ((nums[mid - 1] as number) + (nums[mid] as number)) / 2; + } catch (e) { + return 0; + } +} + +/** + * Calculates consistency by mapping COV from [0, +infinity) to [100, 0). + * The mapping function is a version of the sigmoid function tanh(x) that is closer to the identity function tanh(arctanh(x)) in [0, 1). + * @param cov The coefficient of variation of an array of numbers (standard deviation / mean). + * @returns Consistency + */ +export function kogasa(cov: number): number { + return ( + 100 * (1 - Math.tanh(cov + Math.pow(cov, 3) / 3 + Math.pow(cov, 5) / 5)) + ); +} + +/** + * Gets an integer between min and max, both are inclusive. + * @param min + * @param max + * @returns Random integer betwen min and max. + */ +export function randomIntFromRange(min: number, max: number): number { + const minNorm = Math.ceil(min); + const maxNorm = Math.floor(max); + return Math.floor(Math.random() * (maxNorm - minNorm + 1) + minNorm); +} + +/** + * Maps a value from one range to another. + * @param value The value to map. + * @param inMin Input range minimum. + * @param inMax Input range maximum. + * @param outMin Output range minimum. + * @param outMax Output range maximum. + * @param clamp If true, the result is clamped to the output range. Default true. + * @returns The mapped value. + */ +export function mapRange( + value: number, + inMin: number, + inMax: number, + outMin: number, + outMax: number, + clamp = true +): number { + if (inMin === inMax) { + return outMin; + } + + const result = + ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; + + if (clamp) { + if (outMin < outMax) { + return Math.min(Math.max(result, outMin), outMax); + } else { + return Math.max(Math.min(result, outMin), outMax); + } + } + + return result; +} diff --git a/packages/util/tsconfig.json b/packages/util/tsconfig.json new file mode 100644 index 000000000000..e632a1d38f12 --- /dev/null +++ b/packages/util/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@monkeytype/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "moduleResolution": "Node", + "module": "ES6", + "target": "ES2015", + "lib": ["es2016"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/util/vitest.config.js b/packages/util/vitest.config.js new file mode 100644 index 000000000000..d071c79ce671 --- /dev/null +++ b/packages/util/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + include: ["**/*.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 837d220caae6..b7298094f1c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@monkeytype/contracts': specifier: workspace:* version: link:../packages/contracts + '@monkeytype/util': + specifier: workspace:* + version: link:../packages/util '@ts-rest/core': specifier: 3.51.0 version: 3.51.0(@types/node@20.14.11)(zod@3.23.8) @@ -264,6 +267,9 @@ importers: '@monkeytype/contracts': specifier: workspace:* version: link:../packages/contracts + '@monkeytype/util': + specifier: workspace:* + version: link:../packages/util '@ts-rest/core': specifier: 3.51.0 version: 3.51.0(@types/node@20.14.11)(zod@3.23.8) @@ -455,6 +461,9 @@ importers: specifier: 3.23.8 version: 3.23.8 devDependencies: + '@monkeytype/esbuild': + specifier: workspace:* + version: link:../esbuild-config '@monkeytype/eslint-config': specifier: workspace:* version: link:../eslint-config @@ -464,9 +473,6 @@ importers: chokidar: specifier: 3.6.0 version: 3.6.0 - esbuild: - specifier: 0.23.0 - version: 0.23.0 eslint: specifier: 8.57.0 version: 8.57.0 @@ -483,6 +489,15 @@ importers: specifier: 2.0.5 version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + packages/esbuild-config: + devDependencies: + esbuild: + specifier: 0.23.0 + version: 0.23.0 + eslint: + specifier: 8.57.0 + version: 8.57.0 + packages/eslint-config: devDependencies: '@typescript-eslint/eslint-plugin': @@ -531,6 +546,36 @@ importers: packages/typescript-config: {} + packages/util: + devDependencies: + '@monkeytype/esbuild': + specifier: workspace:* + version: link:../esbuild-config + '@monkeytype/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@monkeytype/typescript-config': + specifier: workspace:* + version: link:../typescript-config + chokidar: + specifier: 3.6.0 + version: 3.6.0 + eslint: + specifier: 8.57.0 + version: 8.57.0 + madge: + specifier: 8.0.0 + version: 8.0.0(typescript@5.5.4) + rimraf: + specifier: 6.0.1 + version: 6.0.1 + typescript: + specifier: 5.5.4 + version: 5.5.4 + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) + packages: '@ampproject/remapping@2.3.0':