From 2ccd4b6ed348763f4fa7278bb5ee8e4bad14d753 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 25 Jun 2026 11:38:34 +0200 Subject: [PATCH 1/8] update challenge on addResult --- .../__integration__/dal/user.spec.ts | 29 +++++++++++++++ .../__tests__/api/controllers/result.spec.ts | 36 +++++++++++++++++++ backend/src/api/controllers/result.ts | 9 ++--- backend/src/dal/user.ts | 12 +++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 1126650fefef..894f10b6fd10 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -1335,6 +1335,35 @@ describe("UserDal", () => { expect(read.challenges).toBeUndefined(); }); }); + + 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", () => { it("claims rewards on read", async () => { //GIVEN 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/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/dal/user.ts b/backend/src/dal/user.ts index a862ad26e50c..1c4a6c7462a6 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -40,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; @@ -635,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, From 5bc379b5f681742b85b6ba86224551dde707882a Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 28 Jun 2026 11:07:51 +0200 Subject: [PATCH 2/8] missing challenges --- .../__integration__/dal/user.spec.ts | 13 +- backend/src/api/controllers/user.ts | 42 ++- backend/src/dal/user.ts | 7 +- backend/src/utils/discord.ts | 63 +++- .../ts/components/modals/EditProfileModal.tsx | 14 + .../components/pages/profile/Challenges.tsx | 123 +++++++ .../components/pages/profile/UserDetails.tsx | 20 +- .../components/pages/profile/UserProfile.tsx | 6 + frontend/src/ts/controllers/url-handler.tsx | 3 +- frontend/src/ts/db.ts | 1 + packages/challenges/src/index.ts | 315 +++++++++++++++++- packages/contracts/src/users.ts | 10 + packages/schemas/src/challenges.ts | 43 +++ packages/schemas/src/users.ts | 12 + 14 files changed, 649 insertions(+), 23 deletions(-) create mode 100644 frontend/src/ts/components/pages/profile/Challenges.tsx diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 98ca0af12813..894f10b6fd10 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(); }); }); 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/dal/user.ts b/backend/src/dal/user.ts index 2cd8cd905c79..1c4a6c7462a6 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, @@ -614,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" }); } @@ -626,7 +631,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/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..bb7bc34c01ec --- /dev/null +++ b/frontend/src/ts/components/pages/profile/Challenges.tsx @@ -0,0 +1,123 @@ +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 { 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/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/packages/challenges/src/index.ts b/packages/challenges/src/index.ts index 1c1374e910df..e13298ba96f5 100644 --- a/packages/challenges/src/index.ts +++ b/packages/challenges/src/index.ts @@ -23,7 +23,7 @@ export type Challenge = { | "funbox" | "champions" | "roleCount"; - settings: ChallengeSettings; + settings?: ChallengeSettings; }; type ChallengeParameter = @@ -849,6 +849,319 @@ const challenges: Record> = { requirements: { acc: { exact: 100 } }, }, }, + "100hours": { + display: "100 hours", + isHidden: true, + discordRoleId: "761766710704603166", + category: "other", + description: "Achieve 100 hours of typing.", + }, + "250hours": { + display: "250 hours", + isHidden: true, + discordRoleId: "799825381733433344", + category: "other", + description: "Achieve 250 hours of typing.", + }, + "500hours": { + display: "500 hours", + isHidden: true, + discordRoleId: "951861792622125106", + category: "other", + description: "Achieve 500 hours of typing.", + }, + "1000hours": { + display: "1000 hours", + isHidden: true, + discordRoleId: "1262175323588395100", + category: "other", + description: "Achieve 1000 hours of typing.", + }, + ultimateMonkeyFlex: { + display: "Ultimate Monkey Flex", + isHidden: true, + discordRoleId: "768497815496032266", + category: "champions", + description: "Have the most champion roles in the server.", + }, + oneRoleToRuleThemAll: { + display: "One role to rule them all", + isHidden: true, + discordRoleId: "758784729151176755", + category: "champions", + description: "Have the most challenge roles in the server.", + }, + doYouKnowTheDefinitionOfInsanity: { + display: "Do You Know The Definition Of Insanity", + isHidden: true, + discordRoleId: "736527448757370880", + category: "champions", + description: "Complete the longest typing session in Monkeytype history.", + }, + oneHourChampion: { + display: "One Hour Champion", + isHidden: true, + discordRoleId: "728650773503934464", + category: "champions", + description: "Achieve the highest WPM in a one-hour test.", + }, + fluidChampion: { + display: "Fluid Champion", + isHidden: true, + discordRoleId: "740568718719058041", + category: "champions", + description: "Achieve the highest WPM in a 60-second layoutfluid test.", + }, + accuracyChampion: { + display: "Accuracy Champion", + isHidden: true, + discordRoleId: "768499906511110235", + category: "champions", + description: "Achieve the longest Master mode test.", + }, + literallyTheFastestPersonHere: { + display: "Literally The Fastest Person Here", + isHidden: true, + discordRoleId: "984922187385405460", + category: "champions", + description: + "Achieve 1st place on the time 60 English all-time leaderboard.", + }, + bananaHoarder: { + display: "Banana Hoarder", + isHidden: true, + discordRoleId: "773590599227932754", + category: "champions", + description: "Achieve 1st place on the banana leaderboard.", + }, + alpha: { + display: "A l p h a", + isHidden: true, + discordRoleId: "773590612762034176", + 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", + isHidden: true, + discordRoleId: "803650889461006346", + category: "speed", + description: "Achieve 420 WPM (can be rounded) by typing weed.", + }, + burstMaster: { + display: "Burst Master", + isHidden: true, + discordRoleId: "757330922726096917", + category: "speed", + description: "Achieve 200+ WPM on the words 10 mode.", + }, + burstGod: { + display: "Burst God", + isHidden: true, + discordRoleId: "757330992821305366", + category: "speed", + description: "Achieve 250+ WPM on the words 10 mode.", + }, + shotgun: { + display: "Shotgun", + isHidden: true, + discordRoleId: "757331084366184539", + category: "speed", + description: "Achieve 300+ WPM on the words 10 mode.", + }, + nuke: { + display: "Nuke", + isHidden: true, + discordRoleId: "912522664604758016", + category: "speed", + description: "Achieve 350+ WPM on the words 10 mode.", + }, + orbitalCannon: { + display: "Orbital Cannon", + isHidden: true, + discordRoleId: "1084094136199684196", + category: "speed", + description: "Achieve 400+ WPM on the words 10 mode.", + }, + marathonSprinter: { + display: "Marathon Sprinter", + isHidden: true, + discordRoleId: "878715678830510111", + category: "speed", + description: "Achieve 200+ WPM on a one-hour test.", + }, + flawless: { + display: "Flawless", + isHidden: true, + 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", + isHidden: true, + discordRoleId: "979729541096431688", + category: "accuracy", + description: + "Achieve 100% accuracy in a 2-minute test under specified settings.", + }, + goldenHands: { + display: "Golden Hands", + isHidden: true, + discordRoleId: "851096860969795684", + category: "accuracy", + description: "Complete a 1-hour Master mode test.", + }, + fingerBlaster: { + display: "Finger Blaster", + isHidden: true, + 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?", + isHidden: true, + discordRoleId: "910078947302191114", + category: "other", + description: "Complete a one-hour test using tape mode and letter mode.", + }, + stickman: { + display: "stickman", + isHidden: true, + 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", + isHidden: true, + 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", + isHidden: true, + discordRoleId: "863193901153779713", + category: "other", + description: + "Complete a one-hour test in a Tribe lobby with at least 10 players.", + }, + apesTogetherStronger: { + display: "Apes Together Stronger", + isHidden: true, + discordRoleId: "898964842726195220", + category: "other", + description: + "Complete a two-hour test in a Tribe lobby with at least 10 players.", + }, + apesTogetherInvincible: { + display: "Apes Together Invincible", + isHidden: true, + discordRoleId: "1367559768746758194", + category: "other", + description: + "Complete a three-hour test in a Tribe lobby with at least 10 players.", + }, + footBarbarian: { + display: "Foot Barbarian", + isHidden: true, + discordRoleId: "1025814170962231336", + category: "other", + description: "Complete a two-hour test using your feet.", + }, + bigFoot: { + display: "Big Foot", + isHidden: true, + discordRoleId: "1030531753082900610", + category: "other", + description: "Complete a three-hour test using your feet.", + }, + woodPecker: { + display: "Wood Pecker", + isHidden: true, + discordRoleId: "753724531666845830", + category: "other", + description: "Complete a 200-word test using only your nose.", + }, + mrWorldwide: { + display: "Mr Worldwide", + isHidden: true, + 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", + isHidden: true, + 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", + isHidden: true, + discordRoleId: "739306809554108520", + category: "roleCount", + description: "Collect 10 roles.", + }, + roleEnthusiast: { + display: "Role Enthusiast", + isHidden: true, + discordRoleId: "753360663656529931", + category: "roleCount", + description: "Collect 20 roles.", + }, + roleAddict: { + display: "Role Addict", + isHidden: true, + discordRoleId: "758783172833443850", + category: "roleCount", + description: "Collect 30 roles.", + }, + roleOverdose: { + display: "Role Overdose", + isHidden: true, + discordRoleId: "758783365930811423", + category: "roleCount", + description: "Collect 40 roles.", + }, + roleZombie: { + display: "Role Zombie", + isHidden: true, + discordRoleId: "762701731993616405", + category: "roleCount", + description: "Collect 50 roles.", + }, + roleOverlord: { + display: "Role Overlord", + isHidden: true, + discordRoleId: "805519411502514187", + category: "roleCount", + description: "Collect 60 roles.", + }, + roleImp: { + display: "Role Imp", + isHidden: true, + discordRoleId: "906565521271558214", + category: "roleCount", + description: "Collect 70 roles.", + }, + fiftyShadesOfHell: { + display: "50 Shades of Hell", + isHidden: true, + discordRoleId: "751802155119280128", + 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, From 241f4c6328b5fe4cc8917eafbbb1ab02d8d4fff8 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 28 Jun 2026 13:44:59 +0200 Subject: [PATCH 3/8] ui --- .../components/pages/profile/Challenges.tsx | 134 ++++++++++++++---- .../components/pages/profile/UserDetails.tsx | 2 +- .../components/pages/profile/UserProfile.tsx | 2 +- frontend/src/ts/states/modals.ts | 3 +- 4 files changed, 112 insertions(+), 29 deletions(-) diff --git a/frontend/src/ts/components/pages/profile/Challenges.tsx b/frontend/src/ts/components/pages/profile/Challenges.tsx index bb7bc34c01ec..f95c95463b2e 100644 --- a/frontend/src/ts/components/pages/profile/Challenges.tsx +++ b/frontend/src/ts/components/pages/profile/Challenges.tsx @@ -6,11 +6,18 @@ import { 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 { 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"; function sortNewestFirst( a: [ChallengeName, { addedAt?: number | undefined } | undefined], @@ -48,37 +55,65 @@ export function Challenges(props: { getRegularChallenges().filter((it) => !completedNames().has(it.name)), ); + const numIcons = () => 10; + + const unlockPercentage = () => + (Object.keys(props.challenges ?? {}).length * 100) / + getRegularChallenges().length; + return ( -
-
-

Challenges

-
- {Object.keys(props.challenges ?? {}).length} /{" "} - {getRegularChallenges().length} completed -
+ +
+

Challenges

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

Locked Challenges

+ + +
); } -function ChallengeItem(props: { completed: boolean; challenge: Challenge }) { +function ChallengeItem(props: { + completed: boolean; + challenge: Challenge; + iconOnly?: boolean; + unlocked?: number; +}) { const icon = (): FaSolidIcon => { switch (props.challenge.category) { case "accuracy": @@ -98,26 +133,73 @@ function ChallengeItem(props: { completed: boolean; challenge: Challenge }) { 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}

-
+ +
+

{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 8e7ad0924b64..30d8d80487c5 100644 --- a/frontend/src/ts/components/pages/profile/UserDetails.tsx +++ b/frontend/src/ts/components/pages/profile/UserDetails.tsx @@ -473,7 +473,7 @@ function TypingStats(props: {

-
+