Merge branch 'master' into newtribemerge

This commit is contained in:
Miodec 2024-08-13 18:23:44 +02:00
commit b3865152e4
96 changed files with 3216 additions and 1717 deletions

View file

@ -2,6 +2,9 @@
"recommendations": [
"esbenp.prettier-vscode",
"vitest.explorer",
"huntertran.auto-markdown-toc"
"huntertran.auto-markdown-toc",
"ms-vscode.vscode-typescript-next",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

View file

@ -0,0 +1,37 @@
import { Configuration } from "@monkeytype/shared-types";
import { randomBytes } from "crypto";
import { hash } from "bcrypt";
import { ObjectId } from "mongodb";
import { base64UrlEncode } from "../../src/utils/misc";
import * as ApeKeyDal from "../../src/dal/ape-keys";
export async function mockAuthenticateWithApeKey(
uid: string,
config: Configuration
): Promise<string> {
if (!config.apeKeys.acceptKeys)
throw Error("config.apeKeys.acceptedKeys needs to be set to true");
const { apeKeyBytes, apeKeySaltRounds } = config.apeKeys;
const apiKey = randomBytes(apeKeyBytes).toString("base64url");
const saltyHash = await hash(apiKey, apeKeySaltRounds);
const apeKey: MonkeyTypes.ApeKeyDB = {
_id: new ObjectId(),
name: "bob",
enabled: true,
uid,
hash: saltyHash,
createdOn: Date.now(),
modifiedOn: Date.now(),
lastUsedOn: -1,
useCount: 0,
};
const apeKeyId = new ObjectId().toHexString();
vi.spyOn(ApeKeyDal, "getApeKey").mockResolvedValue(apeKey);
vi.spyOn(ApeKeyDal, "updateLastUsedOn").mockResolvedValue();
return base64UrlEncode(`${apeKeyId}.${apiKey}`);
}

View file

@ -15,7 +15,7 @@ describe("ApeKeyController", () => {
beforeEach(async () => {
await enableApeKeysEndpoints(true);
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: true }));
getUserMock.mockResolvedValue(user(uid, {}));
vi.useFakeTimers();
vi.setSystemTime(1000);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,144 @@
import request from "supertest";
import app from "../../../src/app";
import * as PublicDal from "../../../src/dal/public";
const mockApp = request(app);
describe("PublicController", () => {
describe("get speed histogram", () => {
const getSpeedHistogramMock = vi.spyOn(PublicDal, "getSpeedHistogram");
afterEach(() => {
getSpeedHistogramMock.mockReset();
});
it("gets for english time 60", async () => {
//GIVEN
getSpeedHistogramMock.mockResolvedValue({ "0": 1, "10": 2 });
//WHEN
const { body } = await mockApp
.get("/public/speedHistogram")
.query({ language: "english", mode: "time", mode2: "60" });
//.expect(200);
console.log(body);
//THEN
expect(body).toEqual({
message: "Public speed histogram retrieved",
data: { "0": 1, "10": 2 },
});
expect(getSpeedHistogramMock).toHaveBeenCalledWith(
"english",
"time",
"60"
);
});
it("gets for mode", async () => {
for (const mode of ["time", "words", "quote", "zen", "custom"]) {
const response = await mockApp
.get("/public/speedHistogram")
.query({ language: "english", mode, mode2: "custom" });
expect(response.status, "for mode " + mode).toEqual(200);
}
});
it("gets for mode2", async () => {
for (const mode2 of [
"10",
"25",
"50",
"100",
"15",
"30",
"60",
"120",
"zen",
"custom",
]) {
const response = await mockApp
.get("/public/speedHistogram")
.query({ language: "english", mode: "words", mode2 });
expect(response.status, "for mode2 " + mode2).toEqual(200);
}
});
it("fails for missing query", async () => {
const { body } = await mockApp.get("/public/speedHistogram").expect(422);
expect(body).toEqual({
message: "Invalid query schema",
validationErrors: [
'"language" Required',
'"mode" Required',
'"mode2" Needs to be either a number, "zen" or "custom."',
],
});
});
it("fails for invalid query", async () => {
const { body } = await mockApp
.get("/public/speedHistogram")
.query({
language: "en?gli.sh",
mode: "unknownMode",
mode2: "unknownMode2",
})
.expect(422);
expect(body).toEqual({
message: "Invalid query schema",
validationErrors: [
'"language" Can only contain letters [a-zA-Z0-9_+]',
`"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`,
'"mode2" Needs to be a number or a number represented as a string e.g. "10".',
],
});
});
it("fails for unknown query", async () => {
const { body } = await mockApp
.get("/public/speedHistogram")
.query({
language: "english",
mode: "time",
mode2: "60",
extra: "value",
})
.expect(422);
expect(body).toEqual({
message: "Invalid query schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
});
describe("get typing stats", () => {
const getTypingStatsMock = vi.spyOn(PublicDal, "getTypingStats");
afterEach(() => {
getTypingStatsMock.mockReset();
});
it("gets without authentication", async () => {
//GIVEN
getTypingStatsMock.mockResolvedValue({
testsCompleted: 23,
testsStarted: 42,
timeTyping: 1000,
} as any);
//WHEN
const { body } = await mockApp.get("/public/typingStats").expect(200);
//THEN
expect(body).toEqual({
message: "Public typing stats retrieved",
data: {
testsCompleted: 23,
testsStarted: 42,
timeTyping: 1000,
},
});
});
});
});

View file

@ -4,6 +4,8 @@ import * as UserDal from "../../src/dal/user";
import * as LeaderboardsDal from "../../src/dal/leaderboards";
import * as PublicDal from "../../src/dal/public";
import * as Configuration from "../../src/init/configuration";
import type { DBLeaderboardEntry } from "../../src/dal/leaderboards";
import type { PersonalBest } from "@monkeytype/contracts/schemas/shared";
const configuration = Configuration.getCachedConfiguration();
import * as DB from "../../src/init/db";
@ -29,7 +31,9 @@ describe("LeaderboardsDal", () => {
//THEN
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("uid", applicableUser.uid);
expect(
(result as LeaderboardsDal.DBLeaderboardEntry[])[0]
).toHaveProperty("uid", applicableUser.uid);
});
it("should create leaderboard time english 15", async () => {
@ -46,7 +50,7 @@ describe("LeaderboardsDal", () => {
"15",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as DBLeaderboardEntry[];
//THEN
const lb = result.map((it) => _.omit(it, ["_id"]));
@ -72,7 +76,7 @@ describe("LeaderboardsDal", () => {
"60",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as LeaderboardsDal.DBLeaderboardEntry[];
//THEN
const lb = result.map((it) => _.omit(it, ["_id"]));
@ -98,7 +102,7 @@ describe("LeaderboardsDal", () => {
"60",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as DBLeaderboardEntry[];
//THEN
expect(lb[0]).not.toHaveProperty("discordId");
@ -121,7 +125,7 @@ describe("LeaderboardsDal", () => {
"15",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as DBLeaderboardEntry[];
//THEN
expect(lb[0]).not.toHaveProperty("consistency");
@ -183,7 +187,7 @@ describe("LeaderboardsDal", () => {
"15",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as DBLeaderboardEntry[];
//THEN
const lb = result.map((it) => _.omit(it, ["_id"]));
@ -219,7 +223,7 @@ describe("LeaderboardsDal", () => {
"15",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as DBLeaderboardEntry[];
//THEN
const lb = result.map((it) => _.omit(it, ["_id"]));
@ -251,7 +255,7 @@ describe("LeaderboardsDal", () => {
"15",
"english",
0
)) as SharedTypes.LeaderboardEntry[];
)) as DBLeaderboardEntry[];
//THEN
expect(result[0]?.isPremium).toBeUndefined();
@ -263,8 +267,10 @@ function expectedLbEntry(
time: string,
{ rank, user, badgeId, isPremium }: ExpectedLbEntry
) {
const lbBest: SharedTypes.PersonalBest =
user.lbPersonalBests?.time[time].english;
// @ts-expect-error
const lbBest: PersonalBest =
// @ts-expect-error
user.lbPersonalBests?.time[Number.parseInt(time)].english;
return {
rank,
@ -308,10 +314,10 @@ async function createUser(
}
function lbBests(
pb15?: SharedTypes.PersonalBest,
pb60?: SharedTypes.PersonalBest
pb15?: PersonalBest,
pb60?: PersonalBest
): MonkeyTypes.LbPersonalBests {
const result = { time: {} };
const result: MonkeyTypes.LbPersonalBests = { time: {} };
if (pb15) result.time["15"] = { english: pb15 };
if (pb60) result.time["60"] = { english: pb60 };
return result;
@ -321,7 +327,7 @@ function pb(
wpm: number,
acc: number = 90,
timestamp: number = 1
): SharedTypes.PersonalBest {
): PersonalBest {
return {
acc,
consistency: 100,
@ -335,7 +341,7 @@ function pb(
};
}
function premium(expirationDeltaSeconds) {
function premium(expirationDeltaSeconds: number) {
return {
premium: {
startTimestamp: 0,

View file

@ -12,7 +12,7 @@
"start": "node ./dist/server.js",
"test": "vitest run",
"test-coverage": "vitest run --coverage",
"dev": "concurrently \"tsx watch --clear-screen=false --inspect ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"npx eslint-watch \"./src/**/*.ts\"\"",
"dev": "concurrently -p none \"tsx watch --clear-screen=false --inspect ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"esw src/ -w --ext .ts --cache --color\"",
"knip": "knip",
"docker-db-only": "docker compose -f docker/compose.db-only.yml up",
"docker": "docker compose -f docker/compose.yml up",
@ -65,7 +65,7 @@
"@monkeytype/eslint-config": "workspace:*",
"@monkeytype/shared-types": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"@redocly/cli": "1.18.1",
"@redocly/cli": "1.19.0",
"@types/bcrypt": "5.0.2",
"@types/cors": "2.8.12",
"@types/cron": "1.7.3",
@ -85,7 +85,7 @@
"@types/swagger-ui-express": "4.1.3",
"@types/ua-parser-js": "0.7.36",
"@types/uuid": "10.0.0",
"@vitest/coverage-v8": "1.6.0",
"@vitest/coverage-v8": "2.0.5",
"concurrently": "8.2.2",
"eslint": "8.57.0",
"eslint-watch": "8.0.0",
@ -95,7 +95,7 @@
"supertest": "6.2.3",
"tsx": "4.16.2",
"typescript": "5.5.4",
"vitest": "1.6.0",
"vitest": "2.0.5",
"vitest-mongodb": "1.0.0"
}
}

View file

@ -66,6 +66,16 @@ export function getOpenApi(): OpenAPIObject {
description: "Ape keys provide access to certain API endpoints.",
"x-displayName": "Ape Keys",
},
{
name: "public",
description: "Public endpoints such as typing stats.",
"x-displayName": "public",
},
{
name: "leaderboards",
description: "All-time and daily leaderboards of the fastest typers.",
"x-displayName": "Leaderboards",
},
{
name: "psas",
description: "Public service announcements.",

View file

@ -4,85 +4,81 @@ import {
MILLISECONDS_IN_DAY,
getCurrentWeekTimestamp,
} from "../../utils/misc";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import * as LeaderboardsDAL from "../../dal/leaderboards";
import MonkeyError from "../../utils/error";
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
import {
GetDailyLeaderboardQuery,
GetDailyLeaderboardRankQuery,
GetLeaderboardDailyRankResponse,
GetLeaderboardQuery,
GetLeaderboardRankResponse,
GetLeaderboardResponse as GetLeaderboardResponse,
GetWeeklyXpLeaderboardQuery,
GetWeeklyXpLeaderboardRankResponse,
GetWeeklyXpLeaderboardResponse,
LanguageAndModeQuery,
} from "@monkeytype/contracts/leaderboards";
import { Configuration } from "@monkeytype/shared-types";
export async function getLeaderboard(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { language, mode, mode2, skip, limit = 50 } = req.query;
const { uid } = req.ctx.decodedToken;
const queryLimit = Math.min(parseInt(limit as string, 10), 50);
req: MonkeyTypes.Request2<GetLeaderboardQuery>
): Promise<GetLeaderboardResponse> {
const { language, mode, mode2, skip = 0, limit = 50 } = req.query;
const leaderboard = await LeaderboardsDAL.get(
mode as string,
mode2 as string,
language as string,
parseInt(skip as string, 10),
queryLimit
mode,
mode2,
language,
skip,
limit
);
if (leaderboard === false) {
return new MonkeyResponse(
"Leaderboard is currently updating. Please try again in a few seconds.",
null,
503
throw new MonkeyError(
503,
"Leaderboard is currently updating. Please try again in a few seconds."
);
}
const normalizedLeaderboard = _.map(leaderboard, (entry) => {
return uid && entry.uid === uid
? entry
: _.omit(entry, ["_id", "difficulty", "language"]);
});
const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"]));
return new MonkeyResponse("Leaderboard retrieved", normalizedLeaderboard);
return new MonkeyResponse2("Leaderboard retrieved", normalizedLeaderboard);
}
export async function getRankFromLeaderboard(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<LanguageAndModeQuery>
): Promise<GetLeaderboardRankResponse> {
const { language, mode, mode2 } = req.query;
const { uid } = req.ctx.decodedToken;
const data = await LeaderboardsDAL.getRank(
mode as string,
mode2 as string,
language as string,
uid
);
const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid);
if (data === false) {
return new MonkeyResponse(
"Leaderboard is currently updating. Please try again in a few seconds.",
null,
503
throw new MonkeyError(
503,
"Leaderboard is currently updating. Please try again in a few seconds."
);
}
return new MonkeyResponse("Rank retrieved", data);
return new MonkeyResponse2("Rank retrieved", data);
}
function getDailyLeaderboardWithError(
req: MonkeyTypes.Request
{ language, mode, mode2, daysBefore }: GetDailyLeaderboardRankQuery,
config: Configuration["dailyLeaderboards"]
): DailyLeaderboards.DailyLeaderboard {
const { language, mode, mode2, daysBefore } = req.query;
const normalizedDayBefore = parseInt(daysBefore as string, 10);
const currentDayTimestamp = getCurrentDayTimestamp();
const dayBeforeTimestamp =
currentDayTimestamp - normalizedDayBefore * MILLISECONDS_IN_DAY;
const customTimestamp = _.isNil(daysBefore) ? -1 : dayBeforeTimestamp;
const customTimestamp =
daysBefore === undefined
? -1
: getCurrentDayTimestamp() - daysBefore * MILLISECONDS_IN_DAY;
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
language as string,
mode as string,
mode2 as string,
req.ctx.configuration.dailyLeaderboards,
language,
mode,
mode2,
config,
customTimestamp
);
if (!dailyLeaderboard) {
@ -93,14 +89,17 @@ function getDailyLeaderboardWithError(
}
export async function getDailyLeaderboard(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<GetDailyLeaderboardQuery>
): Promise<GetLeaderboardResponse> {
const { skip = 0, limit = 50 } = req.query;
const dailyLeaderboard = getDailyLeaderboardWithError(req);
const dailyLeaderboard = getDailyLeaderboardWithError(
req.query,
req.ctx.configuration.dailyLeaderboards
);
const minRank = parseInt(skip as string, 10);
const maxRank = minRank + parseInt(limit as string, 10) - 1;
const minRank = skip;
const maxRank = minRank + limit - 1;
const topResults = await dailyLeaderboard.getResults(
minRank,
@ -109,40 +108,37 @@ export async function getDailyLeaderboard(
req.ctx.configuration.users.premium.enabled
);
return new MonkeyResponse("Daily leaderboard retrieved", topResults);
return new MonkeyResponse2("Daily leaderboard retrieved", topResults);
}
export async function getDailyLeaderboardRank(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<GetDailyLeaderboardRankQuery>
): Promise<GetLeaderboardDailyRankResponse> {
const { uid } = req.ctx.decodedToken;
const dailyLeaderboard = getDailyLeaderboardWithError(req);
const dailyLeaderboard = getDailyLeaderboardWithError(
req.query,
req.ctx.configuration.dailyLeaderboards
);
const rank = await dailyLeaderboard.getRank(
uid,
req.ctx.configuration.dailyLeaderboards
);
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
return new MonkeyResponse2("Daily leaderboard rank retrieved", rank);
}
function getWeeklyXpLeaderboardWithError(
req: MonkeyTypes.Request
{ weeksBefore }: GetWeeklyXpLeaderboardQuery,
config: Configuration["leaderboards"]["weeklyXp"]
): WeeklyXpLeaderboard.WeeklyXpLeaderboard {
const { weeksBefore } = req.query;
const customTimestamp =
weeksBefore === undefined
? -1
: getCurrentWeekTimestamp() - weeksBefore * MILLISECONDS_IN_DAY * 7;
const normalizedWeeksBefore = parseInt(weeksBefore as string, 10);
const currentWeekTimestamp = getCurrentWeekTimestamp();
const weekBeforeTimestamp =
currentWeekTimestamp - normalizedWeeksBefore * MILLISECONDS_IN_DAY * 7;
const customTimestamp = _.isNil(weeksBefore) ? -1 : weekBeforeTimestamp;
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(
req.ctx.configuration.leaderboards.weeklyXp,
customTimestamp
);
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(config, customTimestamp);
if (!weeklyXpLeaderboard) {
throw new MonkeyError(404, "XP leaderboard for this week not found.");
}
@ -151,33 +147,39 @@ function getWeeklyXpLeaderboardWithError(
}
export async function getWeeklyXpLeaderboardResults(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<GetWeeklyXpLeaderboardQuery>
): Promise<GetWeeklyXpLeaderboardResponse> {
const { skip = 0, limit = 50 } = req.query;
const minRank = parseInt(skip as string, 10);
const maxRank = minRank + parseInt(limit as string, 10) - 1;
const minRank = skip;
const maxRank = minRank + limit - 1;
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req);
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
req.query,
req.ctx.configuration.leaderboards.weeklyXp
);
const results = await weeklyXpLeaderboard.getResults(
minRank,
maxRank,
req.ctx.configuration.leaderboards.weeklyXp
);
return new MonkeyResponse("Weekly xp leaderboard retrieved", results);
return new MonkeyResponse2("Weekly xp leaderboard retrieved", results);
}
export async function getWeeklyXpLeaderboardRank(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetWeeklyXpLeaderboardRankResponse> {
const { uid } = req.ctx.decodedToken;
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req);
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
{},
req.ctx.configuration.leaderboards.weeklyXp
);
const rankEntry = await weeklyXpLeaderboard.getRank(
uid,
req.ctx.configuration.leaderboards.weeklyXp
);
return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry);
return new MonkeyResponse2("Weekly xp leaderboard rank retrieved", rankEntry);
}

View file

@ -1,21 +1,22 @@
import {
GetSpeedHistogramQuery,
GetSpeedHistogramResponse,
GetTypingStatsResponse,
} from "@monkeytype/contracts/public";
import * as PublicDAL from "../../dal/public";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
export async function getPublicSpeedHistogram(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
export async function getSpeedHistogram(
req: MonkeyTypes.Request2<GetSpeedHistogramQuery>
): Promise<GetSpeedHistogramResponse> {
const { language, mode, mode2 } = req.query;
const data = await PublicDAL.getSpeedHistogram(
language as string,
mode as string,
mode2 as string
);
return new MonkeyResponse("Public speed histogram retrieved", data);
const data = await PublicDAL.getSpeedHistogram(language, mode, mode2);
return new MonkeyResponse2("Public speed histogram retrieved", data);
}
export async function getPublicTypingStats(
_req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
export async function getTypingStats(
_req: MonkeyTypes.Request2
): Promise<GetTypingStatsResponse> {
const data = await PublicDAL.getTypingStats();
return new MonkeyResponse("Public typing stats retrieved", data);
return new MonkeyResponse2("Public typing stats retrieved", data);
}

View file

@ -15,7 +15,7 @@ const commonMiddleware = [
}),
checkUserPermissions({
criteria: (user) => {
return user.canManageApeKeys ?? false;
return user.canManageApeKeys ?? true;
},
invalidMessage: "You have lost access to ape keys, please contact support",
}),

View file

@ -1,4 +1,4 @@
import { Router } from "express";
import { Response, Router } from "express";
import * as swaggerUi from "swagger-ui-express";
import publicSwaggerSpec from "../../documentation/public-swagger.json";
@ -12,6 +12,7 @@ const router = Router();
const root = __dirname + "../../../static";
router.use("/v2/internal", (req, res) => {
setCsp(res);
res.sendFile("api/internal.html", { root });
});
@ -21,6 +22,7 @@ router.use("/v2/internal.json", (req, res) => {
});
router.use(["/v2/public", "/v2/"], (req, res) => {
setCsp(res);
res.sendFile("api/public.html", { root });
});
@ -38,3 +40,10 @@ router.use(
);
export default router;
function setCsp(res: Response): void {
res.setHeader(
"Content-Security-Policy",
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redoc.ly data:;object-src 'none';script-src 'self' cdn.redoc.ly 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests"
);
}

View file

@ -19,7 +19,6 @@ import leaderboards from "./leaderboards";
import addSwaggerMiddlewares from "./swagger";
import { asyncHandler } from "../../middlewares/utility";
import { MonkeyResponse } from "../../utils/monkey-response";
import { recordClientVersion } from "../../utils/prometheus";
import {
Application,
IRouter,
@ -45,8 +44,6 @@ const APP_START_TIME = Date.now();
const API_ROUTE_MAP = {
"/users": users,
"/results": results,
"/public": publicStats,
"/leaderboards": leaderboards,
"/quotes": quotes,
"/webhooks": webhooks,
"/docs": docs,
@ -59,6 +56,8 @@ const router = s.router(contract, {
configs,
presets,
psas,
public: publicStats,
leaderboards,
});
export function addApiRoutes(app: Application): void {
@ -80,16 +79,29 @@ export function addApiRoutes(app: Application): void {
function applyTsRestApiRoutes(app: IRouter): void {
createExpressEndpoints(contract, router, app, {
jsonQuery: true,
requestValidationErrorHandler(err, req, res, next) {
if (err.body?.issues === undefined) {
requestValidationErrorHandler(err, _req, res, next) {
let message: string | undefined = undefined;
let validationErrors: string[] | undefined = undefined;
if (err.pathParams?.issues !== undefined) {
message = "Invalid path parameter schema";
validationErrors = err.pathParams.issues.map(prettyErrorMessage);
} else if (err.query?.issues !== undefined) {
message = "Invalid query schema";
validationErrors = err.query.issues.map(prettyErrorMessage);
} else if (err.body?.issues !== undefined) {
message = "Invalid request data schema";
validationErrors = err.body.issues.map(prettyErrorMessage);
}
if (message !== undefined) {
res
.status(422)
.json({ message, validationErrors } as MonkeyValidationError);
} else {
next();
return;
}
const issues = err.body?.issues.map(prettyErrorMessage);
res.status(422).json({
message: "Invalid request data schema",
validationErrors: issues,
} as MonkeyValidationError);
},
globalMiddleware: [authenticateTsRestRequest()],
});
@ -113,7 +125,9 @@ function applyDevApiRoutes(app: Application): void {
app.use(async (req, res, next) => {
const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
if (slowdown > 0) {
Logger.info(`Simulating ${slowdown}ms delay for ${req.path}`);
Logger.info(
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`
);
await new Promise((resolve) => setTimeout(resolve, slowdown));
}
next();
@ -141,20 +155,6 @@ function applyApiRoutes(app: Application): void {
return;
}
if (req.path === "/psas") {
const clientVersion =
(req.headers["x-client-version"] as string) ||
req.headers["client-version"];
recordClientVersion(clientVersion?.toString() ?? "unknown");
}
if (req.path.startsWith("/docs")) {
res.setHeader(
"Content-Security-Policy",
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redoc.ly data:;object-src 'none';script-src 'self' cdn.redoc.ly 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests"
);
}
next();
}
);

View file

@ -1,41 +1,11 @@
import joi from "joi";
import { Router } from "express";
import * as RateLimit from "../../middlewares/rate-limit";
import { initServer } from "@ts-rest/express";
import { withApeRateLimiter } from "../../middlewares/ape-rate-limit";
import { authenticateRequest } from "../../middlewares/auth";
import * as LeaderboardController from "../controllers/leaderboard";
import { validate } from "../../middlewares/configuration";
import { validateRequest } from "../../middlewares/validation";
import { asyncHandler } from "../../middlewares/utility";
import * as RateLimit from "../../middlewares/rate-limit";
import * as LeaderboardController from "../controllers/leaderboard";
const BASE_LEADERBOARD_VALIDATION_SCHEMA = {
language: joi
.string()
.max(50)
.pattern(/^[a-zA-Z0-9_+]+$/)
.required(),
mode: joi
.string()
.valid("time", "words", "quote", "zen", "custom")
.required(),
mode2: joi
.string()
.regex(/^(\d)+|custom|zen/)
.required(),
};
const LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT = {
...BASE_LEADERBOARD_VALIDATION_SCHEMA,
skip: joi.number().min(0),
limit: joi.number().min(0).max(50),
};
const DAILY_LEADERBOARD_VALIDATION_SCHEMA = {
...LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
daysBefore: joi.number().min(1).max(1),
};
const router = Router();
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
import { callController } from "../ts-rest-adapter";
const requireDailyLeaderboardsEnabled = validate({
criteria: (configuration) => {
@ -44,58 +14,6 @@ const requireDailyLeaderboardsEnabled = validate({
invalidMessage: "Daily leaderboards are not available at this time.",
});
router.get(
"/",
authenticateRequest({ isPublic: true }),
withApeRateLimiter(RateLimit.leaderboardsGet),
validateRequest({
query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
}),
asyncHandler(LeaderboardController.getLeaderboard)
);
router.get(
"/rank",
authenticateRequest({ acceptApeKeys: true }),
withApeRateLimiter(RateLimit.leaderboardsGet),
validateRequest({
query: BASE_LEADERBOARD_VALIDATION_SCHEMA,
}),
asyncHandler(LeaderboardController.getRankFromLeaderboard)
);
router.get(
"/daily",
requireDailyLeaderboardsEnabled,
authenticateRequest({ isPublic: true }),
RateLimit.leaderboardsGet,
validateRequest({
query: DAILY_LEADERBOARD_VALIDATION_SCHEMA,
}),
asyncHandler(LeaderboardController.getDailyLeaderboard)
);
router.get(
"/daily/rank",
requireDailyLeaderboardsEnabled,
authenticateRequest(),
RateLimit.leaderboardsGet,
validateRequest({
query: DAILY_LEADERBOARD_VALIDATION_SCHEMA,
}),
asyncHandler(LeaderboardController.getDailyLeaderboardRank)
);
const BASE_XP_LEADERBOARD_VALIDATION_SCHEMA = {
skip: joi.number().min(0),
limit: joi.number().min(0).max(50),
};
const WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA = {
...BASE_XP_LEADERBOARD_VALIDATION_SCHEMA,
weeksBefore: joi.number().min(1).max(1),
};
const requireWeeklyXpLeaderboardEnabled = validate({
criteria: (configuration) => {
return configuration.leaderboards.weeklyXp.enabled;
@ -103,23 +21,36 @@ const requireWeeklyXpLeaderboardEnabled = validate({
invalidMessage: "Weekly XP leaderboards are not available at this time.",
});
router.get(
"/xp/weekly",
requireWeeklyXpLeaderboardEnabled,
authenticateRequest({ isPublic: true }),
withApeRateLimiter(RateLimit.leaderboardsGet),
validateRequest({
query: WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA,
}),
asyncHandler(LeaderboardController.getWeeklyXpLeaderboardResults)
);
router.get(
"/xp/weekly/rank",
requireWeeklyXpLeaderboardEnabled,
authenticateRequest(),
withApeRateLimiter(RateLimit.leaderboardsGet),
asyncHandler(LeaderboardController.getWeeklyXpLeaderboardRank)
);
export default router;
const s = initServer();
export default s.router(leaderboardsContract, {
get: {
middleware: [RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getLeaderboard)(r),
},
getRank: {
middleware: [withApeRateLimiter(RateLimit.leaderboardsGet)],
handler: async (r) =>
callController(LeaderboardController.getRankFromLeaderboard)(r),
},
getDaily: {
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboard)(r),
},
getDailyRank: {
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboardRank)(r),
},
getWeeklyXp: {
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r),
},
getWeeklyXpRank: {
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
},
});

View file

@ -3,11 +3,12 @@ import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as PsaController from "../controllers/psa";
import { callController } from "../ts-rest-adapter";
import { recordClientVersion } from "../../middlewares/utility";
const s = initServer();
export default s.router(psasContract, {
get: {
middleware: [RateLimit.psaGet],
middleware: [recordClientVersion(), RateLimit.psaGet],
handler: async (r) => callController(PsaController.getPsas)(r),
},
});

View file

@ -1,41 +1,17 @@
import { Router } from "express";
import * as PublicController from "../controllers/public";
import { publicContract } from "@monkeytype/contracts/public";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import { asyncHandler } from "../../middlewares/utility";
import joi from "joi";
import { validateRequest } from "../../middlewares/validation";
import * as PublicController from "../controllers/public";
import { callController } from "../ts-rest-adapter";
const GET_MODE_STATS_VALIDATION_SCHEMA = {
language: joi
.string()
.max(50)
.pattern(/^[a-zA-Z0-9_+]+$/)
.required(),
mode: joi
.string()
.valid("time", "words", "quote", "zen", "custom")
.required(),
mode2: joi
.string()
.regex(/^(\d)+|custom|zen/)
.required(),
};
const router = Router();
router.get(
"/speedHistogram",
RateLimit.publicStatsGet,
validateRequest({
query: GET_MODE_STATS_VALIDATION_SCHEMA,
}),
asyncHandler(PublicController.getPublicSpeedHistogram)
);
router.get(
"/typingStats",
RateLimit.publicStatsGet,
asyncHandler(PublicController.getPublicTypingStats)
);
export default router;
const s = initServer();
export default s.router(publicContract, {
getSpeedHistogram: {
middleware: [RateLimit.publicStatsGet],
handler: async (r) => callController(PublicController.getSpeedHistogram)(r),
},
getTypingStats: {
middleware: [RateLimit.publicStatsGet],
handler: async (r) => callController(PublicController.getTypingStats)(r),
},
});

View file

@ -4,8 +4,27 @@ import { performance } from "perf_hooks";
import { setLeaderboard } from "../utils/prometheus";
import { isDevEnvironment } from "../utils/misc";
import { getCachedConfiguration } from "../init/configuration";
import { LeaderboardEntry } from "@monkeytype/shared-types";
import { addLog } from "./logs";
import { Collection } from "mongodb";
import {
LeaderboardEntry,
LeaderboardRank,
} from "@monkeytype/contracts/schemas/leaderboards";
import { omit } from "lodash";
export type DBLeaderboardEntry = LeaderboardEntry & {
_id: ObjectId;
};
export const getCollection = (key: {
language: string;
mode: string;
mode2: string;
}): Collection<DBLeaderboardEntry> =>
db.collection<DBLeaderboardEntry>(
`leaderboards.${key.language}.${key.mode}.${key.mode2}`
);
export async function get(
mode: string,
@ -13,14 +32,13 @@ export async function get(
language: string,
skip: number,
limit = 50
): Promise<LeaderboardEntry[] | false> {
): Promise<DBLeaderboardEntry[] | false> {
//if (leaderboardUpdating[`${language}_${mode}_${mode2}`]) return false;
if (limit > 50 || limit <= 0) limit = 50;
if (skip < 0) skip = 0;
try {
const preset = await db
.collection<LeaderboardEntry>(`leaderboards.${language}.${mode}.${mode2}`)
const preset = await getCollection({ language, mode, mode2 })
.find()
.sort({ rank: 1 })
.skip(skip)
@ -31,8 +49,9 @@ export async function get(
.premium.enabled;
if (!premiumFeaturesEnabled) {
preset.forEach((it) => (it.isPremium = undefined));
return preset.map((it) => omit(it, "isPremium"));
}
return preset;
} catch (e) {
if (e.error === 175) {
@ -43,30 +62,26 @@ export async function get(
}
}
type GetRankResponse = {
count: number;
rank: number | null;
entry: LeaderboardEntry | null;
};
export async function getRank(
mode: string,
mode2: string,
language: string,
uid: string
): Promise<GetRankResponse | false> {
): Promise<LeaderboardRank | false> {
try {
const entry = await db
.collection<LeaderboardEntry>(`leaderboards.${language}.${mode}.${mode2}`)
.findOne({ uid });
const count = await db
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.estimatedDocumentCount();
const entry = await getCollection({ language, mode, mode2 }).findOne({
uid,
});
const count = await getCollection({
language,
mode,
mode2,
}).estimatedDocumentCount();
return {
count,
rank: entry ? entry.rank : null,
entry,
rank: entry?.rank,
entry: entry !== null ? entry : undefined,
};
} catch (e) {
if (e.error === 175) {

View file

@ -1,10 +1,13 @@
import * as db from "../init/db";
import { roundTo2 } from "../utils/misc";
import MonkeyError from "../utils/error";
import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
import {
TypingStats,
SpeedHistogram,
} from "@monkeytype/contracts/schemas/public";
type PublicTypingStatsDB = PublicTypingStats & { _id: "stats" };
type PublicSpeedStatsDB = {
export type PublicTypingStatsDB = TypingStats & { _id: "stats" };
export type PublicSpeedStatsDB = {
_id: "speedStatsHistogram";
english_time_15: SpeedHistogram;
english_time_60: SpeedHistogram;

View file

@ -21,7 +21,6 @@ import {
CustomTheme,
DBResult,
MonkeyMail,
ResultFilters,
UserInventory,
UserProfileDetails,
UserQuoteRatings,
@ -33,6 +32,7 @@ import {
PersonalBest,
} from "@monkeytype/contracts/schemas/shared";
import { addImportantLog } from "./logs";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
const SECONDS_PER_HOUR = 3600;

View file

@ -23,10 +23,6 @@
"name": "users",
"description": "User data and related operations"
},
{
"name": "leaderboards",
"description": "Leaderboard data"
},
{
"name": "results",
"description": "Result data and related operations"
@ -412,78 +408,6 @@
}
}
},
"/leaderboards": {
"get": {
"tags": ["leaderboards"],
"summary": "Gets a leaderboard",
"parameters": [
{
"in": "query",
"name": "language",
"type": "string"
},
{
"in": "query",
"name": "mode",
"type": "string"
},
{
"in": "query",
"name": "mode2",
"type": "string"
},
{
"in": "query",
"name": "skip",
"type": "number"
},
{
"in": "query",
"name": "limit",
"type": "number"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/leaderboards/rank": {
"get": {
"tags": ["leaderboards"],
"summary": "Gets a user's rank from a leaderboard",
"parameters": [
{
"in": "query",
"name": "language",
"type": "string"
},
{
"in": "query",
"name": "mode",
"type": "string"
},
{
"in": "query",
"name": "mode2",
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/results": {
"get": {
"tags": ["results"],

View file

@ -20,10 +20,6 @@
"name": "users",
"description": "User data and related operations"
},
{
"name": "leaderboards",
"description": "Leaderboard data and related operations"
},
{
"name": "results",
"description": "User results data and related operations"
@ -201,97 +197,6 @@
}
}
}
},
"/leaderboards": {
"get": {
"tags": ["leaderboards"],
"summary": "Gets global leaderboard data",
"parameters": [
{
"name": "language",
"in": "query",
"description": "The leaderboard's language (i.e., english)",
"required": true,
"type": "string"
},
{
"name": "mode",
"in": "query",
"description": "The primary mode (i.e., time)",
"required": true,
"type": "string"
},
{
"name": "mode2",
"in": "query",
"description": "The secondary mode (i.e., 60)",
"required": true,
"type": "string"
},
{
"name": "skip",
"in": "query",
"description": "How many leaderboard entries to skip",
"required": false,
"type": "number",
"minimum": 0
},
{
"name": "limit",
"in": "query",
"description": "How many leaderboard entries to request",
"required": false,
"type": "number",
"minimum": 0,
"maximum": 50
}
],
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/LeaderboardEntry"
}
}
}
}
},
"/leaderboards/rank": {
"get": {
"tags": ["leaderboards"],
"summary": "Gets your qualifying rank from a leaderboard",
"parameters": [
{
"name": "language",
"in": "query",
"description": "The leaderboard's language (i.e., english)",
"required": true,
"type": "string"
},
{
"name": "mode",
"in": "query",
"description": "The primary mode (i.e., time)",
"required": true,
"type": "string"
},
{
"name": "mode2",
"in": "query",
"description": "The secondary mode (i.e., 60)",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/LeaderboardEntry"
}
}
}
}
}
},
"definitions": {
@ -606,70 +511,6 @@
}
}
},
"LeaderboardEntry": {
"type": "object",
"properties": {
"uid": {
"type": "string",
"example": "6226b17aebc27a4a8d1ce04b"
},
"acc": {
"type": "number",
"format": "double",
"example": 97.96
},
"consistency": {
"type": "number",
"format": "double",
"example": 83.29
},
"lazyMode": {
"type": "boolean",
"example": false
},
"name": {
"type": "string",
"example": "Miodec"
},
"punctuation": {
"type": "boolean",
"example": false
},
"rank": {
"type": "integer",
"example": 3506
},
"raw": {
"type": "number",
"format": "double",
"example": 145.18
},
"wpm": {
"type": "number",
"format": "double",
"example": 141.18
},
"timestamp": {
"type": "integer",
"example": 1644438189583
},
"discordId": {
"type": "string",
"example": "974761412044437307"
},
"discordAvatar": {
"type": "string",
"example": "6226b17aebc27a4a8d1ce04b"
},
"badgeIds": {
"type": "array",
"items": {
"type": "integer",
"example": 1
}
}
}
},
"Results": {
"type": "array",
"items": {

View file

@ -2,20 +2,21 @@ import { CronJob } from "cron";
import GeorgeQueue from "../queues/george-queue";
import * as LeaderboardsDAL from "../dal/leaderboards";
import { getCachedConfiguration } from "../init/configuration";
import { LeaderboardEntry } from "@monkeytype/shared-types";
const CRON_SCHEDULE = "30 14/15 * * * *";
const RECENT_AGE_MINUTES = 10;
const RECENT_AGE_MILLISECONDS = RECENT_AGE_MINUTES * 60 * 1000;
async function getTop10(leaderboardTime: string): Promise<LeaderboardEntry[]> {
async function getTop10(
leaderboardTime: string
): Promise<LeaderboardsDAL.DBLeaderboardEntry[]> {
return (await LeaderboardsDAL.get(
"time",
leaderboardTime,
"english",
0,
10
)) as LeaderboardEntry[]; //can do that because gettop10 will not be called during an update
)) as LeaderboardsDAL.DBLeaderboardEntry[]; //can do that because gettop10 will not be called during an update
}
async function updateLeaderboardAndNotifyChanges(

View file

@ -1,7 +1,8 @@
import _ from "lodash";
import type { Response, NextFunction, RequestHandler } from "express";
import type { Request, Response, NextFunction, RequestHandler } from "express";
import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response";
import { isDevEnvironment } from "../utils/misc";
import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus";
export const emptyMiddleware = (
_req: MonkeyTypes.Request,
@ -45,3 +46,18 @@ export function useInProduction(
isDevEnvironment() ? emptyMiddleware : middleware
);
}
/**
* record the client version from the `x-client-version` or ` client-version` header to prometheus
*/
export function recordClientVersion(): RequestHandler {
return (req: Request, _res: Response, next: NextFunction) => {
const clientVersion =
(req.headers["x-client-version"] as string) ||
req.headers["client-version"];
prometheusRecordClientVersion(clientVersion?.toString() ?? "unknown");
next();
};
}

View file

@ -1,5 +1,4 @@
import { LeaderboardEntry } from "@monkeytype/shared-types";
import { type LbEntryWithRank } from "../utils/daily-leaderboards";
import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
import { MonkeyQueue } from "./monkey-queue";
const QUEUE_NAME = "george-tasks";
@ -62,7 +61,7 @@ class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
}
async announceLeaderboardUpdate(
newRecords: LeaderboardEntry[],
newRecords: Omit<LeaderboardEntry, "_id">[],
leaderboardId: string
): Promise<void> {
const taskName = "announceLeaderboardUpdate";
@ -90,7 +89,7 @@ class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
async announceDailyLeaderboardTopResults(
leaderboardId: string,
leaderboardTimestamp: number,
topResults: LbEntryWithRank[]
topResults: LeaderboardEntry[]
): Promise<void> {
const taskName = "announceDailyLeaderboardTopResults";

View file

@ -2,25 +2,21 @@ import { Configuration } from "@monkeytype/shared-types";
import * as RedisClient from "../init/redis";
import LaterQueue from "../queues/later-queue";
import { getCurrentWeekTimestamp } from "../utils/misc";
type InternalWeeklyXpLeaderboardEntry = {
uid: string;
name: string;
discordAvatar?: string;
discordId?: string;
badgeId?: number;
lastActivityTimestamp: number;
};
type WeeklyXpLeaderboardEntry = {
totalXp: number;
rank: number;
count?: number;
timeTypedSeconds: number;
} & InternalWeeklyXpLeaderboardEntry;
import {
XpLeaderboardEntry,
XpLeaderboardRank,
} from "@monkeytype/contracts/schemas/leaderboards";
type AddResultOpts = {
entry: InternalWeeklyXpLeaderboardEntry;
entry: Pick<
XpLeaderboardEntry,
| "uid"
| "name"
| "discordId"
| "discordAvatar"
| "badgeId"
| "lastActivityTimestamp"
>;
xpGained: number;
timeTypedSeconds: number;
};
@ -123,7 +119,7 @@ export class WeeklyXpLeaderboard {
minRank: number,
maxRank: number,
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
): Promise<WeeklyXpLeaderboardEntry[]> {
): Promise<XpLeaderboardEntry[]> {
const connection = RedisClient.getConnection();
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
return [];
@ -154,10 +150,10 @@ export class WeeklyXpLeaderboard {
);
}
const resultsWithRanks: WeeklyXpLeaderboardEntry[] = results.map(
const resultsWithRanks: XpLeaderboardEntry[] = results.map(
(resultJSON: string, index: number) => {
//TODO parse with zod?
const parsed = JSON.parse(resultJSON) as WeeklyXpLeaderboardEntry;
const parsed = JSON.parse(resultJSON) as XpLeaderboardEntry;
return {
...parsed,
@ -173,7 +169,7 @@ export class WeeklyXpLeaderboard {
public async getRank(
uid: string,
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
): Promise<WeeklyXpLeaderboardEntry | null> {
): Promise<XpLeaderboardRank | null> {
const connection = RedisClient.getConnection();
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
return null;
@ -201,7 +197,7 @@ export class WeeklyXpLeaderboard {
//TODO parse with zod?
const parsed = JSON.parse(result ?? "null") as Omit<
WeeklyXpLeaderboardEntry,
XpLeaderboardEntry,
"rank" | "count" | "totalXp"
>;

View file

@ -1,33 +1,13 @@
import _ from "lodash";
import _, { omit } from "lodash";
import * as RedisClient from "../init/redis";
import LaterQueue from "../queues/later-queue";
import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc";
import { Configuration, ValidModeRule } from "@monkeytype/shared-types";
type DailyLeaderboardEntry = {
uid: string;
name: string;
wpm: number;
raw: number;
acc: number;
consistency: number;
timestamp: number;
discordAvatar?: string;
discordId?: string;
badgeId?: number;
isPremium?: boolean;
};
type GetRankResponse = {
minWpm: number;
count: number;
rank: number | null;
entry: DailyLeaderboardEntry | null;
};
export type LbEntryWithRank = {
rank: number;
} & DailyLeaderboardEntry;
import {
DailyLeaderboardRank,
LeaderboardEntry,
} from "@monkeytype/contracts/schemas/leaderboards";
import MonkeyError from "./error";
const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard";
const scoresNamespace = `${dailyLeaderboardNamespace}:scores`;
@ -68,7 +48,7 @@ export class DailyLeaderboard {
}
public async addResult(
entry: DailyLeaderboardEntry,
entry: Omit<LeaderboardEntry, "rank">,
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
): Promise<number> {
const connection = RedisClient.getConnection();
@ -127,7 +107,7 @@ export class DailyLeaderboard {
maxRank: number,
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
premiumFeaturesEnabled: boolean
): Promise<LbEntryWithRank[]> {
): Promise<LeaderboardEntry[]> {
const connection = RedisClient.getConnection();
if (!connection || !dailyLeaderboardsConfig.enabled) {
return [];
@ -152,10 +132,10 @@ export class DailyLeaderboard {
);
}
const resultsWithRanks: LbEntryWithRank[] = results.map(
const resultsWithRanks: LeaderboardEntry[] = results.map(
(resultJSON, index) => {
// TODO: parse with zod?
const parsed = JSON.parse(resultJSON) as LbEntryWithRank;
const parsed = JSON.parse(resultJSON) as LeaderboardEntry;
return {
...parsed,
@ -165,7 +145,7 @@ export class DailyLeaderboard {
);
if (!premiumFeaturesEnabled) {
resultsWithRanks.forEach((it) => (it.isPremium = undefined));
return resultsWithRanks.map((it) => omit(it, "isPremium"));
}
return resultsWithRanks;
@ -174,10 +154,10 @@ export class DailyLeaderboard {
public async getRank(
uid: string,
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
): Promise<GetRankResponse | null> {
): Promise<DailyLeaderboardRank> {
const connection = RedisClient.getConnection();
if (!connection || !dailyLeaderboardsConfig.enabled) {
return null;
throw new MonkeyError(500, "Redis connnection is unavailable");
}
const { leaderboardScoresKey, leaderboardResultsKey } =
@ -198,8 +178,6 @@ export class DailyLeaderboard {
return {
minWpm,
count: count ?? 0,
rank: null,
entry: null,
};
}

View file

@ -3,7 +3,6 @@ import FunboxList from "../constants/funbox-list";
import { DBResult } from "@monkeytype/shared-types";
import {
Mode,
Mode2,
PersonalBest,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
@ -39,7 +38,7 @@ export function checkAndUpdatePb(
result: Result
): CheckAndUpdatePbResult {
const mode = result.mode;
const mode2 = result.mode2 as Mode2<"time">;
const mode2 = result.mode2;
const userPb = userPersonalBests ?? {};
userPb[mode] ??= {};
@ -175,7 +174,7 @@ function updateLeaderboardPersonalBests(
}
const mode = result.mode;
const mode2 = result.mode2 as Mode2<"time">;
const mode2 = result.mode2;
lbPersonalBests[mode] = lbPersonalBests[mode] ?? {};
const lbMode2 = lbPersonalBests[mode][mode2] as MonkeyTypes.LbPersonalBests;

View file

@ -105,7 +105,7 @@ export function incrementResult(res: Result<Mode>): void {
punctuation,
} = res;
let m2 = mode2 as string;
let m2 = mode2;
if (mode === "time" && !["15", "30", "60", "120"].includes(mode2)) {
m2 = "custom";
}

View file

@ -6,7 +6,6 @@ export default defineConfig({
environment: "node",
globalSetup: "__tests__/global-setup.ts",
setupFiles: ["__tests__/setup-tests.ts"],
pool: "forks",
coverage: {
include: ["**/*.ts"],

View file

@ -0,0 +1,73 @@
import defaultResultFilters from "../../../src/ts/constants/default-result-filters";
import { mergeWithDefaultFilters } from "../../../src/ts/elements/account/result-filters";
describe("result-filters.ts", () => {
describe("mergeWithDefaultFilters", () => {
it("should merge with default filters correctly", () => {
const tests = [
{
input: {
pb: {
no: false,
yes: false,
},
},
expected: () => {
const expected = defaultResultFilters;
expected.pb.no = false;
expected.pb.yes = false;
return expected;
},
},
{
input: {
words: {
"10": false,
},
},
expected: () => {
const expected = defaultResultFilters;
expected.words["10"] = false;
return expected;
},
},
{
input: {
blah: true,
},
expected: () => {
return defaultResultFilters;
},
},
{
input: 1,
expected: () => {
return defaultResultFilters;
},
},
{
input: null,
expected: () => {
return defaultResultFilters;
},
},
{
input: undefined,
expected: () => {
return defaultResultFilters;
},
},
{
input: {},
expected: () => {
return defaultResultFilters;
},
},
];
tests.forEach((test) => {
const merged = mergeWithDefaultFilters(test.input as any);
expect(merged).toEqual(test.expected());
});
});
});
});

View file

@ -1,8 +1,11 @@
import { isObject } from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
} from "../../src/ts/utils/strings";
//todo this file is in the wrong place
describe("misc.ts", () => {
describe("getLanguageDisplayString", () => {
it("should return correctly formatted strings", () => {
@ -72,4 +75,47 @@ describe("misc.ts", () => {
});
});
});
describe("isObject", () => {
it("should correctly identify objects", () => {
const tests = [
{
input: {},
expected: true,
},
{
input: { a: 1 },
expected: true,
},
{
input: [],
expected: false,
},
{
input: [1, 2, 3],
expected: false,
},
{
input: "string",
expected: false,
},
{
input: 1,
expected: false,
},
{
input: null,
expected: false,
},
{
input: undefined,
expected: false,
},
];
tests.forEach((test) => {
const result = isObject(test.input);
expect(result).toBe(test.expected);
});
});
});
});

View file

@ -0,0 +1,126 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../../src/ts/utils/local-storage-with-schema";
describe("local-storage-with-schema.ts", () => {
describe("LocalStorageWithSchema", () => {
const objectSchema = z.object({
punctuation: z.boolean(),
mode: z.enum(["words", "time"]),
fontSize: z.number(),
});
const defaultObject: z.infer<typeof objectSchema> = {
punctuation: true,
mode: "words",
fontSize: 16,
};
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
const getItemMock = vi.fn();
const setItemMock = vi.fn();
const removeItemMock = vi.fn();
vi.stubGlobal("localStorage", {
getItem: getItemMock,
setItem: setItemMock,
removeItem: removeItemMock,
});
afterEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
removeItemMock.mockReset();
});
it("should save to localStorage if schema is correct and return true", () => {
const res = ls.set(defaultObject);
expect(localStorage.setItem).toHaveBeenCalledWith(
"config",
JSON.stringify(defaultObject)
);
expect(res).toBe(true);
});
it("should fail to save to localStorage if schema is incorrect and return false", () => {
const obj = {
hi: "hello",
};
const res = ls.set(obj as any);
expect(localStorage.setItem).not.toHaveBeenCalled();
expect(res).toBe(false);
});
it("should revert to the fallback value if localstorage is null", () => {
getItemMock.mockReturnValue(null);
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should revert to the fallback value and remove if localstorage json is malformed", () => {
getItemMock.mockReturnValue("badjson");
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(localStorage.removeItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should get from localStorage", () => {
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should revert to fallback value if no migrate function and schema failed", () => {
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should migrate (when function is provided) if schema failed", () => {
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
const migrated = {
punctuation: false,
mode: "time",
fontSize: 1,
};
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
migrate: () => {
return migrated;
},
});
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(migrated);
});
});
});

View file

@ -44,7 +44,7 @@
"@types/seedrandom": "3.0.2",
"@types/subset-font": "1.4.3",
"@types/throttle-debounce": "2.1.0",
"@vitest/coverage-v8": "1.6.0",
"@vitest/coverage-v8": "2.0.5",
"ajv": "8.12.0",
"autoprefixer": "10.4.20",
"concurrently": "8.2.2",
@ -68,7 +68,7 @@
"vite-plugin-html-inject": "1.1.2",
"vite-plugin-inspect": "0.8.3",
"vite-plugin-pwa": "0.20.0",
"vitest": "1.6.0"
"vitest": "2.0.5"
},
"dependencies": {
"@date-fns/utc": "1.2.0",

View file

@ -1720,7 +1720,7 @@
<span style="display: inline-flex">
(
<a
href="https://api.monkeytype.com/documentation"
href="https://api.monkeytype.com/docs"
target="_blank"
rel="noopener"
>

View file

@ -262,7 +262,7 @@
}
&.tape .word {
margin: 0.25em 0.5em 0.75em 0;
margin: 0.25em 0.6em 0.75em 0;
}
/* a little hack for right-to-left languages */
@ -451,7 +451,7 @@
position: relative;
font-size: 1em;
line-height: 1em;
margin: 0.25em;
margin: 0.25em 0.3em;
font-variant: no-common-ligatures;
border-bottom: 2px solid transparent;
letter {

View file

@ -1,14 +1,10 @@
import Leaderboards from "./leaderboards";
import Quotes from "./quotes";
import Results from "./results";
import Users from "./users";
import Public from "./public";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Leaderboards,
Public,
Quotes,
Results,
Users,

View file

@ -1,53 +0,0 @@
const BASE_PATH = "/leaderboards";
export default class Leaderboards {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(
query: Ape.Leaderboards.QueryWithPagination
): Ape.EndpointResponse<Ape.Leaderboards.GetLeaderboard> {
const {
language,
mode,
mode2,
isDaily,
skip = 0,
limit = 50,
daysBefore,
} = query;
const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0;
const searchQuery = {
language,
mode,
mode2,
skip: Math.max(skip, 0),
limit: Math.max(Math.min(limit, 50), 0),
...(includeDaysBefore && { daysBefore }),
};
const endpointPath = `${BASE_PATH}/${isDaily ? "daily" : ""}`;
return await this.httpClient.get(endpointPath, { searchQuery });
}
async getRank(
query: Ape.Leaderboards.Query
): Ape.EndpointResponse<Ape.Leaderboards.GetRank> {
const { language, mode, mode2, isDaily, daysBefore } = query;
const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0;
const searchQuery = {
language,
mode,
mode2,
...(includeDaysBefore && { daysBefore }),
};
const endpointPath = `${BASE_PATH}${isDaily ? "/daily" : ""}/rank`;
return await this.httpClient.get(endpointPath, { searchQuery });
}
}

View file

@ -1,27 +0,0 @@
import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
const BASE_PATH = "/public";
type SpeedStatsQuery = {
language: string;
mode: string;
mode2: string;
};
export default class Public {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async getSpeedHistogram(
searchQuery: SpeedStatsQuery
): Ape.EndpointResponse<SpeedHistogram> {
return await this.httpClient.get(`${BASE_PATH}/speedHistogram`, {
searchQuery,
});
}
async getTypingStats(): Ape.EndpointResponse<PublicTypingStats> {
return await this.httpClient.get(`${BASE_PATH}/typingStats`);
}
}

View file

@ -1,12 +1,12 @@
import {
CountByYearAndDay,
CustomTheme,
ResultFilters,
UserProfile,
UserProfileDetails,
UserTag,
} from "@monkeytype/shared-types";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
const BASE_PATH = "/users";

View file

@ -2,28 +2,21 @@ import endpoints from "./endpoints";
import { buildHttpClient } from "./adapters/axios-adapter";
import { envConfig } from "../constants/env-config";
import { buildClient } from "./adapters/ts-rest-adapter";
import { configsContract } from "@monkeytype/contracts/configs";
import { presetsContract } from "@monkeytype/contracts/presets";
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
import { psasContract } from "@monkeytype/contracts/psas";
import { contract } from "@monkeytype/contracts";
const API_PATH = "";
const BASE_URL = envConfig.backendUrl;
const API_URL = `${BASE_URL}${API_PATH}`;
const httpClient = buildHttpClient(API_URL, 10_000);
const tsRestClient = buildClient(contract, BASE_URL, 10_000);
// API Endpoints
const Ape = {
...tsRestClient,
users: new endpoints.Users(httpClient),
configs: buildClient(configsContract, BASE_URL, 10_000),
results: new endpoints.Results(httpClient),
psas: buildClient(psasContract, BASE_URL, 10_000),
quotes: new endpoints.Quotes(httpClient),
leaderboards: new endpoints.Leaderboards(httpClient),
presets: buildClient(presetsContract, BASE_URL, 10_000),
publicStats: new endpoints.Public(httpClient),
apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
};

View file

@ -1,25 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// for some reason when using the dot notaion, the types are not being recognized as used
declare namespace Ape.Leaderboards {
type Query = {
language: string;
mode: Config.Mode;
mode2: string;
isDaily?: boolean;
daysBefore?: number;
};
type QueryWithPagination = {
skip?: number;
limit?: number;
} & Query;
type GetLeaderboard = LeaderboardEntry[];
type GetRank = {
minWpm: number;
count: number;
rank: number | null;
entry: import("@monkeytype/shared-types").LeaderboardEntry | null;
};
}

View file

@ -1,4 +1,5 @@
import { navigate } from "../../controllers/route-controller";
import { isAuthenticated } from "../../firebase";
import { toggleFullscreen } from "../../utils/misc";
const commands: MonkeyTypes.Command[] = [
@ -45,10 +46,7 @@ const commands: MonkeyTypes.Command[] = [
alias: "navigate go to stats",
icon: "fa-user",
exec: (): void => {
//todo probably base this on some state instead of the dom
$("header nav .textButton.view-account").hasClass("hidden")
? navigate("/login")
: navigate("/account");
isAuthenticated() ? navigate("/account") : navigate("/login");
},
},
{

View file

@ -17,6 +17,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
id: "setPlaySoundOnError1",
display: "damage",
configValue: "1",
hover: (): void => {
void SoundController.previewError("1");
},
exec: (): void => {
UpdateConfig.setPlaySoundOnError("1");
void SoundController.playError();
@ -26,6 +29,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
id: "setPlaySoundOnError2",
display: "triangle",
configValue: "2",
hover: (): void => {
void SoundController.previewError("2");
},
exec: (): void => {
UpdateConfig.setPlaySoundOnError("2");
void SoundController.playError();
@ -35,6 +41,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
id: "setPlaySoundOnError3",
display: "square",
configValue: "3",
hover: (): void => {
void SoundController.previewError("3");
},
exec: (): void => {
UpdateConfig.setPlaySoundOnError("3");
void SoundController.playError();
@ -44,6 +53,9 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
id: "setPlaySoundOnError3",
display: "punch miss",
configValue: "4",
hover: (): void => {
void SoundController.previewError("4");
},
exec: (): void => {
UpdateConfig.setPlaySoundOnError("4");
void SoundController.playError();

View file

@ -18,13 +18,35 @@ import {
} from "./test/funbox/funbox-validation";
import * as TribeState from "./tribe/tribe-state";
import * as TribeConfigSyncEvent from "./observables/tribe-config-sync-event";
import { isDevEnvironment, reloadAfter, typedKeys } from "./utils/misc";
import {
isDevEnvironment,
isObject,
reloadAfter,
typedKeys,
} from "./utils/misc";
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
import { Config } from "@monkeytype/contracts/schemas/configs";
import { roundTo1 } from "./utils/numbers";
import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared";
import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util";
import { LocalStorageWithSchema } from "./utils/local-storage-with-schema";
import { mergeWithDefaultConfig } from "./utils/config";
export let localStorageConfig: Config;
const configLS = new LocalStorageWithSchema({
key: "config",
schema: ConfigSchemas.ConfigSchema,
fallback: DefaultConfig,
migrate: (value, _issues) => {
if (!isObject(value)) {
return DefaultConfig;
}
const configWithoutLegacyValues = replaceLegacyValues(value);
const merged = mergeWithDefaultConfig(configWithoutLegacyValues);
return merged;
},
});
let loadDone: (value?: unknown) => void;
@ -49,29 +71,25 @@ function saveToLocalStorage(
noDbCheck = false
): void {
if (nosave) return;
const localToSave = config;
const localToSaveStringified = JSON.stringify(localToSave);
window.localStorage.setItem("config", localToSaveStringified);
configLS.set(config);
if (!noDbCheck) {
//@ts-expect-error this is fine
configToSend[key] = config[key];
saveToDatabase();
}
const localToSaveStringified = JSON.stringify(config);
ConfigEvent.dispatch("saveToLocalStorage", localToSaveStringified);
}
export function saveFullConfigToLocalStorage(noDbCheck = false): void {
console.log("saving full config to localStorage");
const save = config;
const stringified = JSON.stringify(save);
window.localStorage.setItem("config", stringified);
configLS.set(config);
if (!noDbCheck) {
AccountButton.loading(true);
void DB.saveConfig(save);
void DB.saveConfig(config);
AccountButton.loading(false);
}
const stringified = JSON.stringify(config);
ConfigEvent.dispatch("saveToLocalStorage", stringified);
}
@ -1631,12 +1649,11 @@ export function setCustomThemeColors(
}
export function setLanguage(
language: ConfigSchemas.Language,
language: Language,
nosave?: boolean,
tribeOverride = false
): boolean {
if (!isConfigValueValid("language", language, ConfigSchemas.LanguageSchema))
return false;
if (!isConfigValueValid("language", language, LanguageSchema)) return false;
if (!TribeState.canChangeConfig(tribeOverride)) return false;
config.language = language;
@ -2085,8 +2102,6 @@ export async function apply(
ConfigEvent.dispatch("fullConfigChange");
configToApply = replaceLegacyValues(configToApply);
const configObj = configToApply as Config;
(Object.keys(DefaultConfig) as (keyof Config)[]).forEach((configKey) => {
if (configObj[configKey] === undefined) {
@ -2206,33 +2221,19 @@ export async function reset(): Promise<void> {
export async function loadFromLocalStorage(): Promise<void> {
console.log("loading localStorage config");
const newConfigString = window.localStorage.getItem("config");
let newConfig: Config;
if (
newConfigString !== undefined &&
newConfigString !== null &&
newConfigString !== ""
) {
try {
newConfig = JSON.parse(newConfigString);
} catch (e) {
newConfig = {} as Config;
}
await apply(newConfig);
localStorageConfig = newConfig;
saveFullConfigToLocalStorage(true);
} else {
const newConfig = configLS.get();
if (newConfig === undefined) {
await reset();
} else {
await apply(newConfig);
saveFullConfigToLocalStorage(true);
}
// TestLogic.restart(false, true);
loadDone();
}
function replaceLegacyValues(
configToApply: ConfigSchemas.PartialConfig | MonkeyTypes.ConfigChanges
): ConfigSchemas.Config | MonkeyTypes.ConfigChanges {
const configObj = configToApply as ConfigSchemas.Config;
export function replaceLegacyValues(
configObj: ConfigSchemas.PartialConfig
): ConfigSchemas.PartialConfig {
//@ts-expect-error
if (configObj.quickTab === true) {
configObj.quickRestart = "tab";
@ -2270,7 +2271,7 @@ function replaceLegacyValues(
if (configObj.showLiveWpm === true) {
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
val = configObj.timerStyle;
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
}
configObj.liveSpeedStyle = val;
}
@ -2279,7 +2280,7 @@ function replaceLegacyValues(
if (configObj.showLiveBurst === true) {
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
val = configObj.timerStyle;
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
}
configObj.liveBurstStyle = val;
}
@ -2288,7 +2289,7 @@ function replaceLegacyValues(
if (configObj.showLiveAcc === true) {
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
val = configObj.timerStyle;
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
}
configObj.liveAccStyle = val;
}

View file

@ -3,7 +3,7 @@ import {
CustomThemeColors,
} from "@monkeytype/contracts/schemas/configs";
export default {
const obj = {
theme: "serika_dark",
themeLight: "serika",
themeDark: "serika_dark",
@ -103,3 +103,5 @@ export default {
tribeDelta: "bar",
tribeCarets: "on",
} as Config;
export default JSON.parse(JSON.stringify(obj)) as Config;

View file

@ -0,0 +1,66 @@
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
const object: ResultFilters = {
_id: "default-result-filters-id",
name: "default result filters",
pb: {
no: true,
yes: true,
},
difficulty: {
normal: true,
expert: true,
master: true,
},
mode: {
words: true,
time: true,
quote: true,
zen: true,
custom: true,
},
words: {
"10": true,
"25": true,
"50": true,
"100": true,
custom: true,
},
time: {
"15": true,
"30": true,
"60": true,
"120": true,
custom: true,
},
quoteLength: {
short: true,
medium: true,
long: true,
thicc: true,
},
punctuation: {
on: true,
off: true,
},
numbers: {
on: true,
off: true,
},
date: {
last_day: false,
last_week: false,
last_month: false,
last_3months: false,
all: true,
},
tags: {
none: true,
},
language: {},
funbox: {
none: true,
},
};
export default JSON.parse(JSON.stringify(object)) as ResultFilters;

View file

@ -47,6 +47,7 @@ import { navigate } from "./route-controller";
import { getHtmlByUserFlags } from "./user-flag-controller";
import { FirebaseError } from "firebase/app";
import * as PSA from "../elements/psa";
import defaultResultFilters from "../constants/default-result-filters";
export const gmailProvider = new GoogleAuthProvider();
export const githubProvider = new GithubAuthProvider();
@ -135,10 +136,10 @@ async function getDataAndInit(): Promise<boolean> {
.then((values) => {
const [languages, funboxes] = values;
languages.forEach((language) => {
ResultFilters.defaultResultFilters.language[language] = true;
defaultResultFilters.language[language] = true;
});
funboxes.forEach((funbox) => {
ResultFilters.defaultResultFilters.funbox[funbox.name] = true;
defaultResultFilters.funbox[funbox.name] = true;
});
// filters = defaultResultFilters;
void ResultFilters.load();
@ -166,7 +167,7 @@ async function getDataAndInit(): Promise<boolean> {
const areConfigsEqual =
JSON.stringify(Config) === JSON.stringify(snapshot.config);
if (UpdateConfig.localStorageConfig === undefined || !areConfigsEqual) {
if (Config === undefined || !areConfigsEqual) {
console.log(
"no local config or local and db configs are different - applying db"
);

View file

@ -404,6 +404,20 @@ export async function previewClick(val: string): Promise<void> {
safeClickSounds[val][0].sounds[0].play();
}
export async function previewError(val: string): Promise<void> {
if (errorSounds === null) await initErrorSound();
const safeErrorSounds = errorSounds as ErrorSounds;
const errorSoundIds = Object.keys(safeErrorSounds);
if (!errorSoundIds.includes(val)) return;
//@ts-expect-error
errorClickSounds[val][0].sounds[0].seek(0);
//@ts-expect-error
errorClickSounds[val][0].sounds[0].play();
}
let currentCode = "KeyA";
$(document).on("keydown", (event) => {

View file

@ -1,17 +1,25 @@
import { z } from "zod";
import * as DB from "../db";
import * as ModesNotice from "../elements/modes-notice";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { IdSchema } from "@monkeytype/contracts/schemas/util";
const activeTagsLS = new LocalStorageWithSchema({
key: "activeTags",
schema: z.array(IdSchema),
fallback: [],
});
export function saveActiveToLocalStorage(): void {
const tags: string[] = [];
try {
DB.getSnapshot()?.tags?.forEach((tag) => {
if (tag.active === true) {
tags.push(tag._id);
}
});
window.localStorage.setItem("activeTags", JSON.stringify(tags));
} catch (e) {}
DB.getSnapshot()?.tags?.forEach((tag) => {
if (tag.active === true) {
tags.push(tag._id);
}
});
activeTagsLS.set(tags);
}
export function clear(nosave = false): void {
@ -61,18 +69,9 @@ export function toggle(tagid: string, nosave = false): void {
}
export function loadActiveFromLocalStorage(): void {
let newTags: string[] | string = window.localStorage.getItem(
"activeTags"
) as string;
if (newTags != undefined && newTags !== "") {
try {
newTags = JSON.parse(newTags) ?? [];
} catch (e) {
newTags = [];
}
(newTags as string[]).forEach((ntag) => {
toggle(ntag, true);
});
saveActiveToLocalStorage();
const newTags = activeTagsLS.get();
for (const tag of newTags) {
toggle(tag, true);
}
saveActiveToLocalStorage();
}

View file

@ -623,29 +623,26 @@ export async function getLocalPB<M extends Mode>(
difficulty: Difficulty,
lazyMode: boolean,
funbox: string
): Promise<number> {
): Promise<PersonalBest | undefined> {
const funboxes = (await getFunboxList()).filter((fb) => {
return funbox?.split("#").includes(fb.name);
});
if (!funboxes.every((f) => f.canGetPb)) {
return 0;
return undefined;
}
if (dbSnapshot === null || dbSnapshot?.personalBests === null) return 0;
const bestsByMode = dbSnapshot?.personalBests[mode][mode2] as PersonalBest[];
const pbs = dbSnapshot?.personalBests?.[mode]?.[mode2] as
| PersonalBest[]
| undefined;
if (bestsByMode === undefined) return 0;
return (
bestsByMode.find(
(pb) =>
(pb.punctuation ?? false) === punctuation &&
(pb.numbers ?? false) === numbers &&
pb.difficulty === difficulty &&
pb.language === language &&
(pb.lazyMode ?? false) === lazyMode
)?.wpm ?? 0
return pbs?.find(
(pb) =>
(pb.punctuation ?? false) === punctuation &&
(pb.numbers ?? false) === numbers &&
pb.difficulty === difficulty &&
pb.language === language &&
(pb.lazyMode ?? false) === lazyMode
);
}

View file

@ -8,8 +8,49 @@ import Ape from "../../ape/index";
import * as Loader from "../loader";
// @ts-expect-error TODO: update slim-select
import SlimSelect from "slim-select";
import { ResultFilters } from "@monkeytype/shared-types";
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
import {
ResultFilters,
ResultFiltersSchema,
ResultFiltersGroup,
ResultFiltersGroupItem,
} from "@monkeytype/contracts/schemas/users";
import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema";
import defaultResultFilters from "../../constants/default-result-filters";
export function mergeWithDefaultFilters(
filters: Partial<ResultFilters>
): ResultFilters {
try {
const merged = {} as ResultFilters;
for (const groupKey of Misc.typedKeys(defaultResultFilters)) {
if (groupKey === "_id" || groupKey === "name") {
merged[groupKey] = filters[groupKey] ?? defaultResultFilters[groupKey];
} else {
// @ts-expect-error i cant figure this out
merged[groupKey] = {
...defaultResultFilters[groupKey],
...filters[groupKey],
};
}
}
return merged;
} catch (e) {
return defaultResultFilters;
}
}
const resultFiltersLS = new LocalStorageWithSchema({
key: "resultFilters",
schema: ResultFiltersSchema,
fallback: defaultResultFilters,
migrate: (unknown, _issues) => {
if (!Misc.isObject(unknown)) {
return defaultResultFilters;
}
return mergeWithDefaultFilters(unknown as ResultFilters);
},
});
type Option = {
id: string;
@ -31,118 +72,18 @@ type Option = {
const groupsUsingSelect = ["language", "funbox", "tags"];
const groupSelects: Partial<Record<keyof ResultFilters, SlimSelect>> = {};
export const defaultResultFilters: ResultFilters = {
_id: "default-result-filters-id",
name: "default result filters",
pb: {
no: true,
yes: true,
},
difficulty: {
normal: true,
expert: true,
master: true,
},
mode: {
words: true,
time: true,
quote: true,
zen: true,
custom: true,
},
words: {
"10": true,
"25": true,
"50": true,
"100": true,
custom: true,
},
time: {
"15": true,
"30": true,
"60": true,
"120": true,
custom: true,
},
quoteLength: {
short: true,
medium: true,
long: true,
thicc: true,
},
punctuation: {
on: true,
off: true,
},
numbers: {
on: true,
off: true,
},
date: {
last_day: false,
last_week: false,
last_month: false,
last_3months: false,
all: true,
},
tags: {
none: true,
},
language: {},
funbox: {
none: true,
},
};
// current activated filter
let filters = defaultResultFilters;
function save(): void {
window.localStorage.setItem("resultFilters", JSON.stringify(filters));
resultFiltersLS.set(filters);
}
export async function load(): Promise<void> {
try {
const newResultFilters = window.localStorage.getItem("resultFilters") ?? "";
if (!newResultFilters) {
filters = defaultResultFilters;
} else {
const newFiltersObject = JSON.parse(newResultFilters);
let reset = false;
for (const key of Object.keys(defaultResultFilters)) {
if (reset) break;
if (newFiltersObject[key] === undefined) {
reset = true;
break;
}
if (
typeof defaultResultFilters[
key as keyof typeof defaultResultFilters
] === "object"
) {
for (const subKey of Object.keys(
defaultResultFilters[key as keyof typeof defaultResultFilters]
)) {
if (newFiltersObject[key][subKey] === undefined) {
reset = true;
break;
}
}
}
}
if (reset) {
filters = defaultResultFilters;
} else {
filters = newFiltersObject;
}
}
const filters = resultFiltersLS.get();
const newTags: Record<string, boolean> = { none: false };
Object.keys(defaultResultFilters.tags).forEach((tag) => {
if (filters.tags[tag] !== undefined) {
newTags[tag] = filters.tags[tag];
@ -152,7 +93,6 @@ export async function load(): Promise<void> {
});
filters.tags = newTags;
// await updateFilterPresets();
save();
} catch {
console.log("error in loading result filters");
@ -288,7 +228,7 @@ function getFilters(): ResultFilters {
return filters;
}
function getGroup<G extends keyof ResultFilters>(group: G): ResultFilters[G] {
function getGroup<G extends ResultFiltersGroup>(group: G): ResultFilters[G] {
return filters[group];
}
@ -296,22 +236,22 @@ function getGroup<G extends keyof ResultFilters>(group: G): ResultFilters[G] {
// filters[group][filter] = value;
// }
export function getFilter<G extends keyof ResultFilters>(
export function getFilter<G extends ResultFiltersGroup>(
group: G,
filter: MonkeyTypes.Filter<G>
): ResultFilters[G][MonkeyTypes.Filter<G>] {
filter: ResultFiltersGroupItem<G>
): ResultFilters[G][ResultFiltersGroupItem<G>] {
return filters[group][filter];
}
function setFilter(
group: keyof ResultFilters,
filter: MonkeyTypes.Filter<typeof group>,
function setFilter<G extends ResultFiltersGroup>(
group: G,
filter: ResultFiltersGroupItem<G>,
value: boolean
): void {
filters[group][filter as keyof typeof filters[typeof group]] = value as never;
filters[group][filter] = value as typeof filters[G][typeof filter];
}
function setAllFilters(group: keyof ResultFilters, value: boolean): void {
function setAllFilters(group: ResultFiltersGroup, value: boolean): void {
Object.keys(getGroup(group)).forEach((filter) => {
filters[group][filter as keyof typeof filters[typeof group]] =
value as never;
@ -330,7 +270,7 @@ export function reset(): void {
}
type AboveChartDisplay = Partial<
Record<keyof ResultFilters, { all: boolean; array?: string[] }>
Record<ResultFiltersGroup, { all: boolean; array?: string[] }>
>;
export function updateActive(): void {
@ -352,7 +292,10 @@ export function updateActive(): void {
if (groupAboveChartDisplay === undefined) continue;
const filterValue = getFilter(group, filter);
const filterValue = getFilter(
group,
filter as ResultFiltersGroupItem<typeof group>
);
if (filterValue === true) {
groupAboveChartDisplay.array?.push(filter);
} else {
@ -392,7 +335,7 @@ export function updateActive(): void {
for (const [id, select] of Object.entries(groupSelects)) {
const ss = select;
const group = getGroup(id as keyof ResultFilters);
const group = getGroup(id as ResultFiltersGroup);
const everythingSelected = Object.values(group).every((v) => v === true);
const newData = ss.store.getData();
@ -436,7 +379,7 @@ export function updateActive(): void {
}, 0);
}
function addText(group: keyof ResultFilters): string {
function addText(group: ResultFiltersGroup): string {
let ret = "";
ret += "<div class='group'>";
if (group === "difficulty") {
@ -534,9 +477,9 @@ export function updateActive(): void {
}, 0);
}
function toggle<G extends keyof ResultFilters>(
function toggle<G extends ResultFiltersGroup>(
group: G,
filter: MonkeyTypes.Filter<G>
filter: ResultFiltersGroupItem<G>
): void {
// user is changing the filters -> current filter is no longer a filter preset
deSelectFilterPreset();
@ -548,7 +491,7 @@ function toggle<G extends keyof ResultFilters>(
const currentValue = filters[group][filter] as unknown as boolean;
const newValue = !currentValue;
filters[group][filter] =
newValue as unknown as ResultFilters[G][MonkeyTypes.Filter<G>];
newValue as ResultFilters[G][ResultFiltersGroupItem<G>];
save();
} catch (e) {
Notifications.add(
@ -567,8 +510,10 @@ $(
).on("click", "button", (e) => {
const group = $(e.target)
.parents(".buttons")
.attr("group") as keyof ResultFilters;
const filter = $(e.target).attr("filter") as MonkeyTypes.Filter<typeof group>;
.attr("group") as ResultFiltersGroup;
const filter = $(e.target).attr("filter") as ResultFiltersGroupItem<
typeof group
>;
if ($(e.target).hasClass("allFilters")) {
Misc.typedKeys(getFilters()).forEach((group) => {
// id and name field do not correspond to any ui elements, no need to update
@ -594,8 +539,8 @@ $(
} else if ($(e.target).is("button")) {
if (e.shiftKey) {
setAllFilters(group, false);
filters[group][filter as keyof typeof filters[typeof group]] =
true as never;
filters[group][filter] =
true as ResultFilters[typeof group][typeof filter];
} else {
toggle(group, filter);
// filters[group][filter] = !filters[group][filter];
@ -658,7 +603,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
filters.words.custom = true;
}
} else if (Config.mode === "quote") {
const filterName: MonkeyTypes.Filter<"quoteLength">[] = [
const filterName: ResultFiltersGroupItem<"quoteLength">[] = [
"short",
"medium",
"long",
@ -689,7 +634,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
}
if (Config.funbox === "none") {
filters.funbox.none = true;
filters.funbox["none"] = true;
} else {
for (const f of Config.funbox.split("#")) {
filters.funbox[f] = true;
@ -718,7 +663,7 @@ $(".pageAccount .topFilters button.toggleAdvancedFilters").on("click", () => {
});
function adjustScrollposition(
group: keyof ResultFilters,
group: ResultFiltersGroup,
topItem: number = 0
): void {
const slimSelect = groupSelects[group];
@ -730,7 +675,7 @@ function adjustScrollposition(
}
function selectBeforeChangeFn(
group: keyof ResultFilters,
group: ResultFiltersGroup,
selectedOptions: Option[],
oldSelectedOptions: Option[]
): void | boolean {
@ -767,7 +712,11 @@ function selectBeforeChangeFn(
break;
}
setFilter(group, selectedOption.value, true);
setFilter(
group,
selectedOption.value as ResultFiltersGroupItem<typeof group>,
true
);
}
updateActive();
@ -987,7 +936,7 @@ $(".group.presetFilterButtons .filterBtns").on(
function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
const filter = deepCopyFilter(filterIn);
Object.entries(defaultResultFilters).forEach((entry) => {
const key = entry[0] as keyof ResultFilters;
const key = entry[0] as ResultFiltersGroup;
const value = entry[1];
if (filter[key] === undefined) {
// @ts-expect-error key and value is based on default filter so this is safe to ignore

View file

@ -17,7 +17,11 @@ import Format from "../utils/format";
// @ts-expect-error TODO: update slim-select
import SlimSelect from "slim-select";
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
import { LeaderboardEntry } from "@monkeytype/shared-types";
import {
LeaderboardEntry,
LeaderboardRank,
} from "@monkeytype/contracts/schemas/leaderboards";
import { Mode } from "@monkeytype/contracts/schemas/shared";
const wrapperId = "leaderboardsWrapper";
@ -35,7 +39,9 @@ let currentData: {
};
let currentRank: {
[_key in LbKey]: Ape.Leaderboards.GetRank | Record<string, never>;
[_key in LbKey]:
| (LeaderboardRank & { minWpm?: number }) //Daily LB rank has minWpm
| Record<string, never>;
} = {
"15": {},
"60": {},
@ -425,10 +431,10 @@ function updateYesterdayButton(): void {
}
}
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore: number } {
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore?: 1 } {
const isDaily = currentTimeRange === "daily";
const isViewingDailyAndButtonIsActive = isDaily && showingYesterday;
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : 0;
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : undefined;
return {
isDaily,
@ -443,57 +449,59 @@ async function update(): Promise<void> {
showLoader("15");
showLoader("60");
const timeModes = ["15", "60"];
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
const requestData = isDaily
? Ape.leaderboards.getDaily
: Ape.leaderboards.get;
const requestRank = isDaily
? Ape.leaderboards.getDailyRank
: Ape.leaderboards.getRank;
const lbDataRequests = timeModes.map(async (mode2) => {
return Ape.leaderboards.get({
language: currentLanguage,
mode: "time",
mode2,
...getDailyLeaderboardQuery(),
});
});
const baseQuery = {
language: currentLanguage,
mode: "time" as Mode,
daysBefore,
};
const lbRankRequests: Promise<
Ape.HttpClientResponse<Ape.Leaderboards.GetRank>
>[] = [];
if (isAuthenticated()) {
lbRankRequests.push(
...timeModes.map(async (mode2) => {
return Ape.leaderboards.getRank({
language: currentLanguage,
mode: "time",
mode2,
...getDailyLeaderboardQuery(),
});
})
const fallbackResponse = { status: 200, body: { message: "", data: null } };
const lbRank15Request = isAuthenticated()
? requestRank({ query: { ...baseQuery, mode2: "15" } })
: fallbackResponse;
const lbRank60Request = isAuthenticated()
? requestRank({ query: { ...baseQuery, mode2: "60" } })
: fallbackResponse;
const [lb15Data, lb60Data, lb15Rank, lb60Rank] = await Promise.all([
requestData({ query: { ...baseQuery, mode2: "15" } }),
requestData({ query: { ...baseQuery, mode2: "60" } }),
lbRank15Request,
lbRank60Request,
]);
if (
lb15Data.status !== 200 ||
lb60Data.status !== 200 ||
lb15Rank.status !== 200 ||
lb60Rank.status !== 200
) {
const failedResponses = [lb15Data, lb60Data, lb15Rank, lb60Rank].filter(
(it) => it.status !== 200
);
}
const responses = await Promise.all(lbDataRequests);
const rankResponses = await Promise.all(lbRankRequests);
const failedResponses = [
...(responses.filter((response) => response.status !== 200) ?? []),
...(rankResponses.filter((response) => response.status !== 200) ?? []),
];
if (failedResponses.length > 0) {
hideLoader("15");
hideLoader("60");
Notifications.add(
"Failed to load leaderboards: " + failedResponses[0]?.message,
"Failed to load leaderboards: " + failedResponses[0]?.body.message,
-1
);
return;
}
const [lb15Data, lb60Data] = responses.map((response) => response.data);
const [lb15Rank, lb60Rank] = rankResponses.map((response) => response.data);
if (lb15Data !== undefined && lb15Data !== null) currentData["15"] = lb15Data;
if (lb60Data !== undefined && lb60Data !== null) currentData["60"] = lb60Data;
if (lb15Rank !== undefined && lb15Rank !== null) currentRank["15"] = lb15Rank;
if (lb60Rank !== undefined && lb60Rank !== null) currentRank["60"] = lb60Rank;
if (lb15Data.body.data !== null) currentData["15"] = lb15Data.body.data;
if (lb60Data.body.data !== null) currentData["60"] = lb60Data.body.data;
if (lb15Rank.body.data !== null) currentRank["15"] = lb15Rank.body.data;
if (lb60Rank.body.data !== null) currentRank["60"] = lb60Rank.body.data;
const leaderboardKeys: LbKey[] = ["15", "60"];
@ -541,21 +549,34 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
skipVal = 0;
}
const response = await Ape.leaderboards.get({
language: currentLanguage,
mode: "time",
mode2: lb,
skip: skipVal,
limit: limitVal,
...getDailyLeaderboardQuery(),
});
const data = response.data;
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
if (response.status !== 200 || data === null || data.length === 0) {
const requestData = isDaily
? Ape.leaderboards.getDaily
: Ape.leaderboards.get;
const response = await requestData({
query: {
language: currentLanguage,
mode: "time",
mode2: lb,
skip: skipVal,
limit: limitVal,
daysBefore,
},
});
if (
response.status !== 200 ||
response.body.data === null ||
response.body.data.length === 0
) {
hideLoader(lb);
requesting[lb] = false;
return;
}
const data = response.body.data;
if (prepend) {
currentData[lb].unshift(...data);
} else {
@ -582,14 +603,21 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
async function requestNew(lb: LbKey, skip: number): Promise<void> {
showLoader(lb);
const response = await Ape.leaderboards.get({
language: currentLanguage,
mode: "time",
mode2: lb,
skip,
...getDailyLeaderboardQuery(),
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
const requestData = isDaily
? Ape.leaderboards.getDaily
: Ape.leaderboards.get;
const response = await requestData({
query: {
language: currentLanguage,
mode: "time",
mode2: lb,
skip,
daysBefore,
},
});
const data = response.data;
if (response.status === 503) {
Notifications.add(
@ -602,10 +630,16 @@ async function requestNew(lb: LbKey, skip: number): Promise<void> {
clearBody(lb);
currentData[lb] = [];
currentAvatars[lb] = [];
if (response.status !== 200 || data === null || data.length === 0) {
if (
response.status !== 200 ||
response.body.data === null ||
response.body.data.length === 0
) {
hideLoader(lb);
return;
}
const data = response.body.data;
currentData[lb] = data;
await fillTable(lb);
@ -618,7 +652,7 @@ async function requestNew(lb: LbKey, skip: number): Promise<void> {
}
async function getAvatarUrls(
data: Ape.Leaderboards.GetLeaderboard
data: LeaderboardEntry[]
): Promise<(string | null)[]> {
return Promise.allSettled(
data.map(async (entry) =>

View file

@ -0,0 +1,24 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import * as Notifications from "./notifications";
const closed = new LocalStorageWithSchema({
key: "merchBannerClosed",
schema: z.boolean(),
fallback: false,
});
export function showIfNotClosedBefore(): void {
if (!closed.get()) {
Notifications.addBanner(
`Check out our merchandise, available at <a target="_blank" rel="noopener" href="https://monkeytype.store/">monkeytype.store</a>`,
1,
"./images/merch2.png",
false,
() => {
closed.set(true);
},
true
);
}
}

View file

@ -5,24 +5,28 @@ import * as Notifications from "./notifications";
import { format } from "date-fns/format";
import * as Alerts from "./alerts";
import { PSA } from "@monkeytype/contracts/schemas/psas";
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { IdSchema } from "@monkeytype/contracts/schemas/util";
const confirmedPSAs = new LocalStorageWithSchema({
key: "confirmedPSAs",
schema: z.array(IdSchema),
fallback: [],
});
function clearMemory(): void {
window.localStorage.setItem("confirmedPSAs", JSON.stringify([]));
confirmedPSAs.set([]);
}
function getMemory(): string[] {
//TODO verify with zod?
return (
(JSON.parse(
window.localStorage.getItem("confirmedPSAs") ?? "[]"
) as string[]) ?? []
);
return confirmedPSAs.get();
}
function setMemory(id: string): void {
const list = getMemory();
list.push(id);
window.localStorage.setItem("confirmedPSAs", JSON.stringify(list));
confirmedPSAs.set(list);
}
async function getLatest(): Promise<PSA[] | null> {

View file

@ -4,29 +4,30 @@ import { isPopupVisible } from "../utils/misc";
import * as AdController from "../controllers/ad-controller";
import AnimatedModal from "../utils/animated-modal";
import { focusWords } from "../test/test-ui";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { z } from "zod";
type Accepted = {
security: boolean;
analytics: boolean;
};
const AcceptedSchema = z.object({
security: z.boolean(),
analytics: z.boolean(),
});
type Accepted = z.infer<typeof AcceptedSchema>;
function getAcceptedObject(): Accepted | null {
const acceptedCookies = localStorage.getItem("acceptedCookies") ?? "";
if (acceptedCookies) {
//TODO verify with zod?
return JSON.parse(acceptedCookies) as Accepted;
} else {
return null;
}
}
const acceptedCookiesLS = new LocalStorageWithSchema({
key: "acceptedCookies",
schema: AcceptedSchema,
fallback: {
security: false,
analytics: false,
},
});
function setAcceptedObject(obj: Accepted): void {
localStorage.setItem("acceptedCookies", JSON.stringify(obj));
acceptedCookiesLS.set(obj);
}
export function check(): void {
const accepted = getAcceptedObject();
if (accepted === null) {
if (acceptedCookiesLS.get() === undefined) {
show();
}
}

View file

@ -34,7 +34,7 @@ function update(mode: Mode): void {
if (allmode2 === undefined) return;
const list: PBWithMode2[] = [];
(Object.keys(allmode2) as Mode2<Mode>[]).forEach(function (key) {
Object.keys(allmode2).forEach(function (key) {
let pbs = allmode2[key] ?? [];
pbs = pbs.sort(function (a, b) {
return b.wpm - a.wpm;

View file

@ -58,7 +58,7 @@ function updateIndicatorAndButton(): void {
if (!val) {
indicator?.hide();
$("#saveCustomTextModal button.save").addClass("disabled");
$("#saveCustomTextModal button.save").prop("disabled", true);
} else {
const names = CustomText.getCustomTextNames(checkbox);
if (names.includes(val)) {

View file

@ -43,7 +43,7 @@ function updateURL(): void {
}
if (getCheckboxValue("mode2")) {
settings[1] = getMode2(Config, currentQuote) as Mode2<Mode>;
settings[1] = getMode2(Config, currentQuote);
}
if (getCheckboxValue("customText")) {

View file

@ -8,7 +8,10 @@ import * as ChartController from "../controllers/chart-controller";
import * as ConnectionState from "../states/connection";
import { intervalToDuration } from "date-fns/intervalToDuration";
import * as Skeleton from "../utils/skeleton";
import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
import {
TypingStats,
SpeedHistogram,
} from "@monkeytype/contracts/schemas/public";
function reset(): void {
$(".pageAbout .contributors").empty();
@ -19,7 +22,7 @@ function reset(): void {
}
let speedHistogramResponseData: SpeedHistogram | null;
let typingStatsResponseData: PublicTypingStats | null;
let typingStatsResponseData: TypingStats | null;
function updateStatsAndHistogram(): void {
if (speedHistogramResponseData) {
@ -97,25 +100,27 @@ async function getStatsAndHistogramData(): Promise<void> {
return;
}
const speedStats = await Ape.publicStats.getSpeedHistogram({
language: "english",
mode: "time",
mode2: "60",
const speedStats = await Ape.public.getSpeedHistogram({
query: {
language: "english",
mode: "time",
mode2: "60",
},
});
if (speedStats.status >= 200 && speedStats.status < 300) {
speedHistogramResponseData = speedStats.data;
if (speedStats.status === 200) {
speedHistogramResponseData = speedStats.body.data;
} else {
Notifications.add(
`Failed to get global speed stats for histogram: ${speedStats.message}`,
`Failed to get global speed stats for histogram: ${speedStats.body.message}`,
-1
);
}
const typingStats = await Ape.publicStats.getTypingStats();
if (typingStats.status >= 200 && typingStats.status < 300) {
typingStatsResponseData = typingStats.data;
const typingStats = await Ape.public.getTypingStats();
if (typingStats.status === 200) {
typingStatsResponseData = typingStats.body.data;
} else {
Notifications.add(
`Failed to get global typing stats: ${speedStats.message}`,
`Failed to get global typing stats: ${speedStats.body.message}`,
-1
);
}

View file

@ -34,6 +34,7 @@ import {
Mode2Custom,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users";
let filterDebug = false;
//toggle filterdebug
@ -386,7 +387,7 @@ async function fillContent(): Promise<void> {
return;
}
let puncfilter: MonkeyTypes.Filter<"punctuation"> = "off";
let puncfilter: ResultFiltersGroupItem<"punctuation"> = "off";
if (result.punctuation) {
puncfilter = "on";
}
@ -397,7 +398,7 @@ async function fillContent(): Promise<void> {
return;
}
let numfilter: MonkeyTypes.Filter<"numbers"> = "off";
let numfilter: ResultFiltersGroupItem<"numbers"> = "off";
if (result.numbers) {
numfilter = "on";
}

View file

@ -1,7 +1,7 @@
import Config from "./config";
import * as Misc from "./utils/misc";
import * as MonkeyPower from "./elements/monkey-power";
import * as Notifications from "./elements/notifications";
import * as MerchBanner from "./elements/merch-banner";
import * as CookiesModal from "./modals/cookies";
import * as ConnectionState from "./states/connection";
import * as FunboxList from "./test/funbox/funbox-list";
@ -18,21 +18,7 @@ $((): void => {
//this line goes back to pretty much the beginning of the project and im pretty sure its here
//to make sure the initial theme application doesnt animate the background color
$("body").css("transition", "background .25s, transform .05s");
const merchBannerClosed =
window.localStorage.getItem("merchbannerclosed") === "true";
if (!merchBannerClosed) {
Notifications.addBanner(
`Check out our merchandise, available at <a target="_blank" rel="noopener" href="https://monkeytype.store/">monkeytype.store</a>`,
1,
"./images/merch2.png",
false,
() => {
window.localStorage.setItem("merchbannerclosed", "true");
},
true
);
}
MerchBanner.showIfNotClosedBefore();
setTimeout(() => {
FunboxList.get(Config.funbox).forEach((it) =>
it.functions?.applyGlobalCSS?.()

View file

@ -1,7 +1,16 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
const ls = new LocalStorageWithSchema({
key: "prefersArabicLazyMode",
schema: z.boolean(),
fallback: true,
});
export function get(): boolean {
return (localStorage.getItem("prefersArabicLazyMode") ?? "true") === "true";
return ls.get();
}
export function set(value: boolean): void {
localStorage.setItem("prefersArabicLazyMode", value ? "true" : "false");
ls.set(value);
}

View file

@ -1,16 +1,22 @@
import { z } from "zod";
import { getLatestReleaseFromGitHub } from "../utils/json-data";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
const LOCALSTORAGE_KEY = "lastSeenVersion";
const memoryLS = new LocalStorageWithSchema({
key: "lastSeenVersion",
schema: z.string(),
fallback: "",
});
let version: null | string = null;
let isVersionNew: null | boolean = null;
function setMemory(v: string): void {
window.localStorage.setItem(LOCALSTORAGE_KEY, v);
memoryLS.set(v);
}
function getMemory(): string {
return window.localStorage.getItem(LOCALSTORAGE_KEY) ?? "";
return memoryLS.get();
}
async function check(): Promise<void> {

View file

@ -63,8 +63,12 @@ function getTargetPositionLeft(
const currentLetter = currentWordNodeList[inputLen] as
| HTMLElement
| undefined;
const lastWordLetter = currentWordNodeList[wordLen - 1] as HTMLElement;
const lastInputLetter = currentWordNodeList[inputLen - 1] as HTMLElement;
const lastWordLetter = currentWordNodeList[wordLen - 1] as
| HTMLElement
| undefined;
const lastInputLetter = currentWordNodeList[inputLen - 1] as
| HTMLElement
| undefined;
if (isLanguageRightToLeft) {
if (inputLen < wordLen && currentLetter) {
@ -73,11 +77,11 @@ function getTargetPositionLeft(
(fullWidthCaret ? 0 : fullWidthCaretWidth);
} else if (!invisibleExtraLetters) {
positionOffsetToWord =
lastInputLetter.offsetLeft -
(lastInputLetter?.offsetLeft ?? 0) -
(fullWidthCaret ? fullWidthCaretWidth : 0);
} else {
positionOffsetToWord =
lastWordLetter.offsetLeft -
(lastWordLetter?.offsetLeft ?? 0) -
(fullWidthCaret ? fullWidthCaretWidth : 0);
}
} else {
@ -85,10 +89,12 @@ function getTargetPositionLeft(
positionOffsetToWord = currentLetter?.offsetLeft;
} else if (!invisibleExtraLetters) {
positionOffsetToWord =
lastInputLetter.offsetLeft + lastInputLetter.offsetWidth;
(lastInputLetter?.offsetLeft ?? 0) +
(lastInputLetter?.offsetWidth ?? 0);
} else {
positionOffsetToWord =
lastWordLetter.offsetLeft + lastWordLetter.offsetWidth;
(lastWordLetter?.offsetLeft ?? 0) +
(lastWordLetter?.offsetWidth ?? 0);
}
}
result = activeWordElement.offsetLeft + positionOffsetToWord;
@ -143,7 +149,9 @@ export async function updatePosition(noAnim = false): Promise<void> {
const currentLetter = currentWordNodeList[inputLen] as
| HTMLElement
| undefined;
const lastWordLetter = currentWordNodeList[wordLen - 1] as HTMLElement;
const lastWordLetter = currentWordNodeList[wordLen - 1] as
| HTMLElement
| undefined;
const spaceWidth = getSpaceWidth(activeWordEl);
@ -156,7 +164,7 @@ export async function updatePosition(noAnim = false): Promise<void> {
lastWordLetter?.offsetHeight ||
Config.fontSize * Numbers.convertRemToPixels(1);
const letterPosTop = lastWordLetter.offsetTop;
const letterPosTop = lastWordLetter?.offsetTop ?? 0;
const diff = letterHeight - caret.offsetHeight;
let newTop = activeWordEl.offsetTop + letterPosTop + diff / 2;
if (Config.caretStyle === "underline") {

View file

@ -7,6 +7,36 @@ import {
CustomTextLimitMode,
CustomTextMode,
} from "@monkeytype/shared-types";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { z } from "zod";
//zod schema for an object with string keys and string values
const CustomTextObjectSchema = z.record(z.string(), z.string());
type CustomTextObject = z.infer<typeof CustomTextObjectSchema>;
const CustomTextLongObjectSchema = z.record(
z.string(),
z.object({ text: z.string(), progress: z.number() })
);
type CustomTextLongObject = z.infer<typeof CustomTextLongObjectSchema>;
const customTextLS = new LocalStorageWithSchema({
key: "customText",
schema: CustomTextObjectSchema,
fallback: {},
});
//todo maybe add migrations here?
const customTextLongLS = new LocalStorageWithSchema({
key: "customTextLong",
schema: CustomTextLongObjectSchema,
fallback: {},
});
// function setLocalStorage(data: CustomTextObject): void {
// window.localStorage.setItem("customText", JSON.stringify(data));
// }
// function setLocalStorageLong(data: CustomTextLongObject): void {
let text: string[] = [
"The",
@ -95,10 +125,6 @@ export function getData(): CustomTextData {
};
}
type CustomTextObject = Record<string, string>;
type CustomTextLongObject = Record<string, { text: string; progress: number }>;
export function getCustomText(name: string, long = false): string[] {
if (long) {
const customTextLong = getLocalStorageLong();
@ -185,23 +211,19 @@ export function setCustomTextLongProgress(
}
function getLocalStorage(): CustomTextObject {
return JSON.parse(
window.localStorage.getItem("customText") ?? "{}"
) as CustomTextObject;
return customTextLS.get();
}
function getLocalStorageLong(): CustomTextLongObject {
return JSON.parse(
window.localStorage.getItem("customTextLong") ?? "{}"
) as CustomTextLongObject;
return customTextLongLS.get();
}
function setLocalStorage(data: CustomTextObject): void {
window.localStorage.setItem("customText", JSON.stringify(data));
customTextLS.set(data);
}
function setLocalStorageLong(data: CustomTextLongObject): void {
window.localStorage.setItem("customTextLong", JSON.stringify(data));
customTextLongLS.set(data);
}
export function getCustomTextNames(long = false): string[] {

View file

@ -8,7 +8,6 @@ import * as Numbers from "../utils/numbers";
import * as JSONData from "../utils/json-data";
import * as TestState from "./test-state";
import * as ConfigEvent from "../observables/config-event";
import { Mode2 } from "@monkeytype/contracts/schemas/shared";
type Settings = {
wpm: number;
@ -67,21 +66,22 @@ async function resetCaretPosition(): Promise<void> {
export async function init(): Promise<void> {
$("#paceCaret").addClass("hidden");
const mode2 = Misc.getMode2(Config, TestWords.currentQuote) as Mode2<
typeof Config.mode
>;
let wpm;
const mode2 = Misc.getMode2(Config, TestWords.currentQuote);
let wpm = 0;
if (Config.paceCaret === "pb") {
wpm = await DB.getLocalPB(
Config.mode,
mode2,
Config.punctuation,
Config.numbers,
Config.language,
Config.difficulty,
Config.lazyMode,
Config.funbox
);
wpm =
(
await DB.getLocalPB(
Config.mode,
mode2,
Config.punctuation,
Config.numbers,
Config.language,
Config.difficulty,
Config.lazyMode,
Config.funbox
)
)?.wpm ?? 0;
} else if (Config.paceCaret === "tagPb") {
wpm = await DB.getActiveTagsPB(
Config.mode,

View file

@ -176,7 +176,7 @@ async function updateGraph(): Promise<void> {
export async function updateGraphPBLine(): Promise<void> {
const themecolors = await ThemeColors.getAll();
const lpb = await DB.getLocalPB(
const localPb = await DB.getLocalPB(
result.mode,
result.mode2,
result.punctuation ?? false,
@ -186,9 +186,12 @@ export async function updateGraphPBLine(): Promise<void> {
result.lazyMode ?? false,
result.funbox ?? "none"
);
if (lpb === 0) return;
const localPbWpm = localPb?.wpm ?? 0;
if (localPbWpm === 0) return;
const typingSpeedUnit = getTypingSpeedUnit(Config.typingSpeedUnit);
const chartlpb = Numbers.roundTo2(typingSpeedUnit.fromWpm(lpb)).toFixed(2);
const chartlpb = Numbers.roundTo2(
typingSpeedUnit.fromWpm(localPbWpm)
).toFixed(2);
resultAnnotation.push({
display: true,
type: "line",
@ -394,7 +397,7 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
const canGetPb = await resultCanGetPb();
if (canGetPb.value) {
const lpb = await DB.getLocalPB(
const localPb = await DB.getLocalPB(
Config.mode,
result.mode2,
Config.punctuation,
@ -404,7 +407,8 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
Config.lazyMode,
Config.funbox
);
pbDiff = result.wpm - lpb;
const localPbWpm = localPb?.wpm ?? 0;
pbDiff = result.wpm - localPbWpm;
if (pbDiff <= 0) {
hideCrown();
} else {
@ -415,7 +419,7 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
);
}
} else {
const lpb = await DB.getLocalPB(
const localPb = await DB.getLocalPB(
Config.mode,
result.mode2,
Config.punctuation,
@ -425,7 +429,8 @@ export async function updateCrown(dontSave: boolean): Promise<void> {
Config.lazyMode,
"none"
);
pbDiff = result.wpm - lpb;
const localPbWpm = localPb?.wpm ?? 0;
pbDiff = result.wpm - localPbWpm;
if (pbDiff <= 0) {
// hideCrown();
showCrown("warning");

View file

@ -74,6 +74,7 @@ import {
} from "@monkeytype/shared-types";
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
let failReason = "";
const koInputVisual = document.getElementById("koInputVisual") as HTMLElement;
@ -1345,22 +1346,30 @@ async function saveResult(
if (response?.data?.isPb !== undefined && response.data.isPb) {
//new pb
if (
//@ts-expect-error TODO fix this
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
DB.getSnapshot()?.personalBests?.[Config.mode]?.[completedEvent.mode2]
) {
const localPb = await DB.getLocalPB(
completedEvent.mode,
completedEvent.mode2,
completedEvent.punctuation,
completedEvent.numbers,
completedEvent.language,
completedEvent.difficulty,
completedEvent.lazyMode,
completedEvent.funbox
);
if (localPb !== undefined) {
Result.showConfetti();
}
Result.showCrown("normal");
await DB.saveLocalPB(
Config.mode,
completedEvent.mode,
completedEvent.mode2,
Config.punctuation,
Config.numbers,
Config.language,
Config.difficulty,
Config.lazyMode,
completedEvent.punctuation,
completedEvent.numbers,
completedEvent.language,
completedEvent.difficulty,
completedEvent.lazyMode,
completedEvent.wpm,
completedEvent.acc,
completedEvent.rawWpm,

View file

@ -912,14 +912,6 @@ export async function updateWordElement(inputOverride?: string): Promise<void> {
ret += `<letter>` + currentWord[i] + "</letter>";
}
}
if (Config.highlightMode === "letter") {
if (input.length > currentWord.length && !Config.blindMode) {
wordAtIndex.classList.add("error");
} else if (input.length === currentWord.length) {
wordAtIndex.classList.remove("error");
}
}
}
wordAtIndex.innerHTML = ret;

View file

@ -203,12 +203,6 @@ declare namespace MonkeyTypes {
};
};
type Leaderboards = {
time: {
[_key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[];
};
};
type QuoteRatings = Record<string, Record<number, number>>;
type UserTag = import("@monkeytype/shared-types").UserTag & {
@ -237,7 +231,7 @@ declare namespace MonkeyTypes {
inboxUnreadSize: number;
streak: number;
maxStreak: number;
filterPresets: import("@monkeytype/shared-types").ResultFilters[];
filterPresets: import("@monkeytype/contracts/schemas/users").ResultFilters[];
isPremium: boolean;
streakHourOffset?: number;
config: import("@monkeytype/contracts/schemas/configs").Config;
@ -251,15 +245,6 @@ declare namespace MonkeyTypes {
testActivityByYear?: { [key: string]: TestActivityCalendar };
};
type Group<
G extends keyof import("@monkeytype/shared-types").ResultFilters = keyof import("@monkeytype/shared-types").ResultFilters
> = G extends G ? import("@monkeytype/shared-types").ResultFilters[G] : never;
type Filter<G extends Group = Group> =
G extends keyof import("@monkeytype/shared-types").ResultFilters
? keyof import("@monkeytype/shared-types").ResultFilters[G]
: never;
type TimerStats = {
dateNow: number;
now: number;

View file

@ -0,0 +1,66 @@
import { ZodIssue } from "zod";
export class LocalStorageWithSchema<T> {
private key: string;
private schema: Zod.Schema<T>;
private fallback: T;
private migrate?: (value: unknown, zodIssues: ZodIssue[]) => T;
constructor(options: {
key: string;
schema: Zod.Schema<T>;
fallback: T;
migrate?: (value: unknown, zodIssues: ZodIssue[]) => T;
}) {
this.key = options.key;
this.schema = options.schema;
this.fallback = options.fallback;
this.migrate = options.migrate;
}
public get(): T {
const value = window.localStorage.getItem(this.key);
if (value === null) {
return this.fallback;
}
let jsonParsed;
try {
jsonParsed = JSON.parse(value);
} catch (e) {
console.error(
`Value from localStorage ${this.key} was not a valid JSON, using fallback`,
e
);
window.localStorage.removeItem(this.key);
return this.fallback;
}
const schemaParsed = this.schema.safeParse(jsonParsed);
if (schemaParsed.success) {
return schemaParsed.data;
}
console.error(
`Value from localStorage ${this.key} failed schema validation, migrating`,
schemaParsed.error
);
const newValue =
this.migrate?.(jsonParsed, schemaParsed.error.issues) ?? this.fallback;
window.localStorage.setItem(this.key, JSON.stringify(newValue));
return newValue;
}
public set(data: T): boolean {
try {
const parsed = this.schema.parse(data);
window.localStorage.setItem(this.key, JSON.stringify(parsed));
return true;
} catch (e) {
console.error(`Failed to set ${this.key} in localStorage`, e);
return false;
}
}
}

View file

@ -1,10 +1,18 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "./local-storage-with-schema";
import { isDevEnvironment } from "./misc";
const nativeLog = console.log;
const nativeWarn = console.warn;
const nativeError = console.error;
let debugLogs = localStorage.getItem("debugLogs") === "true";
const debugLogsLS = new LocalStorageWithSchema({
key: "debugLogs",
schema: z.boolean(),
fallback: false,
});
let debugLogs = debugLogsLS.get();
if (isDevEnvironment()) {
debugLogs = true;
@ -14,7 +22,7 @@ if (isDevEnvironment()) {
export function toggleDebugLogs(): void {
debugLogs = !debugLogs;
info(`Debug logs ${debugLogs ? "enabled" : "disabled"}`);
localStorage.setItem("debugLogs", debugLogs.toString());
debugLogsLS.set(debugLogs);
}
function info(...args: unknown[]): void {

View file

@ -673,4 +673,8 @@ export function updateTitle(title?: string): void {
}
}
export function isObject(obj: unknown): obj is Record<string, unknown> {
return typeof obj === "object" && !Array.isArray(obj) && obj !== null;
}
// DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES

View file

@ -1001,6 +1001,7 @@
"shell",
"neck",
"program",
"public"
"public",
"universe"
]
}

View file

@ -84,7 +84,7 @@
"kasama",
"taon",
"mahal",
"makita",
"kita",
"ninyo",
"ngunit",
"marami",
@ -107,7 +107,7 @@
"panahon",
"ayaw",
"buong",
"bayan",
"pasok",
"ulit",
"tungkol",
"tama",
@ -125,8 +125,8 @@
"totoo",
"tunay",
"amin",
"laging",
"gawin",
"lagi",
"gawa",
"kanina",
"mundo",
"dati",
@ -139,7 +139,7 @@
"sina",
"daan",
"ganito",
"sige",
"diyan",
"lugar",
"laban",
"bukas",
@ -160,7 +160,7 @@
"labas",
"umalis",
"rin",
"unti",
"kaunti",
"ayon",
"uri",
"kaysa",
@ -168,30 +168,30 @@
"maliit",
"malaki",
"dulo",
"sumunod",
"sinabi",
"kamay",
"luma",
"tulong",
"saka",
"tingnan",
"kabila",
"gamitin",
"gamit",
"kunin",
"pagitan",
"tumayo",
"ilagay",
"mukha",
"pangkat",
"maayos",
"subalit",
"malapit",
"salita",
"susunod",
"simulan",
"buksan",
"mahaba",
"simula",
"isip",
"tagal",
"kay",
"mataas",
"takbo",
"higit",
"sobra",
"likod",
"ilalim",
"naging",

View file

@ -3300,6 +3300,24 @@
"source": "Oscar Wilde",
"length": 124,
"id": 575
},
{
"text": "Die Welt wird von deinem Beispiel verändert, nicht von deiner Meinung.",
"source": "Paulo Coelho",
"length": 70,
"id": 576
},
{
"text": "Die Einzige Macht, die du auf diesem Planeten hast, ist die Macht deiner Entscheidungen.",
"source": "Paulo Coelho",
"length": 88,
"id": 577
},
{
"text": "Eines Tages wirdst du erwachen, und es wird keine Zeit dafür geben, zu machen, was du schon immer machen wolltest. Mach es lieber jetzt.",
"source": "Paulo Coelho",
"length": 136,
"id": 578
}
]
}

View file

@ -33,6 +33,11 @@ const BASE_CONFIG = {
open: process.env.SERVER_OPEN !== "false",
port: 3000,
host: process.env.BACKEND_URL !== undefined,
watch: {
//we rebuild the whole contracts package when a file changes
//so we only want to watch one file
ignored: [/.*\/packages\/contracts\/dist\/(?!configs).*/],
},
},
clearScreen: false,
root: "src",

View file

@ -8,6 +8,7 @@
"full-check": "turbo lint ts-check build test validate-json",
"prepare": "husky install",
"pre-commit": "lint-staged",
"ts-check": "turbo run ts-check",
"lint": "turbo run lint",
"lint-be": "turbo run lint --filter @monkeytype/backend",
"lint-fe": "turbo run lint --filter @monkeytype/frontend",
@ -56,24 +57,18 @@
"node": "20.16.0"
},
"devDependencies": {
"@monkeytype/release": "workspace:*",
"@commitlint/cli": "17.7.1",
"@commitlint/config-conventional": "17.7.0",
"@vitest/coverage-v8": "2.0.5",
"@monkeytype/release": "workspace:*",
"conventional-changelog": "4.0.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.0.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.0",
"eslint-plugin-json": "2.1.2",
"eslint-plugin-require-path-exists": "1.1.9",
"husky": "8.0.1",
"knip": "2.19.2",
"lint-staged": "13.2.3",
"only-allow": "1.2.1",
"prettier": "2.5.1",
"turbo": "2.0.9",
"typescript": "5.5.4",
"wait-for-localhost-cli": "3.2.0"
"vitest": "2.0.5"
},
"lint-staged": {
"*.{json,scss,css,html}": [

View file

@ -81,9 +81,10 @@ if (isWatch) {
console.log("Starting watch mode...");
chokidar.watch("./src/**/*.ts").on(
"change",
(path) => {
(_path) => {
console.log("File change detected...");
build(path, false, false);
// build(path, false, false);
buildAll(false, false);
},
{
ignoreInitial: true,

View file

@ -4,6 +4,8 @@ import { apeKeysContract } from "./ape-keys";
import { configsContract } from "./configs";
import { presetsContract } from "./presets";
import { psasContract } from "./psas";
import { publicContract } from "./public";
import { leaderboardsContract } from "./leaderboards";
const c = initContract();
@ -13,4 +15,6 @@ export const contract = c.router({
configs: configsContract,
presets: presetsContract,
psas: psasContract,
public: publicContract,
leaderboards: leaderboardsContract,
});

View file

@ -0,0 +1,174 @@
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
responseWithData,
responseWithNullableData,
} from "./schemas/api";
import {
DailyLeaderboardRankSchema,
LeaderboardEntrySchema,
LeaderboardRankSchema,
XpLeaderboardEntrySchema,
XpLeaderboardRankSchema,
} from "./schemas/leaderboards";
import { LanguageSchema } from "./schemas/util";
import { Mode2Schema, ModeSchema } from "./schemas/shared";
import { initContract } from "@ts-rest/core";
export const LanguageAndModeQuerySchema = z.object({
language: LanguageSchema,
mode: ModeSchema,
mode2: Mode2Schema,
});
export type LanguageAndModeQuery = z.infer<typeof LanguageAndModeQuerySchema>;
const PaginationQuerySchema = z.object({
skip: z.number().int().nonnegative().optional(),
limit: z.number().int().nonnegative().max(50).optional(),
});
export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
PaginationQuerySchema
);
export type GetLeaderboardQuery = z.infer<typeof GetLeaderboardQuerySchema>;
export const GetLeaderboardResponseSchema = responseWithData(
z.array(LeaderboardEntrySchema)
);
export type GetLeaderboardResponse = z.infer<
typeof GetLeaderboardResponseSchema
>;
export const GetLeaderboardRankResponseSchema = responseWithData(
LeaderboardRankSchema
);
export type GetLeaderboardRankResponse = z.infer<
typeof GetLeaderboardRankResponseSchema
>;
export const GetDailyLeaderboardRankQuerySchema =
LanguageAndModeQuerySchema.extend({
daysBefore: z.literal(1).optional(),
});
export type GetDailyLeaderboardRankQuery = z.infer<
typeof GetDailyLeaderboardRankQuerySchema
>;
export const GetDailyLeaderboardQuerySchema =
GetDailyLeaderboardRankQuerySchema.merge(PaginationQuerySchema);
export type GetDailyLeaderboardQuery = z.infer<
typeof GetDailyLeaderboardQuerySchema
>;
export const GetLeaderboardDailyRankResponseSchema = responseWithData(
DailyLeaderboardRankSchema
);
export type GetLeaderboardDailyRankResponse = z.infer<
typeof GetLeaderboardDailyRankResponseSchema
>;
export const GetWeeklyXpLeaderboardQuerySchema = PaginationQuerySchema.extend({
weeksBefore: z.literal(1).optional(),
});
export type GetWeeklyXpLeaderboardQuery = z.infer<
typeof GetWeeklyXpLeaderboardQuerySchema
>;
export const GetWeeklyXpLeaderboardResponseSchema = responseWithData(
z.array(XpLeaderboardEntrySchema)
);
export type GetWeeklyXpLeaderboardResponse = z.infer<
typeof GetWeeklyXpLeaderboardResponseSchema
>;
export const GetWeeklyXpLeaderboardRankResponseSchema =
responseWithNullableData(XpLeaderboardRankSchema.partial());
export type GetWeeklyXpLeaderboardRankResponse = z.infer<
typeof GetWeeklyXpLeaderboardRankResponseSchema
>;
const c = initContract();
export const leaderboardsContract = c.router(
{
get: {
summary: "get leaderboard",
description: "Get all-time leaderboard.",
method: "GET",
path: "",
query: GetLeaderboardQuerySchema.strict(),
responses: {
200: GetLeaderboardResponseSchema,
},
metadata: {
authenticationOptions: { isPublic: true },
} as EndpointMetadata,
},
getRank: {
summary: "get leaderboard rank",
description:
"Get the rank of the current user on the all-time leaderboard",
method: "GET",
path: "/rank",
query: LanguageAndModeQuerySchema.strict(),
responses: {
200: GetLeaderboardRankResponseSchema,
},
metadata: {
authenticationOptions: { acceptApeKeys: true },
} as EndpointMetadata,
},
getDaily: {
summary: "get daily leaderboard",
description: "Get daily leaderboard.",
method: "GET",
path: "/daily",
query: GetDailyLeaderboardQuerySchema.strict(),
responses: {
200: GetLeaderboardResponseSchema,
},
metadata: {
authenticationOptions: { isPublic: true },
} as EndpointMetadata,
},
getDailyRank: {
summary: "get daily leaderboard rank",
description: "Get the rank of the current user on the daily leaderboard",
method: "GET",
path: "/daily/rank",
query: GetDailyLeaderboardRankQuerySchema.strict(),
responses: {
200: GetLeaderboardDailyRankResponseSchema,
},
},
getWeeklyXp: {
summary: "get weekly xp leaderboard",
description: "Get weekly xp leaderboard",
method: "GET",
path: "/xp/weekly",
query: GetWeeklyXpLeaderboardQuerySchema.strict(),
responses: {
200: GetWeeklyXpLeaderboardResponseSchema,
},
metadata: {
authenticationOptions: { isPublic: true },
} as EndpointMetadata,
},
getWeeklyXpRank: {
summary: "get weekly xp leaderboard rank",
description:
"Get the rank of the current user on the weekly xp leaderboard",
method: "GET",
path: "/xp/weekly/rank",
responses: {
200: GetWeeklyXpLeaderboardRankResponseSchema,
},
},
},
{
pathPrefix: "/leaderboards",
strictStatusCodes: true,
metadata: {
openApiTags: "leaderboards",
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -0,0 +1,70 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
responseWithData,
} from "./schemas/api";
import { SpeedHistogramSchema, TypingStatsSchema } from "./schemas/public";
import { Mode2Schema, ModeSchema } from "./schemas/shared";
import { LanguageSchema } from "./schemas/util";
export const GetSpeedHistogramQuerySchema = z
.object({
language: LanguageSchema,
mode: ModeSchema,
mode2: Mode2Schema,
})
.strict();
export type GetSpeedHistogramQuery = z.infer<
typeof GetSpeedHistogramQuerySchema
>;
export const GetSpeedHistogramResponseSchema =
responseWithData(SpeedHistogramSchema);
export type GetSpeedHistogramResponse = z.infer<
typeof GetSpeedHistogramResponseSchema
>;
export const GetTypingStatsResponseSchema = responseWithData(TypingStatsSchema);
export type GetTypingStatsResponse = z.infer<
typeof GetTypingStatsResponseSchema
>;
const c = initContract();
export const publicContract = c.router(
{
getSpeedHistogram: {
summary: "get speed histogram",
description:
"get number of users personal bests grouped by wpm level (multiples of ten)",
method: "GET",
path: "/speedHistogram",
query: GetSpeedHistogramQuerySchema,
responses: {
200: GetSpeedHistogramResponseSchema,
},
},
getTypingStats: {
summary: "get typing stats",
description: "get number of tests and time users spend typing.",
method: "GET",
path: "/typingStats",
responses: {
200: GetTypingStatsResponseSchema,
},
},
},
{
pathPrefix: "/public",
strictStatusCodes: true,
metadata: {
openApiTags: "public",
authenticationOptions: {
isPublic: true,
},
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -1,6 +1,13 @@
import { z, ZodSchema } from "zod";
export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin" | "psas";
export type OpenApiTag =
| "configs"
| "presets"
| "ape-keys"
| "admin"
| "psas"
| "public"
| "leaderboards";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */

View file

@ -1,5 +1,5 @@
import { z } from "zod";
import { token } from "./util";
import { LanguageSchema, token } from "./util";
import * as Shared from "./shared";
export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]);
@ -262,12 +262,6 @@ export type FontFamily = z.infer<typeof FontFamilySchema>;
export const ThemeNameSchema = token().max(50);
export type ThemeName = z.infer<typeof ThemeNameSchema>;
export const LanguageSchema = z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_+]+$/);
export type Language = z.infer<typeof LanguageSchema>;
export const KeymapLayoutSchema = z
.string()
.max(50)

View file

@ -0,0 +1,47 @@
import { z } from "zod";
export const LeaderboardEntrySchema = z.object({
wpm: z.number().nonnegative(),
acc: z.number().nonnegative().min(0).max(100),
timestamp: z.number().int().nonnegative(),
raw: z.number().nonnegative(),
consistency: z.number().nonnegative().optional(),
uid: z.string(),
name: z.string(),
discordId: z.string().optional(),
discordAvatar: z.string().optional(),
rank: z.number().nonnegative().int(),
badgeId: z.number().int().optional(),
isPremium: z.boolean().optional(),
});
export type LeaderboardEntry = z.infer<typeof LeaderboardEntrySchema>;
export const LeaderboardRankSchema = z.object({
count: z.number().int().nonnegative(),
rank: z.number().int().nonnegative().optional(),
entry: LeaderboardEntrySchema.optional(),
});
export type LeaderboardRank = z.infer<typeof LeaderboardRankSchema>;
export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({
minWpm: z.number().nonnegative(),
});
export type DailyLeaderboardRank = z.infer<typeof DailyLeaderboardRankSchema>;
export const XpLeaderboardEntrySchema = z.object({
uid: z.string(),
name: z.string(),
discordId: z.string().optional(),
discordAvatar: z.string().optional(),
badgeId: z.number().int().optional(),
lastActivityTimestamp: z.number().int().nonnegative(),
timeTypedSeconds: z.number().nonnegative(),
rank: z.number().nonnegative().int(),
totalXp: z.number().nonnegative().int(),
});
export type XpLeaderboardEntry = z.infer<typeof XpLeaderboardEntrySchema>;
export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({
count: z.number().int().nonnegative(),
});
export type XpLeaderboardRank = z.infer<typeof XpLeaderboardRankSchema>;

View file

@ -0,0 +1,15 @@
import { z } from "zod";
import { StringNumberSchema } from "./util";
export const SpeedHistogramSchema = z.record(
StringNumberSchema,
z.number().int()
);
export type SpeedHistogram = z.infer<typeof SpeedHistogramSchema>;
export const TypingStatsSchema = z.object({
timeTyping: z.number().nonnegative(),
testsCompleted: z.number().int().nonnegative(),
testsStarted: z.number().int().nonnegative(),
});
export type TypingStats = z.infer<typeof TypingStatsSchema>;

View file

@ -1,4 +1,4 @@
import { z } from "zod";
import { literal, z } from "zod";
import { StringNumberSchema } from "./util";
//used by config and shared
@ -33,9 +33,19 @@ export const PersonalBestsSchema = z.object({
});
export type PersonalBests = z.infer<typeof PersonalBestsSchema>;
//used by user and config
//used by user, config, public
export const ModeSchema = PersonalBestsSchema.keyof();
export type Mode = z.infer<typeof ModeSchema>;
export const Mode2Schema = z.union(
[StringNumberSchema, literal("zen"), literal("custom")],
{
errorMap: () => ({
message: 'Needs to be either a number, "zen" or "custom."',
}),
}
);
export type Mode2<M extends Mode> = M extends M
? keyof PersonalBests[M]
: never;

View file

@ -1 +1,62 @@
//tbd
import { z } from "zod";
import { IdSchema } from "./util";
import { ModeSchema } from "./shared";
export const ResultFiltersSchema = z.object({
_id: IdSchema,
name: z.string(),
pb: z.object({
no: z.boolean(),
yes: z.boolean(),
}),
difficulty: z.object({
normal: z.boolean(),
expert: z.boolean(),
master: z.boolean(),
}),
mode: z.record(ModeSchema, z.boolean()),
words: z.object({
"10": z.boolean(),
"25": z.boolean(),
"50": z.boolean(),
"100": z.boolean(),
custom: z.boolean(),
}),
time: z.object({
"15": z.boolean(),
"30": z.boolean(),
"60": z.boolean(),
"120": z.boolean(),
custom: z.boolean(),
}),
quoteLength: z.object({
short: z.boolean(),
medium: z.boolean(),
long: z.boolean(),
thicc: z.boolean(),
}),
punctuation: z.object({
on: z.boolean(),
off: z.boolean(),
}),
numbers: z.object({
on: z.boolean(),
off: z.boolean(),
}),
date: z.object({
last_day: z.boolean(),
last_week: z.boolean(),
last_month: z.boolean(),
last_3months: z.boolean(),
all: z.boolean(),
}),
tags: z.record(z.boolean()),
language: z.record(z.boolean()),
funbox: z.record(z.boolean()),
});
export type ResultFilters = z.infer<typeof ResultFiltersSchema>;
export type ResultFiltersGroup = keyof ResultFilters;
export type ResultFiltersGroupItem<T extends ResultFiltersGroup> =
keyof ResultFilters[T];

View file

@ -1,9 +1,12 @@
import { z, ZodString } from "zod";
export const StringNumberSchema = z.custom<`${number}`>((val) => {
return typeof val === "string" ? /^\d+$/.test(val) : false;
});
export const StringNumberSchema = z
.string()
.regex(
/^\d+$/,
'Needs to be a number or a number represented as a string e.g. "10".'
)
.or(z.number().transform(String));
export type StringNumber = z.infer<typeof StringNumberSchema>;
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);
@ -13,3 +16,9 @@ export type Id = z.infer<typeof IdSchema>;
export const TagSchema = token().max(50);
export type Tag = z.infer<typeof TagSchema>;
export const LanguageSchema = z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]");
export type Language = z.infer<typeof LanguageSchema>;

View file

@ -32,7 +32,7 @@ module.exports = {
"no-constant-condition": ["error"],
"no-constant-binary-expression": "error",
"no-unused-vars": [
"warn",
"error",
{
argsIgnorePattern: "^(_|e|event)",
caughtErrorsIgnorePattern: "^(_|e|error)",
@ -91,11 +91,16 @@ module.exports = {
"error",
{ ignoreArrowShorthand: true },
],
"@typescript-eslint/explicit-function-return-type": ["error"],
"@typescript-eslint/explicit-function-return-type": [
"error",
{
allowExpressions: true,
},
],
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
"error",
{
argsIgnorePattern: "^(_|e|event)",
caughtErrorsIgnorePattern: "^(_|e|error)",
@ -116,7 +121,7 @@ module.exports = {
checksVoidReturn: false,
},
],
"@typescript-eslint/promise-function-async": "warn",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/strict-boolean-expressions": [
"error",
@ -124,7 +129,7 @@ module.exports = {
],
"@typescript-eslint/non-nullable-type-assertion-style": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/consistent-type-definitions": ["warn", "type"],
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/no-invalid-void-type": "off",
"import/namespace": "off",
},

View file

@ -4,6 +4,10 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.0.1",
"@typescript-eslint/parser": "8.0.1",
"eslint-config-prettier": "9.1.0"
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.0",
"eslint-plugin-json": "3.1.0",
"eslint-plugin-require-path-exists": "1.1.9"
}
}

View file

@ -240,94 +240,6 @@ export type CustomTextDataWithTextLen = Omit<CustomTextData, "text"> & {
textLen: number;
};
export type ResultFilters = {
_id: string;
name: string;
pb: {
no: boolean;
yes: boolean;
};
difficulty: {
normal: boolean;
expert: boolean;
master: boolean;
};
mode: {
words: boolean;
time: boolean;
quote: boolean;
zen: boolean;
custom: boolean;
};
words: {
"10": boolean;
"25": boolean;
"50": boolean;
"100": boolean;
custom: boolean;
};
time: {
"15": boolean;
"30": boolean;
"60": boolean;
"120": boolean;
custom: boolean;
};
quoteLength: {
short: boolean;
medium: boolean;
long: boolean;
thicc: boolean;
};
punctuation: {
on: boolean;
off: boolean;
};
numbers: {
on: boolean;
off: boolean;
};
date: {
last_day: boolean;
last_week: boolean;
last_month: boolean;
last_3months: boolean;
all: boolean;
};
tags: Record<string, boolean>;
language: Record<string, boolean>;
funbox: {
none?: boolean;
} & Record<string, boolean>;
};
export type SpeedHistogram = {
[key: string]: number;
};
export type PublicTypingStats = {
type: string;
timeTyping: number;
testsCompleted: number;
testsStarted: number;
};
export type LeaderboardEntry = {
_id: string;
wpm: number;
acc: number;
timestamp: number;
raw: number;
consistency?: number;
uid: string;
name: string;
discordId?: string;
discordAvatar?: string;
rank: number;
badgeId?: number;
isPremium?: boolean;
};
export type PostResultResponse = {
isPb: boolean;
tagPbs: string[];
@ -419,7 +331,7 @@ export type User = {
verified?: boolean;
needsToChangeName?: boolean;
quoteMod?: boolean | string;
resultFilterPresets?: ResultFilters[];
resultFilterPresets?: import("@monkeytype/contracts/schemas/users").ResultFilters[];
testActivity?: TestActivity;
};

File diff suppressed because it is too large Load diff

11
vitest.config.js Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
enabled: true,
include: ["**/*.ts"],
reporter: ["json"],
},
},
});

1
vitest.workspace.json Normal file
View file

@ -0,0 +1 @@
["packages/*", "frontend", "backend"]