diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 5f44cffe1d41..14024d831a5b 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 @@ -1312,9 +1316,13 @@ describe("UserDal", () => { }); it("should update", async () => { //given - const { uid } = await UserTestData.createUser({ + const { uid, challenges } = await UserTestData.createUser({ discordId: "discordId", discordAvatar: "discordAvatar", + challenges: { + "100hours": {}, + "250hours": { addedAt: Date.now() }, + }, }); //when @@ -1324,6 +1332,36 @@ describe("UserDal", () => { const read = await UserDAL.getUser(uid, "read"); expect(read.discordId).toBeUndefined(); expect(read.discordAvatar).toBeUndefined(); + expect(read.challenges).toEqual(challenges); + }); + }); + + describe("updateChallenge", () => { + it("throws for nonexisting user", async () => { + await expect(async () => + UserDAL.updateChallenge("unknown", "69"), + ).rejects.toThrow("User not found\nStack: update challenge"); + }); + it("should update", async () => { + //given + vi.useFakeTimers(); + const { uid } = await UserTestData.createUser({ + challenges: { + "100hours": {}, + "250hours": { addedAt: 1 }, + }, + }); + + //when + await UserDAL.updateChallenge(uid, "69"); + + //then + const read = await UserDAL.getUser(uid, "read"); + expect(read.challenges).toEqual({ + "100hours": {}, + "250hours": { addedAt: 1 }, + "69": { addedAt: Date.now() }, + }); }); }); describe("updateInbox", () => { diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 516f66549f3b..8557fcd4ab9d 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -5,6 +5,7 @@ import * as ResultDal from "../../../src/dal/result"; import * as UserDal from "../../../src/dal/user"; import * as LogsDal from "../../../src/dal/logs"; import * as PublicDal from "../../../src/dal/public"; +import * as GeorgeQueue from "../../../src/queues/george-queue"; import { ObjectId } from "mongodb"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { enableRateLimitExpects } from "../../__testData__/rate-limit"; @@ -583,6 +584,11 @@ describe("result controller test", () => { const userCheckIfPbMock = vi.spyOn(UserDal, "checkIfPb"); const userIncrementXpMock = vi.spyOn(UserDal, "incrementXp"); const userUpdateTypingStatsMock = vi.spyOn(UserDal, "updateTypingStats"); + const userUpdateChallengeMock = vi.spyOn(UserDal, "updateChallenge"); + const georgeAwardChallengeMock = vi.spyOn( + GeorgeQueue.default, + "awardChallenge", + ); const resultAddMock = vi.spyOn(ResultDal, "addResult"); const publicUpdateStatsMock = vi.spyOn(PublicDal, "updateStats"); @@ -597,6 +603,8 @@ describe("result controller test", () => { userCheckIfPbMock, userIncrementXpMock, userUpdateTypingStatsMock, + userUpdateChallengeMock, + georgeAwardChallengeMock, resultAddMock, publicUpdateStatsMock, ].forEach((it) => it.mockClear()); @@ -605,6 +613,8 @@ describe("result controller test", () => { userUpdateStreakMock.mockResolvedValue(0); userCheckIfTagPbMock.mockResolvedValue([]); userCheckIfPbMock.mockResolvedValue(true); + userUpdateChallengeMock.mockResolvedValue(); + georgeAwardChallengeMock.mockResolvedValue(); resultAddMock.mockResolvedValue({ insertedId }); userIncrementXpMock.mockResolvedValue(); }); @@ -687,6 +697,32 @@ describe("result controller test", () => { 15.1 + 2 - 5, //duration + incompleteTestSeconds-afk ); }); + + it("should add result with challenge", async () => { + //GIVEN + userGetMock.mockClear(); + userGetMock.mockResolvedValue({ + uid, + name: "bob", + discordId: "discordId", + } as any); + + const completedEvent = buildCompletedEvent({ + challenge: "69", + }); + //WHEN + await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: completedEvent, + }) + .expect(200); + + //THEN + expect(userUpdateChallengeMock).toHaveBeenCalledWith(uid, "69"); + expect(georgeAwardChallengeMock).toHaveBeenCalledWith("discordId", "69"); + }); it("should fail if result saving is disabled", async () => { //GIVEN await enableResultsSaving(false); diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 867f050cbfa2..348bab453d77 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,18 +1590,24 @@ 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"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(async () => { + vi.useFakeTimers(); isStateValidForUserMock.mockResolvedValue(true); getUserMock.mockResolvedValue({} as any); getDiscordUserMock.mockResolvedValue({ id: "discordUserId", avatar: "discordUserAvatar", }); + getDiscordRoleIdsMock.mockResolvedValue([ + getChallenge("100hours").discordRoleId, + getChallenge("250hours").discordRoleId, + ]); isDiscordIdAvailableMock.mockResolvedValue(true); blocklistContainsMock.mockResolvedValue(false); userLinkDiscordMock.mockResolvedValue(); @@ -1610,15 +1619,18 @@ describe("user controller test", () => { isStateValidForUserMock, isDiscordIdAvailableMock, getDiscordUserMock, + getDiscordRoleIdsMock, blocklistContainsMock, userLinkDiscordMock, georgeLinkDiscordMock, addImportantLogMock, ].forEach((it) => it.mockClear()); + vi.useRealTimers(); }); it("should link discord", async () => { //GIVEN + getUserMock.mockResolvedValue({} as any); //WHEN @@ -1629,6 +1641,7 @@ describe("user controller test", () => { tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", + scope: ["scopeOne", "scopeTwo"], }) .expect(200); @@ -1653,6 +1666,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 +1679,10 @@ describe("user controller test", () => { uid, "discordUserId", "discordUserAvatar", + { + "100hours": { addedAt: Date.now() }, + "250hours": { addedAt: Date.now() }, + }, ); expect(georgeLinkDiscordMock).toHaveBeenCalledWith( "discordUserId", @@ -1676,7 +1698,10 @@ describe("user controller test", () => { it("should update existing discord avatar", async () => { //GIVEN - getUserMock.mockResolvedValue({ discordId: "existingDiscordId" } as any); + getUserMock.mockResolvedValue({ + discordId: "existingDiscordId", + challenges: { "100hours": { addedAt: 1 } }, + } as any); //WHEN const { body } = await mockApp @@ -1701,6 +1726,7 @@ describe("user controller test", () => { uid, "existingDiscordId", "discordUserAvatar", + { "250hours": { addedAt: Date.now() } }, //only newly added ); expect(isDiscordIdAvailableMock).not.toHaveBeenCalled(); expect(blocklistContainsMock).not.toHaveBeenCalled(); @@ -2962,6 +2988,9 @@ describe("user controller test", () => { testActivity: { "2024": fillYearWithDay(94), }, + challenges: { + "100hours": { addedAt: 1 }, + }, }; beforeEach(async () => { @@ -3033,12 +3062,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 +3086,18 @@ describe("user controller test", () => { testsByDays: expect.arrayContaining([]), }), ); + + expect(body.data.challenges).toEqual({ "100hours": { addedAt: 1 } }); }); 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 +3108,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 +3226,7 @@ describe("user controller test", () => { website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, }; //WHEN @@ -3216,6 +3255,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/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 59396abbd705..12fb3a822f08 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -459,11 +459,12 @@ export async function addResult( if ( completedEvent.challenge !== null && completedEvent.challenge !== undefined && - AutoRoleList.includes(completedEvent.challenge) && - user.discordId !== undefined && - user.discordId !== "" + AutoRoleList.includes(completedEvent.challenge) ) { - void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge); + await UserDAL.updateChallenge(uid, completedEvent.challenge); + if (user.discordId !== undefined && user.discordId !== "") { + void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge); + } } else { delete completedEvent.challenge; } diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index b89a7874515f..6425ac65dca2 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"); @@ -656,6 +668,7 @@ export async function linkDiscord( "banned", "discordId", "lbOptOut", + "challenges", ]); if (userInfo.banned) { throw new MonkeyError(403, "Banned accounts cannot link with Discord"); @@ -664,8 +677,27 @@ export async function linkDiscord( const { id: discordId, avatar: discordAvatar } = await DiscordUtils.getDiscordUser(tokenType, accessToken); + let roles = await DiscordUtils.getDiscordRoleIds( + tokenType, + accessToken, + scope, + ); + + const challenges: UserChallenges = Object.fromEntries( + roles + .map((roleId) => challengeNameByRoleId[roleId]) + .filter((it) => it !== undefined) + .filter((it) => userInfo.challenges?.[it] === undefined) + .map((it) => [it, { addedAt: Date.now() }]), + ); + if (userInfo.discordId !== undefined && userInfo.discordId !== "") { - await UserDAL.linkDiscord(uid, userInfo.discordId, discordAvatar); + await UserDAL.linkDiscord( + uid, + userInfo.discordId, + discordAvatar, + challenges, + ); return new MonkeyResponse("Discord avatar updated", { discordId, discordAvatar, @@ -692,7 +724,7 @@ export async function linkDiscord( throw new MonkeyError(409, "The Discord account is blocked"); } - await UserDAL.linkDiscord(uid, discordId, discordAvatar); + 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 +1038,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 +1058,7 @@ export async function updateProfile( socialProfiles, selectedBadgeId, showActivityOnPublicProfile, + showChallengesOnPublicProfile, } = req.body; const user = await UserDAL.getPartialUser(uid, "update user profile", [ @@ -1048,6 +1088,7 @@ export async function updateProfile( ]), ), showActivityOnPublicProfile, + showChallengesOnPublicProfile, }; await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory); diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index ada92f0ee764..f220db0cb95d 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, @@ -39,6 +40,7 @@ import { Configuration } from "@monkeytype/schemas/configuration"; import { isToday, isYesterday } from "@monkeytype/util/date-and-time"; import GeorgeQueue from "../queues/george-queue"; import { aggregateWithAcceptedConnections } from "./connections"; +import { ChallengeName } from "@monkeytype/schemas/challenges"; export type DBUserTag = WithObjectId; @@ -613,11 +615,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" }); } @@ -630,6 +636,17 @@ export async function unlinkDiscord(uid: string): Promise { ); } +export async function updateChallenge( + uid: string, + challengeName: ChallengeName, +): Promise { + await updateUser( + { uid }, + { $set: { [`challenges.${challengeName}`]: { addedAt: Date.now() } } }, + { stack: "update challenge" }, + ); +} + export async function incrementBananas( uid: string, wpm: number, 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/src/ts/components/modals/EditProfileModal.tsx b/frontend/src/ts/components/modals/EditProfileModal.tsx index 088f8e5ce2de..a94bca825870 100644 --- a/frontend/src/ts/components/modals/EditProfileModal.tsx +++ b/frontend/src/ts/components/modals/EditProfileModal.tsx @@ -42,6 +42,8 @@ export function EditProfile() { showActivityOnPublicProfile: snapshot.details?.showActivityOnPublicProfile ?? true, badgeId: badges.find((b) => b.selected)?.id ?? -1, + showChallengesOnPublicProfile: + snapshot.details?.showChallengesOnPublicProfile ?? true, }, onSubmit: async ({ value }) => { const updates = { @@ -259,6 +261,18 @@ export function EditProfile() { +
+ + + {(field) => ( + + )} + +
+ save 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..609a874725b3 --- /dev/null +++ b/frontend/src/ts/components/pages/profile/Challenges.tsx @@ -0,0 +1,215 @@ +import { + Challenge, + getChallenge, + getRegularChallenges, +} from "@monkeytype/challenges"; +import { ChallengeName } from "@monkeytype/schemas/challenges"; +import { UserChallenges } from "@monkeytype/schemas/users"; +import { typedEntries } from "@monkeytype/util/objects"; +import { format as dateFormat } from "date-fns"; +import { createMemo, For, Show } from "solid-js"; + +import { bp } from "../../../states/breakpoints"; +import { showModal } from "../../../states/modals"; +import { FaSolidIcon } from "../../../types/font-awesome"; +import { cn } from "../../../utils/cn"; +import { AnimatedModal } from "../../common/AnimatedModal"; +import { Balloon } from "../../common/Balloon"; +import { Bar } from "../../common/Bar"; +import { Button } from "../../common/Button"; +import { Fa } from "../../common/Fa"; +import { H2 } from "../../common/Headers"; + +export function Challenges(props: { + isAccountPage?: true; + challenges: UserChallenges | undefined; +}) { + const completedChallenges = createMemo((): Challenge[] => + ( + typedEntries(props.challenges ?? {}) as [ + ChallengeName, + { addedAt?: number | undefined } | undefined, + ][] + ) + .map(([name]) => getChallenge(name)) + .sort((a, b) => + a.initialCount !== b.initialCount + ? a.initialCount - b.initialCount + : a.name.localeCompare(b.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)) + .sort((a, b) => + a.initialCount !== b.initialCount + ? b.initialCount - a.initialCount + : a.name.localeCompare(b.name), + ), + ); + + const maxIcons = createMemo(() => { + const points = bp(); + if (points.lg) return 15; + if (points.md) return 10; + if (points.xs) return 5; + if (points.xxs) return 3; + + return 7; + }); + + const unlockPercentage = () => + (Object.keys(props.challenges ?? {}).length * 100) / + getRegularChallenges().length; + + return ( + + +
+

Challenges

+
+ You've unlocked {Object.keys(props.challenges ?? {}).length}/ + {getRegularChallenges().length} ({Math.round(unlockPercentage())}%) +
+ + + + + {(challenge) => ( + + )} + + + + +

Locked Challenges

+ + +
+
+ ); +} + +function ChallengeItem(props: { + completed: boolean; + challenge: Challenge; + iconOnly?: boolean; + unlocked?: number; +}) { + 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"; + } + }; + + const unlocked = createMemo(() => + props.unlocked !== undefined + ? `\n\nunlocked: ${dateFormat(props.unlocked, "dd MMM yyyy HH:mm")}` + : "", + ); + + return ( + +
+ +
+ +
+

{props.challenge.display}

+

{props.challenge.description}

+
+
+
+ ); +} + +function ChallengesModal(_props: { completed: Challenge[] }) { + return ( + +

+ + ); +} + +function ChallengeIcons(props: { + challenges: Challenge[]; + max: number; + completed: boolean; +}) { + return ( +
+ + {(challenge) => ( + + )} + + props.max}> +
+ + {props.challenges.length - props.max} +
+
+
+ ); +} diff --git a/frontend/src/ts/components/pages/profile/UserDetails.tsx b/frontend/src/ts/components/pages/profile/UserDetails.tsx index 1824eac6ed2f..30d8d80487c5 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: { )}
+ + + + ); diff --git a/frontend/src/ts/components/pages/profile/UserProfile.tsx b/frontend/src/ts/components/pages/profile/UserProfile.tsx index 4e8906a311da..05b364858397 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: { @@ -18,7 +19,7 @@ export function UserProfile(props: { isAccountPage?: true; }): JSXElement { return ( -
+
+ +
); } diff --git a/frontend/src/ts/controllers/url-handler.tsx b/frontend/src/ts/controllers/url-handler.tsx index 3b5c326f7258..9abe54e075ef 100644 --- a/frontend/src/ts/controllers/url-handler.tsx +++ b/frontend/src/ts/controllers/url-handler.tsx @@ -46,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(); 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/states/modals.ts b/frontend/src/ts/states/modals.ts index ad559f52f514..c65303186f3c 100644 --- a/frontend/src/ts/states/modals.ts +++ b/frontend/src/ts/states/modals.ts @@ -30,7 +30,8 @@ export type ModalId = | "AddPresetModal" | "EditPresetModal" | "EditProfile" - | "ViewApeKey"; + | "ViewApeKey" + | "AllChallengesModal"; export type ModalVisibility = { visible: boolean; diff --git a/packages/challenges/src/index.ts b/packages/challenges/src/index.ts index 1c1374e910df..c89085ca685b 100644 --- a/packages/challenges/src/index.ts +++ b/packages/challenges/src/index.ts @@ -14,6 +14,7 @@ export type Challenge = { description: string; isHidden?: boolean; discordRoleId: string; + initialCount: number; //replace this with calculated values after a while category: | "other" | "endurance" @@ -23,7 +24,7 @@ export type Challenge = { | "funbox" | "champions" | "roleCount"; - settings: ChallengeSettings; + settings?: ChallengeSettings; }; type ChallengeParameter = @@ -82,6 +83,7 @@ const challenges: Record> = { "69": { display: "6969696969", discordRoleId: "749505965174292511", + initialCount: 12, category: "other", description: "Complete a 69-second test and achieve 69 WPM, 69 raw, 69% accuracy, and 69% consistency.", @@ -102,6 +104,7 @@ const challenges: Record> = { oneHourWarrior: { display: "One Hour Warrior", discordRoleId: "728371749737201855", + initialCount: 794, category: "endurance", description: "Complete a one-hour test.", settings: { @@ -114,6 +117,7 @@ const challenges: Record> = { doubleDown: { display: "Double Down", discordRoleId: "732008008514535544", + initialCount: 130, category: "endurance", description: "Complete a two-hour test.", settings: { @@ -126,6 +130,7 @@ const challenges: Record> = { tripleTrouble: { display: "Triple Trouble", discordRoleId: "732008047618293762", + initialCount: 57, category: "endurance", description: "Complete a three-hour test.", settings: { @@ -138,6 +143,7 @@ const challenges: Record> = { quad: { display: "Quaaaaad", discordRoleId: "736215666352455801", + initialCount: 32, category: "endurance", description: "Complete a four-hour test.", settings: { @@ -150,6 +156,7 @@ const challenges: Record> = { "8Ball": { display: "8 Ball", discordRoleId: "736528159956271126", + initialCount: 8, category: "endurance", description: "Complete an eight-hour test.", settings: { @@ -161,6 +168,7 @@ const challenges: Record> = { theBig12: { display: "The Big 12", discordRoleId: "740532256388546581", + initialCount: 10, category: "endurance", description: "Complete a twelve-hour test.", settings: { @@ -172,6 +180,7 @@ const challenges: Record> = { "1Day": { display: "1 Day", discordRoleId: "751801958511149057", + initialCount: 4, category: "endurance", description: "Complete a twenty-four-hour test.", settings: { @@ -183,6 +192,7 @@ const challenges: Record> = { trueSimp: { display: "True Simp", discordRoleId: "744328648211038359", + initialCount: 49, category: "script", description: "Type miodec ten thousand times.", settings: { @@ -200,6 +210,7 @@ const challenges: Record> = { bigramSalad: { display: "Bigram Salad", discordRoleId: "818535054145093652", + initialCount: 764, 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.", @@ -219,6 +230,7 @@ const challenges: Record> = { simp: { display: "Simp", discordRoleId: "743854992699687023", + initialCount: 546, category: "script", description: "Type miodec one thousand times.", settings: { @@ -236,6 +248,7 @@ const challenges: Record> = { simpLord: { display: "Simp Lord", discordRoleId: "984911956949479445", + initialCount: 5, category: "script", description: "Type miodec one hundred thousand times.", settings: { @@ -252,6 +265,7 @@ const challenges: Record> = { antidiseWhat: { display: "Antidise-what?", discordRoleId: "782006507360616449", + initialCount: 106, category: "script", description: "Get at least 200 wpm typing antidisestablishmentarianism.", settings: { @@ -270,6 +284,7 @@ const challenges: Record> = { whatsThisWebsiteCalledAgain: { display: "What's this website called again?", discordRoleId: "739276161603076116", + initialCount: 284, category: "script", description: "Type monkeytype one thousand times.", settings: { @@ -287,6 +302,7 @@ const challenges: Record> = { developd: { display: "Develop'd", discordRoleId: "735964917877964932", + initialCount: 511, category: "script", description: "Type develop one thousand times.", settings: { @@ -304,6 +320,7 @@ const challenges: Record> = { slowAndSteady: { display: "Slow and Steady", discordRoleId: "782005061935956008", + initialCount: 45, category: "speed", description: "Complete a 5-minute test with exactly 60 WPM without using the live WPM or pace caret.", @@ -320,6 +337,7 @@ const challenges: Record> = { speedSpacer: { display: "Speed Spacer", discordRoleId: "755244049446731856", + initialCount: 79, 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.", @@ -339,6 +357,7 @@ const challenges: Record> = { iveGotThePower: { display: "I've got the POWER", discordRoleId: "764879734873915402", + initialCount: 197, category: "speed", description: "Get 400 WPM while typing power 10 times.", settings: { @@ -357,6 +376,7 @@ const challenges: Record> = { accuracyExpert: { display: "Accuracy Expert", discordRoleId: "751168451263070259", + initialCount: 23, category: "accuracy", description: "Complete a 10-minute Master mode test.", settings: { @@ -374,6 +394,7 @@ const challenges: Record> = { accuracyMaster: { display: "Accuracy Master", discordRoleId: "751168567432708239", + initialCount: 6, category: "accuracy", description: "Complete a 20-minute Master mode test.", settings: { @@ -391,6 +412,7 @@ const challenges: Record> = { accuracyGod: { display: "Accuracy God", discordRoleId: "751168657626890361", + initialCount: 5, category: "accuracy", description: "Complete a 30-minute Master mode test.", settings: { @@ -408,6 +430,7 @@ const challenges: Record> = { inAGalaxyFarFarAway: { display: "In a galaxy far, far away", discordRoleId: "740004324301602907", + initialCount: 8, category: "script", description: "Type out the entire Star Wars Episode 4 script with punctuation while watching the movie simultaneously.", @@ -420,6 +443,7 @@ const challenges: Record> = { beepBoop: { display: "Beep Boop", discordRoleId: "813076265145729024", + initialCount: 226, category: "script", description: "Type the beepboop script with 100% accuracy and at least 45 WPM.", @@ -437,6 +461,7 @@ const challenges: Record> = { whosYourDaddy: { display: "Who's your daddy?", discordRoleId: "742171915405361204", + initialCount: 9, category: "script", description: "Type out the entire Star Wars Episode 5 script with punctuation while watching the movie simultaneously.", @@ -449,6 +474,7 @@ const challenges: Record> = { itsATrap: { display: "It's a trap!!", discordRoleId: "744325174668820550", + initialCount: 14, category: "script", description: "Type out the entire Star Wars Episode 6 script with punctuation while watching the movie simultaneously.", @@ -461,6 +487,7 @@ const challenges: Record> = { jolly: { display: "Jolly", discordRoleId: "768497412548329563", + initialCount: 180, category: "script", description: "Type the Jolly script with a minimum of 70 wpm.", settings: { @@ -474,6 +501,7 @@ const challenges: Record> = { gottaCatchEmAll: { display: "Gotta catch 'em all", discordRoleId: "767069340599975998", + initialCount: 473, category: "script", description: "Type out the names of all Pokemon.", settings: { @@ -485,6 +513,7 @@ const challenges: Record> = { rapGod: { display: "Rap God", discordRoleId: "743844891045396603", + initialCount: 281, category: "script", description: "Type out the lyrics of Eminem's Rap God at a minimum of 85 WPM and 90% accuracy, including punctuation.", @@ -499,6 +528,7 @@ const challenges: Record> = { navySeal: { display: "Navy Seal", discordRoleId: "762345535969165342", + initialCount: 88, category: "script", description: "Type out the Navy Seal copy pasta with 100% accuracy and minimum 60 WPM.", @@ -513,6 +543,7 @@ const challenges: Record> = { littleChef: { display: "Little Chef", discordRoleId: "763544714028122153", + initialCount: 13, category: "script", description: "Type out the entire Ratatouille script while watching the movie simultaneously.", @@ -521,6 +552,7 @@ const challenges: Record> = { crosstalk: { display: "(CROSSTALK)", discordRoleId: "761276009664217129", + initialCount: 14, category: "script", description: "Type out the entire transcript of the first 2020 Presidential Debate.", @@ -529,6 +561,7 @@ const challenges: Record> = { bees: { display: "Bees!!!", discordRoleId: "739636003182084307", + initialCount: 22, category: "script", description: "Type out the entire Bee Movie script while watching the movie simultaneously.", @@ -537,6 +570,7 @@ const challenges: Record> = { getOffMySwamp: { display: "Get off my swamp", discordRoleId: "757346966987342026", + initialCount: 14, category: "script", description: "Type out the entire Shrek script with punctuation while watching the movie simultaneously.", @@ -545,6 +579,7 @@ const challenges: Record> = { lookAtMeIAmTheDeveloperNow: { display: "Look at me. I am the developer now.", discordRoleId: "937358772635074600", + initialCount: 4, category: "script", description: "Type out the entire source code ofMonkeytype, as it was in February 2022.", @@ -557,6 +592,7 @@ const challenges: Record> = { beLikeWater: { display: "Be like water", discordRoleId: "740568679485276201", + initialCount: 44, 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).", @@ -569,6 +605,7 @@ const challenges: Record> = { rollercoaster: { display: "Rollercoaster", discordRoleId: "736032495526740001", + initialCount: 45, category: "funbox", description: "Complete at least a one-hour test using the round round baby mode.", @@ -585,6 +622,7 @@ const challenges: Record> = { oneHourMirror: { display: "ɿoɿɿim ɿυoʜ ɘno", discordRoleId: "737385182998429757", + initialCount: 41, category: "funbox", description: "Complete at least a one-hour test using the mirror mode.", settings: { @@ -597,6 +635,7 @@ const challenges: Record> = { chooChoo: { display: "Choo choo", discordRoleId: "739306439574683710", + initialCount: 60, category: "funbox", description: "Complete at least a one-hour test using choo choo mode.", settings: { @@ -609,6 +648,7 @@ const challenges: Record> = { mnemonist: { display: "Mnemonist", discordRoleId: "782005606852067328", + initialCount: 98, category: "funbox", description: "Achieve 100+ WPM with 100% accuracy on a 25-word test using the memory funbox.", @@ -626,6 +666,7 @@ const challenges: Record> = { earfquake: { display: "Earfquake", discordRoleId: "740730587429601291", + initialCount: 86, category: "funbox", description: "Complete at least a one-hour test using the earthquake funbox mode.", @@ -639,6 +680,7 @@ const challenges: Record> = { simonSez: { display: "Simon Sez", discordRoleId: "742128871825997914", + initialCount: 33, category: "funbox", description: "Complete at least a one-hour test using the simon says funbox mode.", @@ -652,6 +694,7 @@ const challenges: Record> = { accountant: { display: "Accountant", discordRoleId: "743962178821816391", + initialCount: 50, category: "funbox", description: "Complete at least a one-hour test using the 58008 funbox mode.", @@ -665,6 +708,7 @@ const challenges: Record> = { hidden: { display: "Hidden", discordRoleId: "782006137742557194", + initialCount: 435, category: "funbox", description: "Achieve 100+ WPM using the read ahead funbox on a 60-second test.", @@ -683,6 +727,7 @@ const challenges: Record> = { iCanSeeTheFuture: { display: "I can see the future", discordRoleId: "814877508008411226", + initialCount: 86, category: "funbox", description: "Achieve 100+ WPM using the read ahead hard funbox on a 60-second test.", @@ -701,6 +746,7 @@ const challenges: Record> = { whatAreWordsAtThisPoint: { display: "What are words at this point?", discordRoleId: "744209241396740176", + initialCount: 55, category: "funbox", description: "Complete at least a one-hour test using the gibberish funbox mode.", @@ -714,6 +760,7 @@ const challenges: Record> = { specials: { display: "Specials", discordRoleId: "744209452714033162", + initialCount: 15, category: "funbox", description: "Complete at least a one-hour test using the specials funbox mode.", @@ -727,6 +774,7 @@ const challenges: Record> = { aeiou: { display: "Aeiou.", discordRoleId: "744318102766092362", + initialCount: 25, category: "funbox", description: "Complete at least a one-hour test using the tts funbox mode.", settings: { @@ -739,6 +787,7 @@ const challenges: Record> = { asciiWarrior: { display: "ASCII warrior", discordRoleId: "746142791326760980", + initialCount: 27, category: "funbox", description: "Complete at least a one-hour test using the ascii funbox mode.", @@ -752,6 +801,7 @@ const challenges: Record> = { iKiNdAlIkEhOwInEfFiCiEnTqWeRtYiS: { display: "i KINda LikE HoW inEFFICIeNt QwErtY Is.", discordRoleId: "760999194525171724", + initialCount: 31, category: "funbox", description: "Complete at least a one-hour test using the randomcase funbox mode.", @@ -765,6 +815,7 @@ const challenges: Record> = { oneNauseousMonkey: { display: "One Nauseous Monkey", discordRoleId: "760930262740631633", + initialCount: 69, category: "funbox", description: "Complete at least a one-hour test using the nausea funbox mode.", @@ -778,6 +829,7 @@ const challenges: Record> = { thumbWarrior: { display: "Thumb warrior", discordRoleId: "761794585109200906", + initialCount: 12, category: "other", description: "Complete a one-hour test using only your thumbs.", settings: { type: "customTime", parameters: { time: 3600 } }, @@ -785,6 +837,7 @@ const challenges: Record> = { mouseWarrior: { display: "Mouse warrior", discordRoleId: "744580294442614790", + initialCount: 21, category: "other", description: "Complete a one-hour test using only the on-screen keyboard. Funbox modes are not allowed.", @@ -793,6 +846,7 @@ const challenges: Record> = { mobileWarrior: { display: "Mobile warrior", discordRoleId: "744723801526370407", + initialCount: 56, category: "other", description: "Complete a one-hour test on mobile.", settings: { type: "customTime", parameters: { time: 3600 } }, @@ -800,6 +854,7 @@ const challenges: Record> = { upsideDown: { display: "uʍop ǝpᴉsdn", discordRoleId: "782725716114014237", + initialCount: 5, category: "other", description: "Achieve at least 60 WPM on a one-minute test with your keyboard upside down.", @@ -808,6 +863,7 @@ const challenges: Record> = { oneArmedBandit: { display: "One armed bandit", discordRoleId: "765919192557682708", + initialCount: 21, 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.", @@ -816,6 +872,7 @@ const challenges: Record> = { englishMaster: { display: "English master", discordRoleId: "751166528824672396", + initialCount: 114, category: "other", description: "Complete a one-hour test using English 10k language with punctuation and numbers enabled.", @@ -832,6 +889,7 @@ const challenges: Record> = { feetWarrior: { display: "Feet warrior", discordRoleId: "751953592860147822", + initialCount: 6, category: "other", description: "Complete a one-hour test using your feet. Don't ask me why.", settings: { type: "customTime", parameters: { time: 3600 } }, @@ -839,6 +897,7 @@ const challenges: Record> = { wingdings: { display: "Ten Words of Pain", discordRoleId: "863192575984140338", + initialCount: 48, category: "other", description: "Complete a 10-word Master mode test using the Wingdings custom font.", @@ -849,6 +908,327 @@ const challenges: Record> = { requirements: { acc: { exact: 100 } }, }, }, + "100hours": { + display: "100 hours", + discordRoleId: "761766710704603166", + initialCount: 100, + category: "other", + description: "Achieve 100 hours of typing.", + }, + "250hours": { + display: "250 hours", + discordRoleId: "799825381733433344", + initialCount: 32, + category: "other", + description: "Achieve 250 hours of typing.", + }, + "500hours": { + display: "500 hours", + discordRoleId: "951861792622125106", + initialCount: 8, + category: "other", + description: "Achieve 500 hours of typing.", + }, + "1000hours": { + display: "1000 hours", + discordRoleId: "1262175323588395100", + initialCount: 3, + category: "other", + description: "Achieve 1000 hours of typing.", + }, + ultimateMonkeyFlex: { + display: "Ultimate Monkey Flex", + isHidden: true, + discordRoleId: "768497815496032266", + initialCount: 1, + category: "champions", + description: "Have the most champion roles in the server.", + }, + oneRoleToRuleThemAll: { + display: "One role to rule them all", + isHidden: true, + discordRoleId: "758784729151176755", + initialCount: 1, + category: "champions", + description: "Have the most challenge roles in the server.", + }, + doYouKnowTheDefinitionOfInsanity: { + display: "Do You Know The Definition Of Insanity", + isHidden: true, + discordRoleId: "736527448757370880", + initialCount: 1, + category: "champions", + description: "Complete the longest typing session in Monkeytype history.", + }, + oneHourChampion: { + display: "One Hour Champion", + isHidden: true, + discordRoleId: "728650773503934464", + initialCount: 1, + category: "champions", + description: "Achieve the highest WPM in a one-hour test.", + }, + fluidChampion: { + display: "Fluid Champion", + isHidden: true, + discordRoleId: "740568718719058041", + initialCount: 1, + category: "champions", + description: "Achieve the highest WPM in a 60-second layoutfluid test.", + }, + accuracyChampion: { + display: "Accuracy Champion", + isHidden: true, + discordRoleId: "768499906511110235", + initialCount: 1, + category: "champions", + description: "Achieve the longest Master mode test.", + }, + literallyTheFastestPersonHere: { + display: "Literally The Fastest Person Here", + isHidden: true, + discordRoleId: "984922187385405460", + initialCount: 1, + category: "champions", + description: + "Achieve 1st place on the time 60 English all-time leaderboard.", + }, + bananaHoarder: { + display: "Banana Hoarder", + isHidden: true, + discordRoleId: "773590599227932754", + initialCount: 1, + category: "champions", + description: "Achieve 1st place on the banana leaderboard.", + }, + alpha: { + display: "A l p h a", + discordRoleId: "773590612762034176", + initialCount: 10, + category: "speed", + description: + "Type 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", + discordRoleId: "803650889461006346", + initialCount: 110, + category: "speed", + description: "Achieve 420 WPM (can be rounded) by typing weed.", + }, + burstMaster: { + display: "Burst Master", + discordRoleId: "757330922726096917", + initialCount: 791, + category: "speed", + description: "Achieve 200+ WPM on the words 10 mode.", + }, + burstGod: { + display: "Burst God", + discordRoleId: "757330992821305366", + initialCount: 186, + category: "speed", + description: "Achieve 250+ WPM on the words 10 mode.", + }, + shotgun: { + display: "Shotgun", + discordRoleId: "757331084366184539", + initialCount: 39, + category: "speed", + description: "Achieve 300+ WPM on the words 10 mode.", + }, + nuke: { + display: "Nuke", + discordRoleId: "912522664604758016", + initialCount: 11, + category: "speed", + description: "Achieve 350+ WPM on the words 10 mode.", + }, + orbitalCannon: { + display: "Orbital Cannon", + discordRoleId: "1084094136199684196", + initialCount: 2, + category: "speed", + description: "Achieve 400+ WPM on the words 10 mode.", + }, + marathonSprinter: { + display: "Marathon Sprinter", + discordRoleId: "878715678830510111", + initialCount: 5, + category: "speed", + description: "Achieve 200+ WPM on a one-hour test.", + }, + flawless: { + display: "Flawless", + discordRoleId: "767070815987695637", + initialCount: 45, + 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", + discordRoleId: "979729541096431688", + initialCount: 96, + category: "accuracy", + description: + "Achieve 100% accuracy in a 2-minute test under specified settings.", + }, + goldenHands: { + display: "Golden Hands", + discordRoleId: "851096860969795684", + initialCount: 2, + category: "accuracy", + description: "Complete a 1-hour Master mode test.", + }, + fingerBlaster: { + display: "Finger Blaster", + discordRoleId: "787509606992969728", + initialCount: 7, + category: "other", + description: + "Achieve at least 60 WPM using one finger on a 60-second test.", + }, + whyAreTheWallsMoving: { + display: "Why are the walls moving?", + discordRoleId: "910078947302191114", + initialCount: 41, + category: "other", + description: "Complete a one-hour test using tape mode and letter mode.", + }, + stickman: { + display: "stickman", + discordRoleId: "788107449151651890", + initialCount: 15, + category: "other", + description: + "Complete a one-hour test using chopsticks/pencils/pens (you get the idea) with both hands.", + }, + waveDynamics: { + display: "Wave Dynamics", + discordRoleId: "1443311363794407586", + initialCount: 8, + 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", + discordRoleId: "863193901153779713", + initialCount: 55, + category: "other", + description: + "Complete a one-hour test in a Tribe lobby with at least 10 players.", + }, + apesTogetherStronger: { + display: "Apes Together Stronger", + discordRoleId: "898964842726195220", + initialCount: 29, + category: "other", + description: + "Complete a two-hour test in a Tribe lobby with at least 10 players.", + }, + apesTogetherInvincible: { + display: "Apes Together Invincible", + discordRoleId: "1367559768746758194", + initialCount: 15, + category: "other", + description: + "Complete a three-hour test in a Tribe lobby with at least 10 players.", + }, + footBarbarian: { + display: "Foot Barbarian", + initialCount: 3, + discordRoleId: "1025814170962231336", + category: "other", + description: "Complete a two-hour test using your feet.", + }, + bigFoot: { + display: "Big Foot", + discordRoleId: "1030531753082900610", + initialCount: 2, + category: "other", + description: "Complete a three-hour test using your feet.", + }, + woodPecker: { + display: "Wood Pecker", + discordRoleId: "753724531666845830", + initialCount: 18, + category: "other", + description: "Complete a 200-word test using only your nose.", + }, + mrWorldwide: { + display: "Mr Worldwide", + discordRoleId: "762345904279519292", + initialCount: 74, + 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", + discordRoleId: "934067904884916234", + initialCount: 91, + 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", + discordRoleId: "739306809554108520", + initialCount: 150, + category: "roleCount", + description: "Collect 10 roles.", + }, + roleEnthusiast: { + display: "Role Enthusiast", + discordRoleId: "753360663656529931", + initialCount: 43, + category: "roleCount", + description: "Collect 20 roles.", + }, + roleAddict: { + display: "Role Addict", + discordRoleId: "758783172833443850", + initialCount: 16, + category: "roleCount", + description: "Collect 30 roles.", + }, + roleOverdose: { + display: "Role Overdose", + discordRoleId: "758783365930811423", + initialCount: 12, + category: "roleCount", + description: "Collect 40 roles.", + }, + roleZombie: { + display: "Role Zombie", + discordRoleId: "762701731993616405", + initialCount: 4, + category: "roleCount", + description: "Collect 50 roles.", + }, + roleOverlord: { + display: "Role Overlord", + discordRoleId: "805519411502514187", + initialCount: 3, + category: "roleCount", + description: "Collect 60 roles.", + }, + roleImp: { + display: "Role Imp", + discordRoleId: "906565521271558214", + initialCount: 2, + category: "roleCount", + description: "Collect 70 roles.", + }, + fiftyShadesOfHell: { + display: "50 Shades of Hell", + discordRoleId: "751802155119280128", + initialCount: 71, + category: "script", + description: "Type out your favourite chapter from 50 Shades of Gray.", + }, }; const map: Record = Object.fromEntries( 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 005ac9fe71a5..3dfe9536423b 100644 --- a/packages/schemas/src/challenges.ts +++ b/packages/schemas/src/challenges.ts @@ -61,6 +61,49 @@ export const ChallengeNameSchema = z.enum( "englishMaster", "feetWarrior", "wingdings", + "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", + "fiftyShadesOfHell", ], { errorMap: customEnumErrorHandler("Must be a known challenge name"), 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,