diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 5f44cffe1d41..1126650fefef 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -1271,7 +1271,7 @@ describe("UserDal", () => { describe("linkDiscord", () => { it("throws for nonexisting user", async () => { await expect(async () => - UserDAL.linkDiscord("unknown", "", ""), + UserDAL.linkDiscord("unknown", "", "", {}), ).rejects.toThrow("User not found\nStack: link discord"); }); it("should update", async () => { @@ -1279,14 +1279,18 @@ describe("UserDal", () => { const { uid } = await UserTestData.createUser({ discordId: "discordId", discordAvatar: "discordAvatar", + challenges: { + "100hours": {}, + }, }); //when - await UserDAL.linkDiscord(uid, "newId", "newAvatar"); + await UserDAL.linkDiscord(uid, "newId", "newAvatar", { "250hours": {} }); //then const read = await UserDAL.getUser(uid, "read"); expect(read.discordId).toEqual("newId"); expect(read.discordAvatar).toEqual("newAvatar"); + expect(read.challenges).toEqual({ "250hours": {} }); }); it("should update without avatar", async () => { //given @@ -1315,6 +1319,10 @@ describe("UserDal", () => { const { uid } = await UserTestData.createUser({ discordId: "discordId", discordAvatar: "discordAvatar", + challenges: { + "100hours": {}, + "250hours": { addedAt: Date.now() }, + }, }); //when @@ -1324,6 +1332,7 @@ describe("UserDal", () => { const read = await UserDAL.getUser(uid, "read"); expect(read.discordId).toBeUndefined(); expect(read.discordAvatar).toBeUndefined(); + expect(read.challenges).toBeUndefined(); }); }); describe("updateInbox", () => { diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 867f050cbfa2..baf7a0e52feb 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -37,6 +37,7 @@ import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboar import * as ConnectionsDal from "../../../src/dal/connections"; import { pb } from "../../__testData__/users"; import Test from "supertest/lib/test"; +import { getChallenge } from "@monkeytype/challenges"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -1552,7 +1553,7 @@ describe("user controller test", () => { it("should get oauth link", async () => { //WHEN const { body } = await mockApp - .get("/users/discord/oauth") + .get("/users/discord/oauth?includeRoles=true") .set("Authorization", `Bearer ${uid}`) .expect(200); @@ -1561,7 +1562,9 @@ describe("user controller test", () => { message: "Discord oauth link generated", data: { url }, }); - expect(getOauthLinkMock).toHaveBeenCalledWith(uid); + expect(getOauthLinkMock).toHaveBeenCalledWith(uid, { + includeRoles: true, + }); }); it("should fail if feature is not enabled", async () => { //GIVEN @@ -1587,6 +1590,7 @@ describe("user controller test", () => { "iStateValidForUser", ); const getDiscordUserMock = vi.spyOn(DiscordUtils, "getDiscordUser"); + const getDiscordRoleIdsMock = vi.spyOn(DiscordUtils, "getDiscordRoleIds"); const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const userLinkDiscordMock = vi.spyOn(UserDal, "linkDiscord"); const georgeLinkDiscordMock = vi.spyOn(GeorgeQueue, "linkDiscord"); @@ -1599,6 +1603,9 @@ describe("user controller test", () => { id: "discordUserId", avatar: "discordUserAvatar", }); + getDiscordRoleIdsMock.mockResolvedValue([ + getChallenge("100hours").discordRoleId, + ]); isDiscordIdAvailableMock.mockResolvedValue(true); blocklistContainsMock.mockResolvedValue(false); userLinkDiscordMock.mockResolvedValue(); @@ -1610,6 +1617,7 @@ describe("user controller test", () => { isStateValidForUserMock, isDiscordIdAvailableMock, getDiscordUserMock, + getDiscordRoleIdsMock, blocklistContainsMock, userLinkDiscordMock, georgeLinkDiscordMock, @@ -1629,6 +1637,7 @@ describe("user controller test", () => { tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", + scope: ["scopeOne", "scopeTwo"], }) .expect(200); @@ -1653,6 +1662,11 @@ describe("user controller test", () => { "tokenType", "accessToken", ); + expect(getDiscordRoleIdsMock).toHaveBeenCalledWith( + "tokenType", + "accessToken", + ["scopeOne", "scopeTwo"], + ); expect(isDiscordIdAvailableMock).toHaveBeenCalledWith("discordUserId"); expect(blocklistContainsMock).toHaveBeenCalledWith({ discordId: "discordUserId", @@ -1661,6 +1675,9 @@ describe("user controller test", () => { uid, "discordUserId", "discordUserAvatar", + { + "100hours": {}, + }, ); expect(georgeLinkDiscordMock).toHaveBeenCalledWith( "discordUserId", @@ -2962,6 +2979,9 @@ describe("user controller test", () => { testActivity: { "2024": fillYearWithDay(94), }, + challenges: { + "100hours": {}, + }, }; beforeEach(async () => { @@ -3033,12 +3053,15 @@ describe("user controller test", () => { expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); expect(getUserMock).not.toHaveBeenCalled(); }); - it("should get testActivity if enabled", async () => { + it("should get testActivity/challenges if enabled", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); getUserByNameMock.mockResolvedValue({ ...foundUser, - profileDetails: { showActivityOnPublicProfile: true }, + profileDetails: { + showActivityOnPublicProfile: true, + showChallengesOnPublicProfile: true, + }, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); @@ -3054,13 +3077,18 @@ describe("user controller test", () => { testsByDays: expect.arrayContaining([]), }), ); + + expect(body.data.challenges).toEqual({ "100hours": {} }); }); it("should not get testActivity if disabled", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); getUserByNameMock.mockResolvedValue({ ...foundUser, - profileDetails: { showActivityOnPublicProfile: false }, + profileDetails: { + showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, + }, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); @@ -3071,6 +3099,7 @@ describe("user controller test", () => { //THEN expect(body.data.testActivity).toBeUndefined(); + expect(body.data.challenges).toBeUndefined(); }); it("should get base profile for banned user", async () => { @@ -3188,6 +3217,7 @@ describe("user controller test", () => { website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, }; //WHEN @@ -3216,6 +3246,7 @@ describe("user controller test", () => { website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, }, { badges: [{ id: 4 }, { id: 2, selected: true }, { id: 3 }], diff --git a/backend/package.json b/backend/package.json index 5cd74ebacf64..c063510eaaaf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@date-fns/utc": "1.2.0", + "@monkeytype/challenges": "workspace:*", "@monkeytype/contracts": "workspace:*", "@monkeytype/funbox": "workspace:*", "@monkeytype/schemas": "workspace:*", diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index b89a7874515f..643423431976 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -39,6 +39,7 @@ import { CountByYearAndDay, TestActivity, UserProfileDetails, + UserChallenges, } from "@monkeytype/schemas/users"; import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs"; import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth"; @@ -59,6 +60,7 @@ import { ForgotPasswordEmailRequest, GetCurrentTestActivityResponse, GetCustomThemesResponse, + GetDiscordOauthLinkQuery, GetDiscordOauthLinkResponse, GetFavoriteQuotesResponse, GetFriendsResponse, @@ -94,6 +96,15 @@ import { tryCatch } from "@monkeytype/util/trycatch"; import * as ConnectionsDal from "../../dal/connections"; import { PersonalBest } from "@monkeytype/schemas/shared"; +import { ChallengeName } from "@monkeytype/schemas/challenges"; +import { getChallenges } from "@monkeytype/challenges"; + +const challengeNameByRoleId: Record = Object.fromEntries( + getChallenges() + .filter((it) => it.discordRoleId !== undefined) + .map((it) => [it.discordRoleId, it.name]), +); + async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); if (error) { @@ -629,12 +640,13 @@ export async function getUser(req: MonkeyRequest): Promise { } export async function getOauthLink( - req: MonkeyRequest, + req: MonkeyRequest, ): Promise { const { uid } = req.ctx.decodedToken; + const { includeRoles } = req.query; //build the url - const url = await DiscordUtils.getOauthLink(uid); + const url = await DiscordUtils.getOauthLink(uid, { includeRoles }); //return return new MonkeyResponse("Discord oauth link generated", { @@ -646,7 +658,7 @@ export async function linkDiscord( req: MonkeyRequest, ): Promise { const { uid } = req.ctx.decodedToken; - const { tokenType, accessToken, state } = req.body; + const { tokenType, accessToken, state, scope } = req.body; if (!(await DiscordUtils.iStateValidForUser(state, uid))) { throw new MonkeyError(403, "Invalid user token"); @@ -692,7 +704,20 @@ export async function linkDiscord( throw new MonkeyError(409, "The Discord account is blocked"); } - await UserDAL.linkDiscord(uid, discordId, discordAvatar); + let roles = await DiscordUtils.getDiscordRoleIds( + tokenType, + accessToken, + scope, + ); + + const challenges: UserChallenges = Object.fromEntries( + roles + .map((roleId) => challengeNameByRoleId[roleId]) + .filter((it) => it !== undefined) + .map((it) => [it, {}]), + ); + + await UserDAL.linkDiscord(uid, discordId, discordAvatar, challenges); await GeorgeQueue.linkDiscord(discordId, uid, userInfo.lbOptOut ?? false); void addImportantLog("user_discord_link", `linked to ${discordId}`, uid); @@ -1006,6 +1031,13 @@ export async function getProfile( } else { delete profileData.testActivity; } + + if (user.profileDetails?.showChallengesOnPublicProfile) { + profileData.challenges = user.challenges; + } else { + delete profileData.challenges; + } + return new MonkeyResponse("Profile retrieved", profileData); } @@ -1019,6 +1051,7 @@ export async function updateProfile( socialProfiles, selectedBadgeId, showActivityOnPublicProfile, + showChallengesOnPublicProfile, } = req.body; const user = await UserDAL.getPartialUser(uid, "update user profile", [ @@ -1048,6 +1081,7 @@ export async function updateProfile( ]), ), showActivityOnPublicProfile, + showChallengesOnPublicProfile, }; await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory); diff --git a/backend/src/constants/auto-roles.ts b/backend/src/constants/auto-roles.ts index dd80dc29a7e0..dba66537ce23 100644 --- a/backend/src/constants/auto-roles.ts +++ b/backend/src/constants/auto-roles.ts @@ -1,38 +1,5 @@ -export default [ - "oneHourWarrior", - "doubleDown", - "tripleTrouble", - "quad", - "trueSimp", - "bigramSalad", - "simp", - "antidiseWhat", - "whatsThisWebsiteCalledAgain", - "developd", - "slowAndSteady", - "speedSpacer", - "iveGotThePower", - "accuracyExpert", - "accuracyMaster", - "accuracyGod", - "jolly", - "gottaCatchEmAll", - "rapGod", - "navySeal", - "rollercoaster", - "oneHourMirror", - "chooChoo", - "earfquake", - "simonSez", - "accountant", - "hidden", - "iCanSeeTheFuture", - "whatAreWordsAtThisPoint", - "specials", - "aeiou", - "asciiWarrior", - "iKiNdAlIkEhOwInEfFiCiEnTqWeRtYiS", - "oneNauseousMonkey", - "69", - "englishMaster", -]; +import { getChallenges } from "@monkeytype/challenges"; + +export default getChallenges() + .filter((it) => it.autoRole) + .map((it) => it.name); diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index ada92f0ee764..a862ad26e50c 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -26,6 +26,7 @@ import { User, CountByYearAndDay, Friend, + UserChallenges, } from "@monkeytype/schemas/users"; import { Mode, @@ -613,11 +614,15 @@ export async function linkDiscord( uid: string, discordId: string, discordAvatar?: string, + challenges?: UserChallenges, ): Promise { const updates: Partial = { discordId }; if (discordAvatar !== undefined && discordAvatar !== null) { updates.discordAvatar = discordAvatar; } + if (challenges !== undefined) { + updates.challenges = challenges; + } await updateUser({ uid }, { $set: updates }, { stack: "link discord" }); } @@ -625,7 +630,7 @@ export async function linkDiscord( export async function unlinkDiscord(uid: string): Promise { await updateUser( { uid }, - { $unset: { discordId: "", discordAvatar: "" } }, + { $unset: { discordId: "", discordAvatar: "", challenges: "" } }, { stack: "unlink discord" }, ); } diff --git a/backend/src/utils/discord.ts b/backend/src/utils/discord.ts index e290c02d5f75..b2533e2db581 100644 --- a/backend/src/utils/discord.ts +++ b/backend/src/utils/discord.ts @@ -6,16 +6,27 @@ import { z } from "zod"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; const BASE_URL = "https://discord.com/api"; +const CLIENT_ID = "798272335035498557"; +const SERVER_ID = "713194177403420752"; +const READ_ROLE_SCOPE = "guilds.members.read"; -const DiscordIdAndAvatarSchema = z.object({ - id: z.string(), - avatar: z - .string() - .optional() - .or(z.null().transform(() => undefined)), -}); +const DiscordIdAndAvatarSchema = z + .object({ + id: z.string(), + avatar: z + .string() + .optional() + .or(z.null().transform(() => undefined)), + }) + .strip(); type DiscordIdAndAvatar = z.infer; +const DiscordGuildMemberSchema = z + .object({ + roles: z.array(z.string()), + }) + .strip(); + export async function getDiscordUser( tokenType: string, accessToken: string, @@ -34,21 +45,51 @@ export async function getDiscordUser( return parsed; } -export async function getOauthLink(uid: string): Promise { +export async function getDiscordRoleIds( + tokenType: string, + accessToken: string, + scope?: string[], +): Promise { + if (!scope?.includes(READ_ROLE_SCOPE)) return []; + + const response = await fetch( + `${BASE_URL}/users/@me/guilds/${SERVER_ID}/member`, + { + headers: { + authorization: `${tokenType} ${accessToken}`, + }, + }, + ); + + const parsed = parseJsonWithSchema( + await response.text(), + DiscordGuildMemberSchema, + ); + + return parsed.roles; +} + +export async function getOauthLink( + uid: string, + options: { includeRoles?: boolean }, +): Promise { const connection = RedisClient.getConnection(); if (!connection) { throw new MonkeyError(500, "Redis connection not found"); } const token = randomBytes(10).toString("hex"); + const scope = ["identify"]; + + if (options.includeRoles) scope.push(READ_ROLE_SCOPE); - //add the token uid pair to reids + //add the token uid pair to redis await connection.setex(`discordoauth:${uid}`, 60, token); - return `${BASE_URL}/oauth2/authorize?client_id=798272335035498557&redirect_uri=${ + return `${BASE_URL}/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${ isDevEnvironment() ? `http%3A%2F%2Flocalhost%3A3000%2Fverify` : `https%3A%2F%2Fmonkeytype.com%2Fverify` - }&response_type=token&scope=identify&state=${token}`; + }&response_type=token&scope=${scope.join("+")}&state=${token}`; } export async function iStateValidForUser( diff --git a/frontend/package.json b/frontend/package.json index 24a7fc9a68f7..5a33b1face8b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "dependencies": { "@date-fns/utc": "1.2.0", "@leonabcd123/modern-caps-lock": "3.1.3", + "@monkeytype/challenges": "workspace:*", "@monkeytype/contracts": "workspace:*", "@monkeytype/funbox": "workspace:*", "@monkeytype/schemas": "workspace:*", diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 52b7231bd230..f73f7a0c6480 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -1,4 +1,5 @@ import { PasswordSchema } from "@monkeytype/schemas/users"; +import { typedKeys } from "@monkeytype/util/objects"; import { tryCatch } from "@monkeytype/util/trycatch"; import { FirebaseError } from "firebase/app"; import { @@ -41,7 +42,6 @@ import { } from "./states/notifications"; import { isDevEnvironment } from "./utils/env"; import { createErrorMessage } from "./utils/error"; -import { typedKeys } from "./utils/misc"; import { SnapshotInitError } from "./utils/snapshot-init-error"; import { OneOf } from "./utils/types"; diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts index 71b01937d653..5d479fd2309a 100644 --- a/frontend/src/ts/commandline/commandline-metadata.ts +++ b/frontend/src/ts/commandline/commandline-metadata.ts @@ -13,8 +13,8 @@ import { getActivePage, isAuthenticated } from "../states/core"; import { Fonts } from "../constants/fonts"; import { KnownFontName } from "@monkeytype/schemas/fonts"; import * as UI from "../ui"; -import { typedKeys } from "../utils/misc"; import { Validation } from "../types/validation"; +import { typedKeys } from "@monkeytype/util/objects"; //TODO: remove display property and instead use optionsMetadata from configMetadata // eventually this file should be fully merged into config metadata, probably under the 'commandline' property diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index 6780991f828f..ac9a39c45a32 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -12,14 +12,10 @@ import CustomThemesListCommands from "./lists/custom-themes-list"; import PresetsCommands from "./lists/presets"; import FunboxCommands from "./lists/funbox"; import ThemesCommands from "./lists/themes"; -import LoadChallengeCommands, { - update as updateLoadChallengeCommands, -} from "./lists/load-challenge"; +import LoadChallengeCommands from "./lists/load-challenge"; import { Config } from "../config/store"; import { setConfig } from "../config/setters"; -import * as getErrorMessage from "../utils/error"; -import * as JSONData from "../utils/json-data"; import { randomizeTheme } from "../controllers/theme-controller"; import { showModal } from "../states/modals"; import { @@ -41,20 +37,6 @@ import { import { applyConfigFromJson } from "../config/lifecycle"; import { lastEventLog } from "../test/test-state"; -const challengesPromise = JSONData.getChallengeList(); -challengesPromise - .then((challenges) => { - updateLoadChallengeCommands(challenges); - }) - .catch((e: unknown) => { - console.error( - getErrorMessage.createErrorMessage( - e, - "Failed to update challenges commands", - ), - ); - }); - const adsCommands = buildCommands("ads"); export const commands: CommandsSubgroup = { @@ -406,8 +388,6 @@ export function doesListExist(listName: string): boolean { export async function getList( listName: CommandlineListKey | ConfigKey, ): Promise { - await Promise.allSettled([challengesPromise]); - const subGroup = subgroupByConfigKey[listName]; if (subGroup !== undefined) { return subGroup; @@ -451,7 +431,6 @@ export function getTopOfStack(): CommandsSubgroup { let singleList: CommandsSubgroup | undefined; export async function getSingleSubgroup(): Promise { - await Promise.allSettled([challengesPromise]); const singleCommands: Command[] = []; for (const command of commands.list) { const ret = buildSingleListCommands(command); diff --git a/frontend/src/ts/commandline/lists/load-challenge.ts b/frontend/src/ts/commandline/lists/load-challenge.ts index c49f1511a298..023a2d4d3d14 100644 --- a/frontend/src/ts/commandline/lists/load-challenge.ts +++ b/frontend/src/ts/commandline/lists/load-challenge.ts @@ -1,13 +1,23 @@ -import { navigate } from "../../controllers/route-controller"; +import { getRegularChallenges } from "@monkeytype/challenges"; import * as ChallengeController from "../../controllers/challenge-controller"; +import { navigate } from "../../controllers/route-controller"; import * as TestLogic from "../../test/test-logic"; import { capitalizeFirstLetterOfEachWord } from "../../utils/strings"; import { Command, CommandsSubgroup } from "../types"; -import { Challenge } from "@monkeytype/schemas/challenges"; const subgroup: CommandsSubgroup = { title: "Load challenge...", - list: [], + list: getRegularChallenges().map((challenge) => ({ + id: `loadChallenge${capitalizeFirstLetterOfEachWord(challenge.name)}`, + display: challenge.display, + exec: async (): Promise => { + await navigate("/"); + await ChallengeController.setup(challenge.name); + TestLogic.restart({ + nosave: true, + }); + }, + })), }; const commands: Command[] = [ @@ -19,21 +29,4 @@ const commands: Command[] = [ }, ]; -function update(challenges: Challenge[]): void { - challenges.forEach((challenge) => { - subgroup.list.push({ - id: `loadChallenge${capitalizeFirstLetterOfEachWord(challenge.name)}`, - display: challenge.display, - exec: async (): Promise => { - await navigate("/"); - await ChallengeController.setup(challenge.name); - TestLogic.restart({ - nosave: true, - }); - }, - }); - }); -} - export default commands; -export { update }; diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 1309377dc4ca..d2b5a4d019f9 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,3 +1,4 @@ +import { typedKeys } from "@monkeytype/util/objects"; import { UseQueryResult } from "@tanstack/solid-query"; import { Accessor, @@ -12,7 +13,6 @@ import { import { showErrorNotification } from "../../states/notifications"; import { createErrorMessage } from "../../utils/error"; -import { typedKeys } from "../../utils/misc"; import { LoadingCircle } from "./LoadingCircle"; type AsyncEntry = { diff --git a/frontend/src/ts/components/modals/EditProfileModal.tsx b/frontend/src/ts/components/modals/EditProfileModal.tsx index 088f8e5ce2de..4bd2574a0cbf 100644 --- a/frontend/src/ts/components/modals/EditProfileModal.tsx +++ b/frontend/src/ts/components/modals/EditProfileModal.tsx @@ -40,7 +40,9 @@ export function EditProfile() { twitter: snapshot.details?.socialProfiles?.twitter ?? "", website: snapshot.details?.socialProfiles?.website ?? "", showActivityOnPublicProfile: - snapshot.details?.showActivityOnPublicProfile ?? true, + snapshot.details?.showActivityOnPublicProfile, + showChallengesOnPublicProfile: + snapshot.details?.showChallengesOnPublicProfile, badgeId: badges.find((b) => b.selected)?.id ?? -1, }, onSubmit: async ({ value }) => { @@ -53,6 +55,7 @@ export function EditProfile() { website: value.website || undefined, }, showActivityOnPublicProfile: value.showActivityOnPublicProfile, + showChallengesOnPublicProfile: value.showChallengesOnPublicProfile, }; const response = await Ape.users.updateProfile({ @@ -259,6 +262,18 @@ export function EditProfile() { +
+ + + {(field) => ( + + )} + +
+ save diff --git a/frontend/src/ts/components/modals/SimpleModal.tsx b/frontend/src/ts/components/modals/SimpleModal.tsx index d07efad88d59..c7a0337944ae 100644 --- a/frontend/src/ts/components/modals/SimpleModal.tsx +++ b/frontend/src/ts/components/modals/SimpleModal.tsx @@ -1,3 +1,4 @@ +import { typedEntries } from "@monkeytype/util/objects"; import { AnyFieldApi, createForm } from "@tanstack/solid-form"; import { Accessor, @@ -25,7 +26,6 @@ import { SimpleModalInput, } from "../../states/simple-modal"; import { cn } from "../../utils/cn"; -import { typedEntries } from "../../utils/misc"; import { getZodType, unwrapSchema } from "../../utils/zod"; import { AnimatedModal } from "../common/AnimatedModal"; import { Checkbox } from "../ui/form/Checkbox"; diff --git a/frontend/src/ts/components/pages/account/utils.ts b/frontend/src/ts/components/pages/account/utils.ts index 096bfe99631f..e251099295c0 100644 --- a/frontend/src/ts/components/pages/account/utils.ts +++ b/frontend/src/ts/components/pages/account/utils.ts @@ -1,7 +1,8 @@ import { ResultFilters, ResultFiltersSchema } from "@monkeytype/schemas/users"; -import { typedKeys } from "../../../utils/misc"; + import defaultResultFilters from "../../../constants/default-result-filters"; import { sanitize } from "../../../utils/sanitize"; +import { typedKeys } from "@monkeytype/util/objects"; export function mergeWithDefaultFilters( filters: Partial, diff --git a/frontend/src/ts/components/pages/profile/Challenges.tsx b/frontend/src/ts/components/pages/profile/Challenges.tsx new file mode 100644 index 000000000000..b283e5e808b9 --- /dev/null +++ b/frontend/src/ts/components/pages/profile/Challenges.tsx @@ -0,0 +1,119 @@ +import { getChallenge, getRegularChallenges } from "@monkeytype/challenges"; +import { Challenge, ChallengeName } from "@monkeytype/schemas/challenges"; +import { UserChallenges } from "@monkeytype/schemas/users"; +import { typedEntries } from "@monkeytype/util/objects"; +import { createMemo, For, Show } from "solid-js"; + +import { FaSolidIcon } from "../../../types/font-awesome"; +import { cn } from "../../../utils/cn"; +import { Fa } from "../../common/Fa"; + +function sortNewestFirst( + a: [ChallengeName, { addedAt?: number | undefined } | undefined], + b: [ChallengeName, { addedAt?: number | undefined } | undefined], +): number { + const aHas = a[1]?.addedAt !== undefined; + const bHas = b[1]?.addedAt !== undefined; + if (aHas && !bHas) return -1; + if (!aHas && bHas) return 1; + if (aHas && bHas) return (b[1]?.addedAt ?? 0) - (a[1]?.addedAt ?? 0); + return a[0].localeCompare(b[0]); +} + +export function Challenges(props: { + isAccountPage?: true; + challenges: UserChallenges | undefined; +}) { + const completedChallenges = createMemo((): Challenge[] => + ( + typedEntries(props.challenges ?? {}) as [ + ChallengeName, + { addedAt?: number | undefined } | undefined, + ][] + ) + .sort(sortNewestFirst) + .map(([name]) => getChallenge(name)) + .filter((it) => it !== undefined), + ); + + const completedNames = createMemo( + () => new Set(completedChallenges().map((it) => it.name)), + ); + + const incompleteChallenges = createMemo((): Challenge[] => + getRegularChallenges().filter((it) => !completedNames().has(it.name)), + ); + + return ( + +
+
+

Challenges

+
+ {Object.keys(props.challenges ?? {}).length} /{" "} + {getRegularChallenges().length} completed +
+
+ +
+ + {(challenge) => ( + + )} + + + + {(challenge) => ( + + )} + + +
+
+
+ ); +} + +function ChallengeItem(props: { completed: boolean; challenge: Challenge }) { + const icon = (): FaSolidIcon => { + switch (props.challenge.category) { + case "accuracy": + return "fa-bullseye"; + case "champions": + return "fa-crown"; + case "endurance": + return "fa-running"; + case "funbox": + return "fa-gamepad"; + case "speed": + return "fa-tachometer-alt"; + case "script": + return "fa-file-alt"; + + default: + return "fa-trophy"; + } + }; + return ( +
+
+ +
+
+

{props.challenge.display}

+

{props.challenge.description}

+
+
+ ); +} diff --git a/frontend/src/ts/components/pages/profile/UserDetails.tsx b/frontend/src/ts/components/pages/profile/UserDetails.tsx index 1824eac6ed2f..8e7ad0924b64 100644 --- a/frontend/src/ts/components/pages/profile/UserDetails.tsx +++ b/frontend/src/ts/components/pages/profile/UserDetails.tsx @@ -1,3 +1,4 @@ +import { getRegularChallenges } from "@monkeytype/challenges"; import { TypingStats as TypingStatsType, UserProfile, @@ -9,6 +10,7 @@ import { getCurrentDayTimestamp, } from "@monkeytype/util/date-and-time"; import { isSafeNumber } from "@monkeytype/util/numbers"; +import { typedKeys } from "@monkeytype/util/objects"; import { differenceInDays } from "date-fns/differenceInDays"; import { formatDate } from "date-fns/format"; import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict"; @@ -80,6 +82,7 @@ export function UserDetails(props: { @@ -409,6 +412,7 @@ function BioAndKeyboard(props: { function TypingStats(props: { typingStats: TypingStatsType; + completedChallenges: number | undefined; variant: Variant; }): JSXElement { const stats = () => formatTypingStatsRatio(props.typingStats); @@ -429,13 +433,13 @@ function TypingStats(props: { class={cn( "grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-2", props.variant === "basic" && - "sm:grid-cols-3 md:grid-cols-1 lg:grid-cols-3 lg:text-[1.25rem]", + "sm:grid-cols-3 md:grid-cols-1 lg:grid-cols-4 lg:text-[1.25rem]", props.variant === "hasBioOrKeyboard" && "sm:col-span-2 md:order-2 md:col-span-1 md:grid-cols-1", props.variant === "hasSocials" && - "sm:col-span-2 sm:grid-cols-3 md:col-span-1 md:grid-cols-1 lg:grid-cols-3 xl:text-[1.25rem]", + "sm:col-span-2 sm:grid-cols-4 md:col-span-1 md:grid-cols-1 lg:grid-cols-4 xl:text-[1.25rem]", props.variant === "full" && - "sm:col-span-2 sm:grid-cols-3 md:col-span-3 md:grid-cols-3 lg:order-2 lg:col-span-1 lg:grid-cols-1", + "sm:col-span-2 sm:grid-cols-4 md:col-span-3 md:grid-cols-4 lg:order-2 lg:col-span-1 lg:grid-cols-1", )} >
@@ -467,6 +471,16 @@ function TypingStats(props: { )}
+ + +
+
challenges
+
+ {props.completedChallenges}{" "} + / {getRegularChallenges().length} +
+
+
); diff --git a/frontend/src/ts/components/pages/profile/UserProfile.tsx b/frontend/src/ts/components/pages/profile/UserProfile.tsx index 4e8906a311da..58423fd92dc9 100644 --- a/frontend/src/ts/components/pages/profile/UserProfile.tsx +++ b/frontend/src/ts/components/pages/profile/UserProfile.tsx @@ -11,6 +11,7 @@ import { getFormatting } from "../../../states/core"; import { formatTopPercentage } from "../../../utils/misc"; import { Button } from "../../common/Button"; import { ActivityCalendar } from "./ActivityCalendar"; +import { Challenges } from "./Challenges"; import { UserDetails } from "./UserDetails"; export function UserProfile(props: { @@ -55,6 +56,11 @@ export function UserProfile(props: { testActivity={props.profile.testActivity} isAccountPage={props.isAccountPage} /> + + ); } diff --git a/frontend/src/ts/config/lifecycle.ts b/frontend/src/ts/config/lifecycle.ts index 4f88247173e4..1ef978c67b1a 100644 --- a/frontend/src/ts/config/lifecycle.ts +++ b/frontend/src/ts/config/lifecycle.ts @@ -13,9 +13,10 @@ import { Config, setFullConfigStore } from "./store"; import { getDefaultConfig } from "../constants/default-config"; import { configEvent } from "../events/config"; import { migrateConfig } from "./utils"; -import { promiseWithResolvers, typedKeys } from "../utils/misc"; +import { promiseWithResolvers } from "../utils/misc"; import { setConfig } from "./setters"; import { deleteConfig } from "../ape/config"; +import { typedKeys } from "@monkeytype/util/objects"; export async function applyConfigFromJson(json: string): Promise { try { diff --git a/frontend/src/ts/config/setters.ts b/frontend/src/ts/config/setters.ts index de181296161f..f571baa401be 100644 --- a/frontend/src/ts/config/setters.ts +++ b/frontend/src/ts/config/setters.ts @@ -10,10 +10,11 @@ import { canSetFunboxWithConfig, } from "./funbox-validation"; import * as TestState from "../test/test-state"; -import { typedKeys, triggerResize, escapeHTML } from "../utils/misc"; +import { triggerResize, escapeHTML } from "../utils/misc"; import { camelCaseToWords, capitalizeFirstLetter } from "../utils/strings"; import { Config, setConfigStore } from "./store"; import { FunboxName } from "@monkeytype/schemas/configs"; +import { typedKeys } from "@monkeytype/util/objects"; export function setConfig( key: T, diff --git a/frontend/src/ts/config/utils.ts b/frontend/src/ts/config/utils.ts index 9621b2e717a3..006f7ecd5d85 100644 --- a/frontend/src/ts/config/utils.ts +++ b/frontend/src/ts/config/utils.ts @@ -4,11 +4,11 @@ import type { PartialConfig, FunboxName, } from "@monkeytype/schemas/configs"; -import { typedKeys } from "../utils/misc"; import { sanitize } from "../utils/sanitize"; import * as ConfigSchemas from "@monkeytype/schemas/configs"; import { getDefaultConfig } from "../constants/default-config"; import { Config } from "./store"; +import { typedKeys } from "@monkeytype/util/objects"; /** * migrates possible outdated config and merges with the default config values * @param config partial or possible outdated config diff --git a/frontend/src/ts/controllers/challenge-controller.ts b/frontend/src/ts/controllers/challenge-controller.ts index 0252e8d5e7b2..3a1a384b984a 100644 --- a/frontend/src/ts/controllers/challenge-controller.ts +++ b/frontend/src/ts/controllers/challenge-controller.ts @@ -1,33 +1,32 @@ -import * as Misc from "../utils/misc"; -import * as JSONData from "../utils/json-data"; import { - showNoticeNotification, showErrorNotification, + showNoticeNotification, showSuccessNotification, } from "../states/notifications"; import * as CustomText from "../test/custom-text"; import * as Funbox from "../test/funbox/funbox"; -import { Config } from "../config/store"; import { setConfig } from "../config/setters"; +import { Config } from "../config/store"; import { configEvent } from "../events/config"; import * as TestState from "../test/test-state"; -import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import { CustomTextLimitMode, CustomTextMode } from "@monkeytype/schemas/util"; +import { Challenge, ChallengeName } from "@monkeytype/schemas/challenges"; import { Config as ConfigType, Difficulty, - ThemeName, FunboxName, + ThemeName, } from "@monkeytype/schemas/configs"; -import { Mode } from "@monkeytype/schemas/shared"; import { CompletedEvent } from "@monkeytype/schemas/results"; +import { Mode } from "@monkeytype/schemas/shared"; +import { CustomTextLimitMode, CustomTextMode } from "@monkeytype/schemas/util"; +import { hideLoaderBar, showLoaderBar } from "../states/loader-bar"; +import { getLoadedChallenge, setLoadedChallenge } from "../states/test"; import { areUnsortedArraysEqual } from "../utils/arrays"; -import { tryCatch } from "@monkeytype/util/trycatch"; -import { Challenge } from "@monkeytype/schemas/challenges"; import { qs } from "../utils/dom"; -import { getLoadedChallenge, setLoadedChallenge } from "../states/test"; +import { typedKeys } from "@monkeytype/util/objects"; +import { getChallenge } from "@monkeytype/challenges"; let challengeLoading = false; @@ -137,7 +136,7 @@ function verifyRequirement( } } else if (requirementType === "config" && requirements.config) { const requirementValue = requirements.config; - for (const configKey of Misc.typedKeys(requirementValue)) { + for (const configKey of typedKeys(requirementValue)) { const configValue = requirementValue[configKey]; if (Config[configKey as keyof ConfigType] !== configValue) { requirementsMet = false; @@ -148,7 +147,7 @@ function verifyRequirement( return [requirementsMet, failReasons]; } -export function verify(result: CompletedEvent): string | null { +export function verify(result: CompletedEvent): ChallengeName | null { const loadedChallenge = getLoadedChallenge(); if (loadedChallenge === null) return null; @@ -167,9 +166,7 @@ export function verify(result: CompletedEvent): string | null { } else { let requirementsMet = true; const failReasons: string[] = []; - for (const requirementType of Misc.typedKeys( - loadedChallenge.requirements, - )) { + for (const requirementType of typedKeys(loadedChallenge.requirements)) { const [passed, requirementFailReasons] = verifyRequirement( result, loadedChallenge.requirements, @@ -207,24 +204,13 @@ export function verify(result: CompletedEvent): string | null { } } -export async function setup(challengeName: string): Promise { +export async function setup(challengeName: ChallengeName): Promise { challengeLoading = true; setConfig("funbox", []); - const { data: list, error } = await tryCatch(JSONData.getChallengeList()); - if (error) { - showErrorNotification("Failed to setup challenge", { error }); - setTimeout(() => { - qs("header .config")?.show(); - qs(".page.pageTest")?.show(); - }, 250); - return false; - } + const challenge = getChallenge(challengeName); - const challenge = list.find( - (c) => c.name.toLowerCase() === challengeName.toLowerCase(), - ); let notitext; try { if (challenge === undefined) { @@ -245,7 +231,7 @@ export async function setup(challengeName: string): Promise { setConfig("difficulty", "normal", { nosave: true, }); - if (challenge.name === "englishMaster") { + if (challengeName === "englishMaster") { setConfig("language", "english_10k", { nosave: true, }); @@ -347,33 +333,7 @@ export async function setup(challengeName: string): Promise { throw new Error("Can't load challenge with current config"); } } else if (challenge.type === "other") { - if (challenge.name === "semimak") { - // so can you make a link that sets up 120s, 10k, punct, stop on word, and semimak as the layout? - setConfig("mode", "time", { - nosave: true, - }); - setConfig("time", 120, { - nosave: true, - }); - setConfig("language", "english_10k", { - nosave: true, - }); - setConfig("punctuation", true, { - nosave: true, - }); - setConfig("stopOnError", "word", { - nosave: true, - }); - setConfig("layout", "semimak", { - nosave: true, - }); - setConfig("keymapLayout", "overrideSync", { - nosave: true, - }); - setConfig("keymapMode", "static", { - nosave: true, - }); - } else if (challenge.name === "wingdings") { + if (challengeName === "wingdings") { // Ten Words of Pain: 10-word Master mode test using the Wingdings custom font, no keymap setConfig("mode", "words", { nosave: true, diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index 636a0c51e09e..01ea9b02b32f 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -60,12 +60,13 @@ import { Config } from "../config/store"; import { configEvent } from "../events/config"; import * as Arrays from "../utils/arrays"; import { blendTwoHexColors } from "../utils/colors"; -import { typedKeys } from "../utils/misc"; + import { getTheme } from "../states/theme"; import { Theme } from "../constants/themes"; import { createDebouncedEffectOn } from "../hooks/effects"; import { getWordIndexesForSecond } from "../test/events/stats"; import { lastEventLog } from "../test/test-state"; +import { typedKeys } from "@monkeytype/util/objects"; export class ChartWithUpdateColors< TType extends ChartType = ChartType, diff --git a/frontend/src/ts/controllers/url-handler.tsx b/frontend/src/ts/controllers/url-handler.tsx index 83ec39952c7f..9abe54e075ef 100644 --- a/frontend/src/ts/controllers/url-handler.tsx +++ b/frontend/src/ts/controllers/url-handler.tsx @@ -1,3 +1,4 @@ +import { ChallengeName } from "@monkeytype/schemas/challenges"; import { CustomBackgroundFilter, CustomBackgroundFilterSchema, @@ -45,10 +46,11 @@ export async function linkDiscord(hashOverride: string): Promise { const accessToken = fragment.get("access_token") as string; const tokenType = fragment.get("token_type") as string; const state = fragment.get("state") as string; + const scope = fragment.get("scope"); showLoaderBar(); const response = await Ape.users.linkDiscord({ - body: { tokenType, accessToken, state }, + body: { tokenType, accessToken, state, scope: scope?.split(" ") }, }); hideLoaderBar(); @@ -317,7 +319,7 @@ export async function loadChallengeFromUrl( ).toLowerCase(); if (getValue === "") return; - ChallengeController.setup(getValue) + ChallengeController.setup(getValue as ChallengeName) .then((result) => { if (result) { restartTest({ diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index cb17d5f81dc3..3cbfb7ea7a9c 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -138,6 +138,7 @@ export async function initSnapshot(): Promise { firstDayOfTheWeek, ); } + snap.challenges = userData.challenges; const hourOffset = userData?.streak?.hourOffset; snap.streakHourOffset = hourOffset ?? undefined; diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 6452541bc1f4..28183253306c 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -181,15 +181,18 @@ qsa( ".page.pageAccountSettings .section.discordIntegration .getLinkAndGoToOauth", )?.on("click", () => { showLoaderBar(); - void Ape.users.getDiscordOAuth().then((response) => { - if (response.status === 200) { - window.open(response.body.data.url, "_self"); - } else { - showErrorNotification( - `Failed to get OAuth from discord: ${response.body.message}`, - ); - } - }); + + void Ape.users + .getDiscordOAuth({ query: { includeRoles: true } }) + .then((response) => { + if (response.status === 200) { + window.open(response.body.data.url, "_self"); + } else { + showErrorNotification( + `Failed to get OAuth from discord: ${response.body.message}`, + ); + } + }); }); qs(".page.pageAccountSettings #setStreakHourOffset")?.on("click", () => { diff --git a/frontend/src/ts/utils/json-data.ts b/frontend/src/ts/utils/json-data.ts index 7b773eb19a20..0d19df8f0639 100644 --- a/frontend/src/ts/utils/json-data.ts +++ b/frontend/src/ts/utils/json-data.ts @@ -1,9 +1,8 @@ import { Language, LanguageObject } from "@monkeytype/schemas/languages"; -import { Challenge } from "@monkeytype/schemas/challenges"; import { LayoutObject } from "@monkeytype/schemas/layouts"; -import { toHex } from "./strings"; import { languageHashes } from "virtual:language-hashes"; import { isDevEnvironment } from "./env"; +import { toHex } from "./strings"; //pin implementation const fetch = window.fetch; @@ -154,15 +153,6 @@ export class Section { export type FunboxWordOrder = "normal" | "reverse"; -/** - * Fetches the list of challenges from the server. - * @returns A promise that resolves to the list of challenges. - */ -export async function getChallengeList(): Promise { - const data = await cachedFetchJson("/challenges/_list.json"); - return data; -} - /** * Fetches the list of supporters from the server. * @returns A promise that resolves to the list of supporters. diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 6480e38ae000..669d93b3bba5 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -473,18 +473,6 @@ export function getBoundingRectOfElements(elements: HTMLElement[]): DOMRect { }; } -export function typedKeys( - obj: T, -): T extends T ? (keyof T)[] : never { - return Object.keys(obj) as unknown as T extends T ? (keyof T)[] : never; -} - -export function typedEntries( - obj: T, -): { [K in keyof T]: [K, T[K]] }[keyof T][] { - return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]; -} - export function reloadAfter(seconds: number): void { setTimeout(() => { window.location.reload(); diff --git a/frontend/static/challenges/_list.json b/frontend/static/challenges/_list.json deleted file mode 100644 index a94b95aa6858..000000000000 --- a/frontend/static/challenges/_list.json +++ /dev/null @@ -1,720 +0,0 @@ -[ - { - "name": "oneHourWarrior", - "display": "One Hour Warrior", - "autoRole": true, - "type": "customTime", - "parameters": [3600], - "requirements": { - "time": { - "min": 3600 - } - } - }, - { - "name": "doubleDown", - "display": "Double Down", - "autoRole": true, - "type": "customTime", - "parameters": [7200], - "requirements": { - "time": { - "min": 7200 - } - } - }, - { - "name": "tripleTrouble", - "display": "Triple Trouble", - "autoRole": true, - "type": "customTime", - "parameters": [10800], - "requirements": { - "time": { - "min": 10800 - } - } - }, - { - "name": "quad", - "display": "Quaaaaad", - "autoRole": true, - "type": "customTime", - "parameters": [14400], - "requirements": { - "time": { - "min": 14400 - } - } - }, - { - "name": "8Ball", - "display": "8 Ball", - "type": "customTime", - "parameters": [28800], - "requirements": { - "time": { - "min": 28800 - } - } - }, - { - "name": "theBig12", - "display": "The Big 12", - "type": "customTime", - "parameters": [43200], - "requirements": { - "time": { - "min": 43200 - } - } - }, - { - "name": "1Day", - "display": "1 Day", - "type": "customTime", - "parameters": [86400], - "requirements": { - "time": { - "min": 86400 - } - } - }, - { - "name": "trueSimp", - "display": "True Simp", - "autoRole": true, - "type": "customText", - "parameters": ["miodec", "repeat", 10000, "word", false] - }, - { - "name": "bigramSalad", - "display": "Bigram Salad", - "autoRole": true, - "type": "customText", - "parameters": [ - "to of in it is as at be we he so on an or do if up by my go", - "random", - 100, - "word", - false - ], - "requirements": { - "wpm": { - "min": 100 - } - } - }, - { - "name": "simp", - "display": "Simp", - "autoRole": true, - "type": "customText", - "parameters": ["miodec", "repeat", 1000, "word", false] - }, - { - "name": "antidiseWhat", - "display": "Antidise-what?", - "autoRole": true, - "type": "customText", - "parameters": ["antidisestablishmentarianism", "repeat", 1, "word", false], - "requirements": { - "wpm": { - "min": 200 - } - } - }, - { - "name": "whatsThisWebsiteCalledAgain", - "display": "What's this website called again?", - "autoRole": true, - "type": "customText", - "parameters": ["monkeytype", "repeat", 1000, "word", false] - }, - { - "name": "developd", - "display": "Develop'd", - "autoRole": true, - "type": "customText", - "parameters": ["develop", "repeat", 1000, "word", false] - }, - { - "name": "slowAndSteady", - "display": "Slow and Steady", - "autoRole": true, - "type": "customTime", - "parameters": [300], - "requirements": { - "wpm": { - "exact": 60 - }, - "config": { - "liveSpeedStyle": "off", - "paceCaret": "off" - } - } - }, - { - "name": "speedSpacer", - "display": "Speed Spacer", - "autoRole": true, - "type": "customText", - "parameters": [ - "a b c d e f g h i j k l m n o p q r s t u v w x y z", - "random", - 100, - "word", - false - ], - "requirements": { - "wpm": { - "min": 100 - } - } - }, - { - "name": "iveGotThePower", - "display": "I've got the POWER", - "autoRole": true, - "type": "customText", - "parameters": ["power", "repeat", 10, "word", false], - "requirements": { - "wpm": { - "min": 400 - } - } - }, - { - "name": "accuracyExpert", - "display": "Accuracy Expert", - "autoRole": true, - "type": "accuracy", - "parameters": [], - "message": "Minimum 60wpm and 100% accuracy required.", - "requirements": { - "wpm": { - "min": 60 - }, - "acc": { - "exact": 100 - }, - "afk": { - "max": 5 - }, - "time": { - "min": 600 - } - } - }, - { - "name": "accuracyMaster", - "display": "Accuracy Master", - "autoRole": true, - "type": "accuracy", - "parameters": [], - "message": "Minimum 60wpm and 100% accuracy required.", - "requirements": { - "wpm": { - "min": 60 - }, - "acc": { - "exact": 100 - }, - "afk": { - "max": 5 - }, - "time": { - "min": 1200 - } - } - }, - { - "name": "accuracyGod", - "display": "Accuracy God", - "autoRole": true, - "type": "accuracy", - "parameters": [], - "message": "Minimum 60wpm and 100% accuracy required.", - "requirements": { - "wpm": { - "min": 60 - }, - "acc": { - "exact": 100 - }, - "afk": { - "max": 5 - }, - "time": { - "min": 1800 - } - } - }, - { - "name": "inAGalaxyFarFarAway", - "display": "In a galaxy far far away", - "type": "script", - "parameters": ["episode4.txt", null, ["space_balls"]], - "requirements": { - "config": { - "tapeMode": "off" - } - } - }, - { - "name": "beepBoop", - "display": "Beep Boop", - "type": "script", - "parameters": ["beepboop.txt", null, ["nospace"]], - "message": "Mininum 45 WPM and 100% accuracy required.", - "requirements": { - "wpm": { - "min": 45 - }, - "acc": { - "min": 100 - }, - "funbox": { - "exact": ["nospace"] - } - } - }, - { - "name": "whosYourDaddy", - "display": "Who's your daddy?", - "type": "script", - "parameters": ["episode5.txt", null, ["space_balls"]], - "requirements": { - "config": { - "tapeMode": "off" - } - } - }, - { - "name": "itsATrap", - "display": "It's a trap!", - "type": "script", - "parameters": ["episode6.txt", null, ["space_balls"]], - "requirements": { - "config": { - "tapeMode": "off" - } - } - }, - { - "name": "jolly", - "display": "Jolly", - "autoRole": true, - "type": "script", - "parameters": ["jolly.txt", null, null], - "message": "Minimum 70wpm required.", - "requirements": { - "wpm": { - "min": 70 - } - } - }, - { - "name": "gottaCatchEmAll", - "display": "Gotta catch 'em all", - "autoRole": true, - "type": "script", - "parameters": ["pokemon.txt", null, null] - }, - { - "name": "rapGod", - "display": "Rap God", - "autoRole": true, - "type": "script", - "parameters": ["rapgod.txt", null, null], - "message": "Minimum 85wpm and 90% accuracy required.", - "requirements": { - "wpm": { - "min": 85 - }, - "acc": { - "min": 90 - }, - "afk": { - "max": 5 - } - } - }, - { - "name": "navySeal", - "display": "Navy Seal", - "autoRole": true, - "type": "script", - "parameters": ["navyseal.txt", null, null], - "message": "Minimum 60wpm and 100% accuracy required.", - "requirements": { - "wpm": { - "min": 60 - }, - "acc": { - "exact": 100 - }, - "afk": { - "max": 5 - } - } - }, - { - "name": "littleChef", - "display": "Little Chef", - "type": "script", - "parameters": ["littlechef.txt", null, null] - }, - { - "name": "crosstalk", - "display": "(CROSSTALK)", - "type": "script", - "parameters": ["crosstalk.txt", null, null] - }, - { - "name": "bees", - "display": "Bees!", - "type": "script", - "parameters": ["bees.txt", null, null] - }, - { - "name": "getOffMySwamp", - "display": "Get off my swamp", - "type": "script", - "parameters": ["shrek.txt", null, null] - }, - { - "name": "lookAtMeIAmTheDeveloperNow", - "display": "Look at me. I am the developer now.", - "autoRole": true, - "type": "script", - "parameters": ["sourcecode.txt", null, null] - }, - { - "name": "beLikeWater", - "display": "Be like water", - "type": "funbox", - "parameters": [["layoutfluid"], "time", 60], - "message": "Remember: You need to achieve at least 50 wpm in each layout." - }, - { - "name": "rollercoaster", - "display": "Rollercoaster", - "autoRole": true, - "type": "funbox", - "parameters": [["round_round_baby"], "time", 3600], - "requirements": { - "time": { - "min": 3600 - }, - "funbox": { - "exact": ["round_round_baby"] - } - } - }, - { - "name": "oneHourMirror", - "display": "ɿoɿɿim ɿυoʜ ɘno", - "autoRole": true, - "type": "funbox", - "parameters": [["mirror"], "time", 3600], - "requirements": { - "time": { - "min": 3600 - }, - "funbox": { - "exact": ["mirror"] - } - } - }, - { - "name": "chooChoo", - "display": "Choo choo", - "autoRole": true, - "type": "funbox", - "parameters": [["choo_choo"], "time", 3600], - "requirements": { - "time": { - "min": 3600 - }, - "funbox": { - "exact": ["choo_choo"] - } - } - }, - { - "name": "mnemonist", - "display": "Mnemonist", - "type": "funbox", - "parameters": [["memory"], "words", 25, "master"], - "requirements": { - "config": { - "tapeMode": "off" - } - } - }, - { - "name": "earfquake", - "display": "Earfquake", - "autoRole": true, - "type": "funbox", - "parameters": [["earthquake"], "time", 3600], - "requirements": { - "time": { - "min": 3600 - }, - "funbox": { - "exact": ["earthquake"] - } - } - }, - { - "name": "simonSez", - "display": "Simon Sez", - "autoRole": true, - "type": "funbox", - "parameters": [["simon_says"], "time", 3600], - "requirements": { - "time": { - "min": 3600 - }, - "funbox": { - "exact": ["simon_says"] - } - } - }, - { - "name": "accountant", - "display": "Accountant", - "autoRole": true, - "type": "funbox", - "parameters": [["58008"], "time", 3600], - "requirements": { - "time": { - "min": 3600 - }, - "funbox": { - "exact": ["58008"] - } - } - }, - { - "name": "hidden", - "display": "Hidden", - "autoRole": true, - "type": "funbox", - "parameters": [["read_ahead"], "time", 60], - "requirements": { - "wpm": { - "min": 100 - }, - "time": { - "min": 60 - }, - "funbox": { - "exact": ["read_ahead"] - }, - "config": { - "tapeMode": "off" - } - } - }, - { - "name": "iCanSeeTheFuture", - "display": "I can see the future", - "autoRole": true, - "type": "funbox", - "parameters": [["read_ahead_hard"], "time", 60], - "requirements": { - "wpm": { - "min": 100 - }, - "time": { - "min": 60 - }, - "funbox": { - "exact": ["read_ahead_hard"] - }, - "config": { - "tapeMode": "off" - } - } - }, - { - "name": "whatAreWordsAtThisPoint", - "display": "What are words at this point?", - "autoRole": true, - "type": "funbox", - "parameters": [["gibberish"], "time", 3600], - "requirements": { - "time": { - "min": 60 - }, - "funbox": { - "exact": ["gibberish"] - } - } - }, - { - "name": "specials", - "display": "Specials", - "autoRole": true, - "type": "funbox", - "parameters": [["specials"], "time", 3600], - "requirements": { - "time": { - "min": 60 - }, - "funbox": { - "exact": ["specials"] - } - } - }, - { - "name": "aeiou", - "display": "Aeiou.", - "autoRole": true, - "type": "funbox", - "parameters": [["tts"], "time", 3600], - "requirements": { - "time": { - "min": 60 - }, - "funbox": { - "exact": ["tts"] - } - } - }, - { - "name": "asciiWarrior", - "display": "ASCII warrior", - "autoRole": true, - "type": "funbox", - "parameters": [["ascii"], "time", 3600], - "requirements": { - "time": { - "min": 60 - }, - "funbox": { - "exact": ["ascii"] - } - } - }, - { - "name": "iKINdaLikEHoWinEFFICIeNtQwErtYIs.", - "display": "i KINda LikE HoW inEFFICIeNt QwErtY Is.", - "autoRole": true, - "type": "funbox", - "parameters": [["sPoNgEcAsE"], "time", 3600], - "requirements": { - "time": { - "min": 60 - }, - "funbox": { - "exact": ["sPoNgEcAsE"] - } - } - }, - { - "name": "oneNauseousMonkey", - "display": "One Nauseous Monkey", - "autoRole": true, - "type": "funbox", - "parameters": [["nausea"], "time", 3600], - "requirements": { - "time": { - "min": 60 - }, - "funbox": { - "exact": ["nausea"] - } - } - }, - { - "name": "thumbWarrior", - "display": "Thumb warrior", - "type": "customTime", - "parameters": [3600] - }, - { - "name": "mouseWarrior", - "display": "Mouse warrior", - "type": "customTime", - "parameters": [3600] - }, - { - "name": "mobileWarrior", - "display": "Mobile warrior", - "type": "customTime", - "parameters": [3600] - }, - { - "name": "69", - "display": "6969696969", - "autoRole": true, - "type": "customTime", - "parameters": [69], - "message": "You need to achieve 69 wpm, 69 raw, 69% accuracy and 69% consistency.", - "requirements": { - "wpm": { - "exact": 69 - }, - "raw": { - "exact": 69 - }, - "acc": { - "exact": 69 - }, - "con": { - "exact": 69 - } - } - }, - { - "name": "upsideDown", - "display": "Upside down", - "type": "customTime", - "parameters": [60] - }, - { - "name": "oneArmedBandit", - "display": "One armed bandit", - "type": "customWords", - "parameters": [10000] - }, - { - "name": "englishMaster", - "display": "English master", - "autoRole": true, - "type": "customTime", - "parameters": [3600], - "requirements": { - "time": { - "min": 3600 - }, - "config": { - "language": "english_10k", - "punctuation": true, - "numbers": true - } - } - }, - { - "name": "feetWarrior", - "display": "Feet warrior", - "type": "customTime", - "parameters": [3600] - }, - { - "name": "wingdings", - "display": "Ten Words of Pain", - "type": "other", - "parameters": [], - "message": "Complete a 10-word Master mode test using the Wingdings custom font. No keymap allowed. Minimum 60 WPM and 100% accuracy required.", - "requirements": { - "acc": { - "exact": 100 - } - } - } -] diff --git a/packages/challenges/.oxlintrc.json b/packages/challenges/.oxlintrc.json new file mode 100644 index 000000000000..f6a8e7c07d0a --- /dev/null +++ b/packages/challenges/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "ignorePatterns": ["node_modules", "dist", ".turbo"], + "extends": [ + "../oxlint-config/index.jsonc" + // "@monkeytype/oxlint-config" + ] +} diff --git a/packages/challenges/__test__/tsconfig.json b/packages/challenges/__test__/tsconfig.json new file mode 100644 index 000000000000..bc5ae47e535d --- /dev/null +++ b/packages/challenges/__test__/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@monkeytype/typescript-config/base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"] +} diff --git a/packages/challenges/package.json b/packages/challenges/package.json new file mode 100644 index 000000000000..da5317a27d41 --- /dev/null +++ b/packages/challenges/package.json @@ -0,0 +1,36 @@ +{ + "name": "@monkeytype/challenges", + "private": true, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "dev": "tsup-node --watch", + "build": "npm run madge && tsup-node", + "test": "vitest run", + "madge": " madge --circular --extensions ts ./src", + "ts-check": "tsc --noEmit", + "lint": "oxlint . --type-aware --type-check", + "lint-fast": "oxlint .", + "george-mapping": "tsx ./scripts/challenge-roles" + }, + "dependencies": { + "@monkeytype/schemas": "workspace:*", + "@monkeytype/util": "workspace:*" + }, + "devDependencies": { + "@monkeytype/tsup-config": "workspace:*", + "@monkeytype/typescript-config": "workspace:*", + "@types/node": "24.9.1", + "madge": "8.0.0", + "oxlint": "1.68.0", + "oxlint-tsgolint": "0.23.0", + "tsup": "8.4.0", + "typescript": "6.0.2", + "vitest": "4.1.0" + } +} diff --git a/packages/challenges/scripts/challenge-roles.ts b/packages/challenges/scripts/challenge-roles.ts new file mode 100644 index 000000000000..80c54edf22c7 --- /dev/null +++ b/packages/challenges/scripts/challenge-roles.ts @@ -0,0 +1,8 @@ +import { getChallenges } from "../src/index"; + +const known = Object.fromEntries( + getChallenges().map((it) => [it.name, it.discordRoleId]), +); + +console.log("roleid mapping"); +console.log(JSON.stringify(known, null, 2)); diff --git a/packages/challenges/src/index.ts b/packages/challenges/src/index.ts new file mode 100644 index 000000000000..4bd5f917ea4c --- /dev/null +++ b/packages/challenges/src/index.ts @@ -0,0 +1,1103 @@ +import { Challenge, ChallengeName } from "@monkeytype/schemas/challenges"; + +const challenges: Record> = { + "100hours": { + display: "100 hours", + autoRole: false, + type: "hidden", + discordRoleId: "761766710704603166", + category: "other", + description: "Achieve 100 hours of typing.", + parameters: [], + }, + "250hours": { + display: "250 hours", + autoRole: false, + type: "hidden", + discordRoleId: "799825381733433344", + category: "other", + description: "Achieve 250 hours of typing.", + parameters: [], + }, + "500hours": { + display: "500 hours", + autoRole: false, + type: "hidden", + discordRoleId: "951861792622125106", + category: "other", + description: "Achieve 500 hours of typing.", + parameters: [], + }, + "1000hours": { + display: "1000 hours", + autoRole: false, + type: "hidden", + discordRoleId: "1262175323588395100", + category: "other", + description: "Achieve 1000 hours of typing.", + parameters: [], + }, + "69": { + display: "6969696969", + autoRole: true, + type: "customTime", + parameters: [69], + message: + "You need to achieve 69 wpm, 69 raw, 69% accuracy and 69% consistency.", + requirements: { + wpm: { exact: 69 }, + raw: { exact: 69 }, + acc: { exact: 69 }, + con: { exact: 69 }, + }, + discordRoleId: "749505965174292511", + category: "other", + description: + "Complete a 69-second test and achieve 69 WPM, 69 raw, 69% accuracy, and 69% consistency.", + }, + oneHourWarrior: { + display: "One Hour Warrior", + autoRole: true, + type: "customTime", + parameters: [3600], + requirements: { + time: { min: 3600 }, + }, + discordRoleId: "728371749737201855", + category: "endurance", + description: "Complete a one-hour test.", + }, + doubleDown: { + display: "Double Down", + autoRole: true, + type: "customTime", + parameters: [7200], + requirements: { + time: { min: 7200 }, + }, + discordRoleId: "732008008514535544", + category: "endurance", + description: "Complete a two-hour test.", + }, + tripleTrouble: { + display: "Triple Trouble", + autoRole: true, + type: "customTime", + parameters: [10800], + requirements: { + time: { min: 10800 }, + }, + discordRoleId: "732008047618293762", + category: "endurance", + description: "Complete a three-hour test.", + }, + quad: { + display: "Quaaaaad", + autoRole: true, + type: "customTime", + parameters: [14400], + requirements: { + time: { min: 14400 }, + }, + discordRoleId: "736215666352455801", + category: "endurance", + description: "Complete a four-hour test.", + }, + "8Ball": { + display: "8 Ball", + type: "customTime", + parameters: [28800], + requirements: { + time: { min: 28800 }, + }, + discordRoleId: "736528159956271126", + category: "endurance", + description: "Complete an eight-hour test.", + }, + theBig12: { + display: "The Big 12", + type: "customTime", + parameters: [43200], + requirements: { + time: { min: 43200 }, + }, + discordRoleId: "740532256388546581", + category: "endurance", + description: "Complete a twelve-hour test.", + }, + "1Day": { + display: "1 Day", + type: "customTime", + parameters: [86400], + requirements: { + time: { min: 86400 }, + }, + discordRoleId: "751801958511149057", + category: "endurance", + description: "Complete a twenty-four-hour test.", + }, + trueSimp: { + display: "True Simp", + autoRole: true, + type: "customText", + parameters: ["miodec", "repeat", 10000, "word", false], + discordRoleId: "744328648211038359", + category: "script", + description: "Type miodec ten thousand times.", + }, + bigramSalad: { + display: "Bigram Salad", + autoRole: true, + type: "customText", + parameters: [ + "to of in it is as at be we he so on an or do if up by my go", + "random", + 100, + "word", + false, + ], + requirements: { + wpm: { min: 100 }, + }, + discordRoleId: "818535054145093652", + category: "speed", + description: + "Get 100 WPM on a randomized, 100-word custom test with the words list: to of in it is as at be we he so on an or do if up by my go.", + }, + simp: { + display: "Simp", + autoRole: true, + type: "customText", + parameters: ["miodec", "repeat", 1000, "word", false], + discordRoleId: "743854992699687023", + category: "script", + description: "Type miodec one thousand times.", + }, + simpLord: { + display: "Simp Lord", + // false for now + autoRole: false, + type: "customText", + // this would be 100k times: + parameters: ["miodec", "repeat", 100000, "word", false], + discordRoleId: "984911956949479445", + category: "script", + description: "Type miodec one hundred thousand times.", + }, + antidiseWhat: { + display: "Antidise-what?", + autoRole: true, + type: "customText", + parameters: ["antidisestablishmentarianism", "repeat", 1, "word", false], + requirements: { + wpm: { min: 200 }, + }, + discordRoleId: "782006507360616449", + category: "script", + description: "Get at least 200 wpm typing antidisestablishmentarianism.", + }, + whatsThisWebsiteCalledAgain: { + display: "What's this website called again?", + autoRole: true, + type: "customText", + parameters: ["monkeytype", "repeat", 1000, "word", false], + discordRoleId: "739276161603076116", + category: "script", + description: "Type monkeytype one thousand times.", + }, + developd: { + display: "Develop'd", + autoRole: true, + type: "customText", + parameters: ["develop", "repeat", 1000, "word", false], + discordRoleId: "735964917877964932", + category: "script", + description: "Type develop one thousand times.", + }, + slowAndSteady: { + display: "Slow and Steady", + autoRole: true, + type: "customTime", + parameters: [300], + requirements: { + wpm: { exact: 60 }, + config: { liveSpeedStyle: "off", paceCaret: "off" }, + }, + discordRoleId: "782005061935956008", + category: "speed", + description: + "Complete a 5-minute test with exactly 60 WPM without using the live WPM or pace caret.", + }, + speedSpacer: { + display: "Speed Spacer", + autoRole: true, + type: "customText", + parameters: [ + "a b c d e f g h i j k l m n o p q r s t u v w x y z", + "random", + 100, + "word", + false, + ], + requirements: { + wpm: { min: 100 }, + }, + discordRoleId: "755244049446731856", + category: "speed", + description: + "Get 100 wpm on a randomised custom test with the input: a b c d e f g h i j k l m n o p q r s t u v w x y z (the alphabet) and a word count of 100.", + }, + iveGotThePower: { + display: "I've got the POWER", + autoRole: true, + type: "customText", + parameters: ["power", "repeat", 10, "word", false], + requirements: { + wpm: { min: 400 }, + }, + discordRoleId: "764879734873915402", + category: "speed", + description: "Get 400 WPM while typing power 10 times.", + }, + accuracyExpert: { + display: "Accuracy Expert", + autoRole: true, + type: "accuracy", + parameters: [], + message: "Minimum 60wpm and 100% accuracy required.", + requirements: { + wpm: { min: 60 }, + acc: { exact: 100 }, + afk: { max: 5 }, + time: { min: 600 }, + }, + discordRoleId: "751168451263070259", + category: "accuracy", + description: "Complete a 10-minute Master mode test.", + }, + accuracyMaster: { + display: "Accuracy Master", + autoRole: true, + type: "accuracy", + parameters: [], + message: "Minimum 60wpm and 100% accuracy required.", + requirements: { + wpm: { min: 60 }, + acc: { exact: 100 }, + afk: { max: 5 }, + time: { min: 1200 }, + }, + discordRoleId: "751168567432708239", + category: "accuracy", + description: "Complete a 20-minute Master mode test.", + }, + accuracyGod: { + display: "Accuracy God", + autoRole: true, + type: "accuracy", + parameters: [], + message: "Minimum 60wpm and 100% accuracy required.", + requirements: { + wpm: { min: 60 }, + acc: { exact: 100 }, + afk: { max: 5 }, + time: { min: 1800 }, + }, + discordRoleId: "751168657626890361", + category: "accuracy", + description: "Complete a 30-minute Master mode test.", + }, + inAGalaxyFarFarAway: { + display: "In a galaxy far, far away", + type: "script", + parameters: ["episode4.txt", null, ["space_balls"]], + requirements: { + config: { tapeMode: "off" }, + }, + discordRoleId: "740004324301602907", + category: "script", + description: + "Type out the entire Star Wars Episode 4 script with punctuation while watching the movie simultaneously.", + }, + beepBoop: { + display: "Beep Boop", + type: "script", + parameters: ["beepboop.txt", null, ["nospace"]], + message: "Mininum 45 WPM and 100% accuracy required.", + requirements: { + wpm: { min: 45 }, + acc: { min: 100 }, + funbox: { exact: ["nospace"] }, + }, + discordRoleId: "813076265145729024", + category: "script", + description: + "Type the beepboop script with 100% accuracy and at least 45 WPM.", + }, + whosYourDaddy: { + display: "Who's your daddy", + type: "script", + parameters: ["episode5.txt", null, ["space_balls"]], + requirements: { + config: { tapeMode: "off" }, + }, + discordRoleId: "742171915405361204", + category: "script", + description: + "Type out the entire Star Wars Episode 5 script with punctuation while watching the movie simultaneously.", + }, + itsATrap: { + display: "It's a trap!!", + type: "script", + parameters: ["episode6.txt", null, ["space_balls"]], + requirements: { + config: { tapeMode: "off" }, + }, + discordRoleId: "744325174668820550", + category: "script", + description: + "Type out the entire Star Wars Episode 6 script with punctuation while watching the movie simultaneously.", + }, + jolly: { + display: "Jolly", + autoRole: true, + type: "script", + parameters: ["jolly.txt", null, null], + message: "Minimum 70wpm required.", + requirements: { + wpm: { min: 70 }, + }, + discordRoleId: "768497412548329563", + category: "script", + description: "Type the Jolly script with a minimum of 70 wpm.", + }, + gottaCatchEmAll: { + display: "Gotta Catch 'Em All", + autoRole: true, + type: "script", + parameters: ["pokemon.txt", null, null], + discordRoleId: "767069340599975998", + category: "script", + description: "Type out the names of all Pokemon.", + }, + rapGod: { + display: "Rap God", + autoRole: true, + type: "script", + parameters: ["rapgod.txt", null, null], + message: "Minimum 85wpm and 90% accuracy required.", + requirements: { + wpm: { min: 85 }, + acc: { min: 90 }, + afk: { max: 5 }, + }, + discordRoleId: "743844891045396603", + category: "script", + description: + "Type out the lyrics of Eminem's Rap God at a minimum of 85 WPM and 90% accuracy, including punctuation.", + }, + navySeal: { + display: "Navy Seal", + autoRole: true, + type: "script", + parameters: ["navyseal.txt", null, null], + message: "Minimum 60wpm and 100% accuracy required.", + requirements: { + wpm: { min: 60 }, + acc: { exact: 100 }, + afk: { max: 5 }, + }, + discordRoleId: "762345535969165342", + category: "script", + description: + "Type out the Navy Seal copy pasta with 100% accuracy and minimum 60 WPM.", + }, + littleChef: { + display: "Little Chef", + type: "script", + parameters: ["littlechef.txt", null, null], + discordRoleId: "763544714028122153", + category: "script", + description: + "Type out the entire Ratatouille script while watching the movie simultaneously.", + }, + crosstalk: { + display: "(CROSSTALK)", + type: "script", + parameters: ["crosstalk.txt", null, null], + discordRoleId: "761276009664217129", + category: "script", + description: + "Type out the entire transcript of the first 2020 Presidential Debate.", + }, + bees: { + display: "Bees!!!", + type: "script", + parameters: ["bees.txt", null, null], + discordRoleId: "739636003182084307", + category: "script", + description: + "Type out the entire Bee Movie script while watching the movie simultaneously.", + }, + getOffMySwamp: { + display: "Get Off My Swamp", + type: "script", + parameters: ["shrek.txt", null, null], + discordRoleId: "757346966987342026", + category: "script", + description: + "Type out the entire Shrek script with punctuation while watching the movie simultaneously.", + }, + fiftyShadesOfHell: { + display: "50 Shades of Hell", + type: "script", + parameters: [], + discordRoleId: "751802155119280128", + category: "script", + description: "Type out your favourite chapter from 50 Shades of Gray.", + }, + lookAtMeIAmTheDeveloperNow: { + display: "Look at me. I am the developer now.", + autoRole: true, + type: "script", + parameters: ["sourcecode.txt", null, null], + discordRoleId: "937358772635074600", + category: "script", + description: + "Type out the entire source code of Monkeytype, as it was in February 2022.", + }, + beLikeWater: { + display: "Be Like Water", + type: "funbox", + parameters: [["layoutfluid"], "time", 60], + message: "Remember: You need to achieve at least 50 wpm in each layout.", + discordRoleId: "740568679485276201", + category: "funbox", + description: + "Achieve at least 50 WPM in all three layouts in a 60-second time test using the layoutfluid mode. Layouts must be unique (e.g., QWERTY, Colemak, Dvorak).", + }, + rollercoaster: { + display: "Rollercoaster", + autoRole: true, + type: "funbox", + parameters: [["round_round_baby"], "time", 3600], + requirements: { + time: { min: 3600 }, + funbox: { exact: ["round_round_baby"] }, + }, + discordRoleId: "736032495526740001", + category: "funbox", + description: + "Complete at least a one-hour test using the round round baby mode.", + }, + oneHourMirror: { + display: "ɿoɿɿim ɿυoʜ ɘno", + autoRole: true, + type: "funbox", + parameters: [["mirror"], "time", 3600], + requirements: { + time: { min: 3600 }, + funbox: { exact: ["mirror"] }, + }, + discordRoleId: "737385182998429757", + category: "funbox", + description: "Complete at least a one-hour test using the mirror mode.", + }, + chooChoo: { + display: "Choo choo", + autoRole: true, + type: "funbox", + parameters: [["choo_choo"], "time", 3600], + requirements: { + time: { min: 3600 }, + funbox: { exact: ["choo_choo"] }, + }, + discordRoleId: "739306439574683710", + category: "funbox", + description: "Complete at least a one-hour test using choo choo mode.", + }, + mnemonist: { + display: "Mnemonist", + type: "funbox", + parameters: [["memory"], "words", 25, "master"], + requirements: { + config: { tapeMode: "off" }, + }, + discordRoleId: "782005606852067328", + category: "funbox", + description: + "Achieve 100+ WPM with 100% accuracy on a 25-word test using the memory funbox.", + }, + earfquake: { + display: "Earfquake", + autoRole: true, + type: "funbox", + parameters: [["earthquake"], "time", 3600], + requirements: { + time: { min: 3600 }, + funbox: { exact: ["earthquake"] }, + }, + discordRoleId: "740730587429601291", + category: "funbox", + description: + "Complete at least a one-hour test using the earthquake funbox mode.", + }, + simonSez: { + display: "Simon Sez", + autoRole: true, + type: "funbox", + parameters: [["simon_says"], "time", 3600], + requirements: { + time: { min: 3600 }, + funbox: { exact: ["simon_says"] }, + }, + discordRoleId: "742128871825997914", + category: "funbox", + description: + "Complete at least a one-hour test using the simon says funbox mode.", + }, + accountant: { + display: "Accountant", + autoRole: true, + type: "funbox", + parameters: [["58008"], "time", 3600], + requirements: { + time: { min: 3600 }, + funbox: { exact: ["58008"] }, + }, + discordRoleId: "743962178821816391", + category: "funbox", + description: + "Complete at least a one-hour test using the 58008 funbox mode.", + }, + hidden: { + display: "Hidden", + autoRole: true, + type: "funbox", + parameters: [["read_ahead"], "time", 60], + requirements: { + wpm: { min: 100 }, + time: { min: 60 }, + funbox: { exact: ["read_ahead"] }, + config: { tapeMode: "off" }, + }, + discordRoleId: "782006137742557194", + category: "funbox", + description: + "Achieve 100+ WPM using the read ahead funbox on a 60-second test.", + }, + iCanSeeTheFuture: { + display: "I can see the future", + autoRole: true, + type: "funbox", + parameters: [["read_ahead_hard"], "time", 60], + requirements: { + wpm: { min: 100 }, + time: { min: 60 }, + funbox: { exact: ["read_ahead_hard"] }, + config: { tapeMode: "off" }, + }, + discordRoleId: "814877508008411226", + category: "funbox", + description: + "Achieve 100+ WPM using the read ahead hard funbox on a 60-second test.", + }, + whatAreWordsAtThisPoint: { + display: "What are words at this point", + autoRole: true, + type: "funbox", + parameters: [["gibberish"], "time", 3600], + requirements: { + time: { min: 60 }, + funbox: { exact: ["gibberish"] }, + }, + discordRoleId: "744209241396740176", + category: "funbox", + description: + "Complete at least a one-hour test using the gibberish funbox mode.", + }, + specials: { + display: "Specials", + autoRole: true, + type: "funbox", + parameters: [["specials"], "time", 3600], + requirements: { + time: { min: 60 }, + funbox: { exact: ["specials"] }, + }, + discordRoleId: "744209452714033162", + category: "funbox", + description: + "Complete at least a one-hour test using the specials funbox mode.", + }, + aeiou: { + display: "Aeiou.", + autoRole: true, + type: "funbox", + parameters: [["tts"], "time", 3600], + requirements: { + time: { min: 60 }, + funbox: { exact: ["tts"] }, + }, + discordRoleId: "744318102766092362", + category: "funbox", + description: "Complete at least a one-hour test using the tts funbox mode.", + }, + asciiWarrior: { + display: "ASCII warrior", + autoRole: true, + type: "funbox", + parameters: [["ascii"], "time", 3600], + requirements: { + time: { min: 60 }, + funbox: { exact: ["ascii"] }, + }, + discordRoleId: "746142791326760980", + category: "funbox", + description: + "Complete at least a one-hour test using the ascii funbox mode.", + }, + iKiNdAlIkEhOwInEfFiCiEnTqWeRtYiS: { + display: "i KINda LikE HoW inEFFICIeNt QwErtY Is.", + autoRole: true, + type: "funbox", + parameters: [["sPoNgEcAsE"], "time", 3600], + requirements: { + time: { min: 60 }, + funbox: { exact: ["sPoNgEcAsE"] }, + }, + discordRoleId: "760999194525171724", + category: "funbox", + description: + "Complete at least a one-hour test using the randomcase funbox mode.", + }, + oneNauseousMonkey: { + display: "One Nauseous Monkey", + autoRole: true, + type: "funbox", + parameters: [["nausea"], "time", 3600], + requirements: { + time: { min: 60 }, + funbox: { exact: ["nausea"] }, + }, + discordRoleId: "760930262740631633", + category: "funbox", + description: + "Complete at least a one-hour test using the nausea funbox mode.", + }, + thumbWarrior: { + display: "Thumb Warrior", + type: "customTime", + parameters: [3600], + discordRoleId: "761794585109200906", + category: "other", + description: "Complete a one-hour test using only your thumbs.", + }, + mouseWarrior: { + display: "Mouse warrior", + type: "customTime", + parameters: [3600], + discordRoleId: "744580294442614790", + category: "other", + description: + "Complete a one-hour test using only the on-screen keyboard. Funbox modes are not allowed.", + }, + mobileWarrior: { + display: "Mobile warrior", + type: "customTime", + parameters: [3600], + discordRoleId: "744723801526370407", + category: "other", + description: "Complete a one-hour test on mobile.", + }, + upsideDown: { + display: "uʍop ǝpᴉsdn", + type: "customTime", + parameters: [60], + discordRoleId: "782725716114014237", + category: "other", + description: + "Achieve at least 60 WPM on a one-minute test with your keyboard upside down.", + }, + oneArmedBandit: { + display: "One armed bandit", + type: "customWords", + parameters: [10000], + discordRoleId: "765919192557682708", + category: "other", + description: + "Complete a one-hour or 10k words test (whichever comes sooner, using an external timer) using a one-handed words list (either left or right) for your layout.", + }, + englishMaster: { + display: "English master", + autoRole: true, + type: "customTime", + parameters: [3600], + requirements: { + time: { min: 3600 }, + config: { language: "english_10k", punctuation: true, numbers: true }, + }, + discordRoleId: "751166528824672396", + category: "other", + description: + "Complete a one-hour test using English 10k language with punctuation and numbers enabled.", + }, + feetWarrior: { + display: "Foot Warrior", + type: "customTime", + parameters: [3600], + discordRoleId: "751953592860147822", + category: "other", + description: "Complete a one-hour test using your feet. Don't ask me why.", + }, + wingdings: { + display: "Ten Words of Pain", + type: "other", + parameters: [], + message: + "Complete a 10-word Master mode test using the Wingdings custom font. No keymap allowed. Minimum 60 WPM and 100% accuracy required.", + requirements: { + acc: { exact: 100 }, + }, + discordRoleId: "863192575984140338", + category: "other", + description: + "Complete a 10-word Master mode test using the Wingdings custom font.", + }, + ultimateMonkeyFlex: { + display: "Ultimate Monkey Flex", + type: "hidden", + parameters: [], + discordRoleId: "768497815496032266", + category: "champions", + description: "Have the most champion roles in the server.", + }, + oneRoleToRuleThemAll: { + display: "One role to rule them all", + type: "hidden", + parameters: [], + discordRoleId: "758784729151176755", + category: "champions", + description: "Have the most challenge roles in the server.", + }, + doYouKnowTheDefinitionOfInsanity: { + display: "Do You Know The Definition Of Insanity", + type: "hidden", + parameters: [], + discordRoleId: "736527448757370880", + category: "champions", + description: "Complete the longest typing session in Monkeytype history.", + }, + oneHourChampion: { + display: "One Hour Champion", + type: "hidden", + parameters: [], + discordRoleId: "728650773503934464", + category: "champions", + description: "Achieve the highest WPM in a one-hour test.", + }, + fluidChampion: { + display: "Fluid Champion", + type: "hidden", + parameters: [], + discordRoleId: "740568718719058041", + category: "champions", + description: "Achieve the highest WPM in a 60-second layoutfluid test.", + }, + accuracyChampion: { + display: "Accuracy Champion", + type: "hidden", + parameters: [], + discordRoleId: "768499906511110235", + category: "champions", + description: "Achieve the longest Master mode test.", + }, + literallyTheFastestPersonHere: { + display: "Literally The Fastest Person Here", + type: "hidden", + parameters: [], + discordRoleId: "984922187385405460", + category: "champions", + description: + "Achieve 1st place on the time 60 English all-time leaderboard.", + }, + // fehmer suggested putting it here under the champions section (as its a role obtainable by anyone) + bananaHoarder: { + display: "Banana Hoarder", + type: "hidden", + parameters: [], + discordRoleId: "773590599227932754", + category: "champions", + description: "Achieve 1st place on the banana leaderboard.", + }, + alpha: { + display: "A l p h a", + type: "hidden", + parameters: [], + discordRoleId: "773590612762034176", + category: "speed", + description: + "Type the alphabet, with each letter separated by a space and in alphabetical order a b c d e f g h i j k l m n o p q r s t u v w x y z in LESS than 3.37 seconds.", + }, + blazeIt: { + display: "Blaze It", + type: "hidden", + parameters: [], + discordRoleId: "803650889461006346", + category: "speed", + description: "Achieve 420 WPM (can be rounded) by typing weed.", + }, + burstMaster: { + display: "Burst Master", + type: "hidden", + parameters: [], + discordRoleId: "757330922726096917", + category: "speed", + description: "Achieve 200+ WPM on the words 10 mode.", + }, + burstGod: { + display: "Burst God", + type: "hidden", + parameters: [], + discordRoleId: "757330992821305366", + category: "speed", + description: "Achieve 250+ WPM on the words 10 mode.", + }, + shotgun: { + display: "Shotgun", + type: "hidden", + parameters: [], + discordRoleId: "757331084366184539", + category: "speed", + description: "Achieve 300+ WPM on the words 10 mode.", + }, + nuke: { + display: "Nuke", + type: "hidden", + parameters: [], + discordRoleId: "912522664604758016", + category: "speed", + description: "Achieve 350+ WPM on the words 10 mode.", + }, + orbitalCannon: { + display: "Orbital Cannon", + type: "hidden", + parameters: [], + discordRoleId: "1084094136199684196", + category: "speed", + description: "Achieve 400+ WPM on the words 10 mode.", + }, + marathonSprinter: { + display: "Marathon Sprinter", + type: "hidden", + parameters: [], + discordRoleId: "878715678830510111", + category: "speed", + description: "Achieve 200+ WPM on a one-hour test.", + }, + flawless: { + display: "Flawless", + type: "hidden", + parameters: [], + discordRoleId: "767070815987695637", + category: "accuracy", + description: + "Complete back-to-back tests in Master Mode: 15, 30, 60, 120 seconds and 10, 25, 50, 100 words. If you fail one, restart from the beginning. Order of modes is up to you.", + }, + hesBeginningToBelieve: { + display: "He's beginning to believe", + type: "hidden", + parameters: [], + discordRoleId: "979729541096431688", + category: "accuracy", + description: + "Achieve 100% accuracy in a 2-minute test under specified settings.", + }, + goldenHands: { + display: "Golden Hands", + type: "hidden", + parameters: [], + discordRoleId: "851096860969795684", + category: "accuracy", + description: "Complete a 1-hour Master mode test.", + }, + fingerBlaster: { + display: "Finger Blaster", + type: "hidden", + parameters: [], + discordRoleId: "787509606992969728", + category: "other", + description: + "Achieve at least 60 WPM using one finger on a 60-second test.", + }, + whyAreTheWallsMoving: { + display: "Why are the walls moving?", + type: "hidden", + parameters: [], + discordRoleId: "910078947302191114", + category: "other", + description: "Complete a one-hour test using tape mode and letter mode.", + }, + stickman: { + display: "stickman", + type: "hidden", + parameters: [], + discordRoleId: "788107449151651890", + category: "other", + description: + "Complete a one-hour test using chopsticks/pencils/pens (you get the idea) with both hands.", + }, + waveDynamics: { + display: "Wave Dynamics", + type: "hidden", + parameters: [], + discordRoleId: "1443311363794407586", + category: "other", + description: + "Achieve 30 wpm 100% acc on a 60 second test with the raw graph being a perfect wave (to achieve this, type 5 characters in 1 second, pause for 1 second, repeat). Must be completed with random words (time 60 mode). Must include words history in the screenshot.", + }, + apesTogetherStrong: { + display: "Apes Together Strong", + type: "hidden", + parameters: [], + discordRoleId: "863193901153779713", + category: "other", + description: + "Complete a one-hour test in a Tribe lobby with at least 10 players.", + }, + apesTogetherStronger: { + display: "Apes Together Stronger", + type: "hidden", + parameters: [], + discordRoleId: "898964842726195220", + category: "other", + description: + "Complete a two-hour test in a Tribe lobby with at least 10 players.", + }, + apesTogetherInvincible: { + display: "Apes Together Invincible", + type: "hidden", + parameters: [], + discordRoleId: "1367559768746758194", + category: "other", + description: + "Complete a three-hour test in a Tribe lobby with at least 10 players.", + }, + footBarbarian: { + display: "Foot Barbarian", + type: "hidden", + parameters: [], + discordRoleId: "1025814170962231336", + category: "other", + description: "Complete a two-hour test using your feet.", + }, + bigFoot: { + display: "Big Foot", + type: "hidden", + parameters: [], + discordRoleId: "1030531753082900610", + category: "other", + description: "Complete a three-hour test using your feet.", + }, + woodPecker: { + display: "Wood Pecker", + type: "hidden", + parameters: [], + discordRoleId: "753724531666845830", + category: "other", + description: "Complete a 200-word test using only your nose.", + }, + mrWorldwide: { + display: "Mr Worldwide", + type: "hidden", + parameters: [], + discordRoleId: "762345904279519292", + category: "other", + description: + "Achieve 100 WPM on a 60-second test in 5 different languages (English, English expanded, English 10k and coding languages all count as English which is 1 language).", + }, + internalMetronome: { + display: "Internal Metronome", + type: "hidden", + parameters: [], + discordRoleId: "934067904884916234", + category: "other", + description: + "Complete a 60-second test (standard English) with a minimum consistency of 90%, 100% accuracy and within 25% of your 60-second personal best.", + }, + roleCollector: { + display: "Role Collector", + type: "hidden", + parameters: [], + discordRoleId: "739306809554108520", + category: "roleCount", + description: "Collect 10 roles.", + }, + roleEnthusiast: { + display: "Role Enthusiast", + type: "hidden", + parameters: [], + discordRoleId: "753360663656529931", + category: "roleCount", + description: "Collect 20 roles.", + }, + roleAddict: { + display: "Role Addict", + type: "hidden", + parameters: [], + discordRoleId: "758783172833443850", + category: "roleCount", + description: "Collect 30 roles.", + }, + roleOverdose: { + display: "Role Overdose", + type: "hidden", + parameters: [], + discordRoleId: "758783365930811423", + category: "roleCount", + description: "Collect 40 roles.", + }, + roleZombie: { + display: "Role Zombie", + type: "hidden", + parameters: [], + discordRoleId: "762701731993616405", + category: "roleCount", + description: "Collect 50 roles.", + }, + roleOverlord: { + display: "Role Overlord", + type: "hidden", + parameters: [], + discordRoleId: "805519411502514187", + category: "roleCount", + description: "Collect 60 roles.", + }, + roleImp: { + display: "Role Imp", + type: "hidden", + parameters: [], + discordRoleId: "906565521271558214", + category: "roleCount", + description: "Collect 70 roles.", + }, +}; + +const map: Record = Object.fromEntries( + Object.entries(challenges).map(([name, def]) => [name, { ...def, name }]), +) as Record; + +const list: Challenge[] = Object.values(map); +const regular: Challenge[] = list.filter((it) => it.type !== "hidden"); + +export function getChallenges(): Challenge[] { + return list; +} + +export function getRegularChallenges(): Challenge[] { + return regular; +} + +export function getChallenge(name: ChallengeName): Challenge { + return map[name]; +} diff --git a/packages/challenges/tsconfig.json b/packages/challenges/tsconfig.json new file mode 100644 index 000000000000..19dc35bfb3ce --- /dev/null +++ b/packages/challenges/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@monkeytype/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "moduleResolution": "Bundler", + "module": "ES6", + "target": "ES2015", + "lib": ["es2019", "dom"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/challenges/tsup.config.js b/packages/challenges/tsup.config.js new file mode 100644 index 000000000000..28181ee3ec44 --- /dev/null +++ b/packages/challenges/tsup.config.js @@ -0,0 +1,3 @@ +import { extendConfig } from "@monkeytype/tsup-config"; + +export default extendConfig(() => ({ entry: ["src/index.ts"] })); diff --git a/packages/challenges/vitest.config.ts b/packages/challenges/vitest.config.ts new file mode 100644 index 000000000000..481ab6a143b8 --- /dev/null +++ b/packages/challenges/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + passWithNoTests: true, + coverage: { + include: ["**/*.ts"], + }, + }, +}); diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 7a01febb6648..62090ea3aabd 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -177,6 +177,14 @@ export const EditCustomThemeRequstSchema = z.object({ }); export type EditCustomThemeRequst = z.infer; +export const GetDiscordOauthLinkQuerySchema = z.object({ + includeRoles: z.boolean().optional(), +}); + +export type GetDiscordOauthLinkQuery = z.infer< + typeof GetDiscordOauthLinkQuerySchema +>; + export const GetDiscordOauthLinkResponseSchema = responseWithData( z.object({ url: z.string().url(), @@ -190,6 +198,7 @@ export const LinkDiscordRequestSchema = z.object({ tokenType: z.string(), accessToken: z.string(), state: z.string().length(20), + scope: z.array(z.string()).optional(), }); export type LinkDiscordRequest = z.infer; @@ -663,6 +672,7 @@ export const usersContract = c.router( description: "Start OAuth authentication with discord", method: "GET", path: "/discord/oauth", + query: GetDiscordOauthLinkQuerySchema.strict(), responses: { 200: GetDiscordOauthLinkResponseSchema, }, diff --git a/packages/schemas/src/challenges.ts b/packages/schemas/src/challenges.ts index 8f4611ed74f1..525f3c5a1cea 100644 --- a/packages/schemas/src/challenges.ts +++ b/packages/schemas/src/challenges.ts @@ -5,11 +5,125 @@ const MinRequiredNumber = z.object({ min: z.number() }).strict(); const MaxRequiredNumber = z.object({ max: z.number() }).strict(); const ExactRequiredNumber = z.object({ exact: z.number() }).strict(); +import { customEnumErrorHandler } from "./util"; + +export const ChallengeNameSchema = z.enum( + [ + "oneHourWarrior", + "doubleDown", + "tripleTrouble", + "quad", + "8Ball", + "theBig12", + "1Day", + "trueSimp", + "bigramSalad", + "simp", + "simpLord", + "antidiseWhat", + "whatsThisWebsiteCalledAgain", + "developd", + "slowAndSteady", + "speedSpacer", + "iveGotThePower", + "accuracyExpert", + "accuracyMaster", + "accuracyGod", + "inAGalaxyFarFarAway", + "beepBoop", + "whosYourDaddy", + "itsATrap", + "jolly", + "gottaCatchEmAll", + "rapGod", + "navySeal", + "littleChef", + "crosstalk", + "bees", + "getOffMySwamp", + "fiftyShadesOfHell", + "lookAtMeIAmTheDeveloperNow", + "beLikeWater", + "rollercoaster", + "oneHourMirror", + "chooChoo", + "mnemonist", + "earfquake", + "simonSez", + "accountant", + "hidden", + "iCanSeeTheFuture", + "whatAreWordsAtThisPoint", + "specials", + "aeiou", + "asciiWarrior", + "oneNauseousMonkey", + "thumbWarrior", + "mouseWarrior", + "mobileWarrior", + "69", + "upsideDown", + "oneArmedBandit", + "englishMaster", + "feetWarrior", + "wingdings", + "iKiNdAlIkEhOwInEfFiCiEnTqWeRtYiS", + "100hours", + "250hours", + "500hours", + "1000hours", + "ultimateMonkeyFlex", + "oneRoleToRuleThemAll", + "doYouKnowTheDefinitionOfInsanity", + "oneHourChampion", + "fluidChampion", + "accuracyChampion", + "literallyTheFastestPersonHere", + "bananaHoarder", + "alpha", + "blazeIt", + "burstMaster", + "burstGod", + "shotgun", + "nuke", + "orbitalCannon", + "marathonSprinter", + "flawless", + "hesBeginningToBelieve", + "goldenHands", + "fingerBlaster", + "whyAreTheWallsMoving", + "stickman", + "waveDynamics", + "apesTogetherStrong", + "apesTogetherStronger", + "apesTogetherInvincible", + "footBarbarian", + "bigFoot", + "woodPecker", + "mrWorldwide", + "internalMetronome", + "roleCollector", + "roleEnthusiast", + "roleAddict", + "roleOverdose", + "roleZombie", + "roleOverlord", + "roleImp", + ], + { + errorMap: customEnumErrorHandler("Must be a known challenge name"), + }, +); + +export type ChallengeName = z.infer; + export const ChallengeSchema = z .object({ - name: z.string(), + name: ChallengeNameSchema, display: z.string(), autoRole: z.boolean().optional(), + discordRoleId: z.string(), type: z.enum([ "customTime", "customWords", @@ -18,6 +132,7 @@ export const ChallengeSchema = z "accuracy", "funbox", "other", + "hidden", ]), message: z.string().optional(), parameters: z.array( @@ -46,6 +161,18 @@ export const ChallengeSchema = z .partial() .strict() .optional(), + + category: z.enum([ + "other", + "endurance", + "script", + "speed", + "accuracy", + "funbox", + "champions", + "roleCount", + ]), + description: z.string(), }) .strict(); diff --git a/packages/schemas/src/results.ts b/packages/schemas/src/results.ts index fe3b8d594aae..0575644e8dca 100644 --- a/packages/schemas/src/results.ts +++ b/packages/schemas/src/results.ts @@ -10,6 +10,7 @@ import { import { LanguageSchema } from "./languages"; import { Mode, Mode2, Mode2Schema, ModeSchema } from "./shared"; import { DifficultySchema, FunboxSchema } from "./configs"; +import { ChallengeNameSchema } from "./challenges"; export const IncompleteTestSchema = z.object({ acc: PercentageSchema, @@ -136,7 +137,7 @@ export const CompletedEventSchema = ResultBaseSchema.required({ }) .extend({ charTotal: z.number().int().nonnegative(), - challenge: token().max(100).optional(), + challenge: ChallengeNameSchema.optional(), customText: CompletedEventCustomTextSchema.optional(), hash: token().max(100), keyDuration: z.array(z.number().nonnegative()).or(z.literal("toolong")), diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index ab0e6c312115..fec0bb676a2e 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -14,6 +14,7 @@ import { import { CustomThemeColorsSchema, FunboxNameSchema } from "./configs"; import { doesNotContainDisallowedWords } from "./validation/validation"; import { ConnectionSchema } from "./connections"; +import { ChallengeNameSchema } from "./challenges"; export const ResultFilterPresetNameSchema = slug().max(16); @@ -117,6 +118,7 @@ export const UserProfileDetailsSchema = z .strict() .optional(), showActivityOnPublicProfile: z.boolean().optional(), + showChallengesOnPublicProfile: z.boolean().optional(), }) .strict(); export type UserProfileDetails = z.infer; @@ -249,6 +251,14 @@ export const UserNameSchema = doesNotContainDisallowedWords( UserNameWithoutFilterSchema, ); +export const UserChallengesSchema = z.record( + ChallengeNameSchema, + z.object({ + addedAt: z.number().int().nonnegative().optional(), + }), +); +export type UserChallenges = z.infer; + export const UserSchema = z.object({ name: UserNameSchema, email: UserEmailSchema, @@ -284,6 +294,7 @@ export const UserSchema = z.object({ quoteMod: QuoteModSchema.optional(), resultFilterPresets: z.array(ResultFiltersSchema).optional(), testActivity: TestActivitySchema.optional(), + challenges: UserChallengesSchema.optional(), }); export type User = z.infer; @@ -312,6 +323,7 @@ export const UserProfileSchema = UserSchema.pick({ inventory: true, allTimeLbs: true, testActivity: true, + challenges: true, }) .extend({ typingStats: TypingStatsSchema, diff --git a/packages/util/src/objects.ts b/packages/util/src/objects.ts new file mode 100644 index 000000000000..35a295988db7 --- /dev/null +++ b/packages/util/src/objects.ts @@ -0,0 +1,15 @@ +export function typedKeys( + obj: T, +): T extends T ? (keyof T)[] : never { + return Object.keys(obj) as unknown as T extends T ? (keyof T)[] : never; +} + +export function typedEntries( + obj: T, +): { [K in keyof T]: [K, T[K]] }[keyof T][] { + return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]; +} + +export function typedValues(obj: T): T[keyof T][] { + return Object.values(obj) as T[keyof T][]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e970454ffba..fe94e485d080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@date-fns/utc': specifier: 1.2.0 version: 1.2.0 + '@monkeytype/challenges': + specifier: workspace:* + version: link:../packages/challenges '@monkeytype/contracts': specifier: workspace:* version: link:../packages/contracts @@ -288,6 +291,9 @@ importers: '@leonabcd123/modern-caps-lock': specifier: 3.1.3 version: 3.1.3 + '@monkeytype/challenges': + specifier: workspace:* + version: link:../packages/challenges '@monkeytype/contracts': specifier: workspace:* version: link:../packages/contracts @@ -642,6 +648,43 @@ importers: specifier: ^4.1.0 version: 4.1.0(@types/node@24.9.1)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@7.3.2(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/challenges: + dependencies: + '@monkeytype/schemas': + specifier: workspace:* + version: link:../schemas + '@monkeytype/util': + specifier: workspace:* + version: link:../util + devDependencies: + '@monkeytype/tsup-config': + specifier: workspace:* + version: link:../tsup-config + '@monkeytype/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/node': + specifier: 24.9.1 + version: 24.9.1 + madge: + specifier: 8.0.0 + version: 8.0.0(typescript@6.0.2) + oxlint: + specifier: 1.68.0 + version: 1.68.0(oxlint-tsgolint@0.23.0) + oxlint-tsgolint: + specifier: 0.23.0 + version: 0.23.0 + tsup: + specifier: 8.4.0 + version: 8.4.0(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + typescript: + specifier: 6.0.2 + version: 6.0.2 + vitest: + specifier: 4.1.0 + version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/contracts: dependencies: '@monkeytype/schemas': @@ -680,7 +723,7 @@ importers: version: 6.0.2 vitest: specifier: 4.1.0 - version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)) packages/funbox: dependencies: @@ -10521,9 +10564,6 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -10914,10 +10954,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} - engines: {node: '>=18'} - tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -22774,8 +22810,6 @@ snapshots: statuses@2.0.2: {} - std-env@4.0.0: {} - std-env@4.1.0: {} stemmer@2.0.1: {} @@ -23344,8 +23378,6 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.0.2: {} - tinyexec@1.1.2: {} tinyglobby@0.2.13: @@ -23962,12 +23994,12 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: @@ -23992,12 +24024,12 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 7.3.2(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: @@ -24022,12 +24054,12 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: @@ -24051,12 +24083,12 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.70.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: @@ -24080,12 +24112,12 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: