From c5d43dd67338620bc4438a931351e050be257b3f Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 15 Aug 2025 14:31:58 +0200 Subject: [PATCH] feat(profile): optionally include test activity on users public profile (@fehmer) (#6824) Co-authored-by: Miodec --- .../__tests__/api/controllers/user.spec.ts | 46 +++++++++++++++ backend/src/api/controllers/user.ts | 14 ++++- frontend/src/html/pages/account.html | 2 +- frontend/src/html/pages/profile.html | 22 ++++++++ frontend/src/html/popups.html | 11 ++++ frontend/src/styles/account.scss | 2 +- frontend/src/styles/media-queries-blue.scss | 2 +- frontend/src/styles/media-queries-brown.scss | 2 +- frontend/src/styles/media-queries-green.scss | 2 +- frontend/src/styles/media-queries-orange.scss | 2 +- frontend/src/styles/media-queries-purple.scss | 2 +- frontend/src/styles/media-queries-yellow.scss | 2 +- frontend/src/styles/popups.scss | 9 ++- frontend/src/styles/profile.scss | 8 +++ frontend/src/ts/elements/test-activity.ts | 56 +++++++++++-------- frontend/src/ts/modals/edit-profile.ts | 11 +++- frontend/src/ts/pages/account.ts | 12 +++- frontend/src/ts/pages/profile.ts | 46 +++++++++++---- packages/schemas/src/users.ts | 2 + 19 files changed, 208 insertions(+), 45 deletions(-) diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 53cd63a05..cf5e62007 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -2948,6 +2948,9 @@ describe("user controller test", () => { streak: { length: 2, lastResultTimestamp: 2000, maxLength: 5 }, lbOptOut: false, bananas: 47, //should get removed + testActivity: { + "2024": fillYearWithDay(94), + }, }; beforeEach(async () => { @@ -2961,6 +2964,7 @@ describe("user controller test", () => { it("should get by name without authentication", async () => { //GIVEN + getUserByNameMock.mockResolvedValue(foundUser as any); const rank = { rank: 24 } as LeaderboardEntry; @@ -3018,6 +3022,46 @@ describe("user controller test", () => { expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); expect(getUserMock).not.toHaveBeenCalled(); }); + it("should get testActivity if enabled", async () => { + //GIVEN + vi.useFakeTimers().setSystemTime(1712102400000); + getUserByNameMock.mockResolvedValue({ + ...foundUser, + profileDetails: { showActivityOnPublicProfile: true }, + } as any); + const rank = { rank: 24 } as LeaderboardEntry; + leaderboardGetRankMock.mockResolvedValue(rank); + leaderboardGetCountMock.mockResolvedValue(100); + + //WHEN + const { body } = await mockApp.get("/users/bob/profile").expect(200); + + //THEN + expect(body.data.testActivity).toEqual( + expect.objectContaining({ + lastDay: 1712102400000, + testsByDays: expect.arrayContaining([]), + }) + ); + }); + it("should not get testActivity if disabled", async () => { + //GIVEN + vi.useFakeTimers().setSystemTime(1712102400000); + getUserByNameMock.mockResolvedValue({ + ...foundUser, + profileDetails: { showActivityOnPublicProfile: false }, + } as any); + const rank = { rank: 24 } as LeaderboardEntry; + leaderboardGetRankMock.mockResolvedValue(rank); + leaderboardGetCountMock.mockResolvedValue(100); + + //WHEN + const { body } = await mockApp.get("/users/bob/profile").expect(200); + + //THEN + expect(body.data.testActivity).toBeUndefined(); + }); + it("should get base profile for banned user", async () => { //GIVEN getUserByNameMock.mockResolvedValue({ @@ -3132,6 +3176,7 @@ describe("user controller test", () => { twitter: "twitter", website: "https://monkeytype.com", }, + showActivityOnPublicProfile: false, }; //WHEN @@ -3159,6 +3204,7 @@ describe("user controller test", () => { twitter: "twitter", website: "https://monkeytype.com", }, + showActivityOnPublicProfile: false, }, { badges: [{ id: 4 }, { id: 2, selected: true }, { id: 3 }], diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 348b66724..994d7ad72 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -972,6 +972,11 @@ export async function getProfile( uid: user.uid, } as UserProfile; + if (user.profileDetails?.showActivityOnPublicProfile) { + profileData.testActivity = generateCurrentTestActivity(user.testActivity); + } else { + delete profileData.testActivity; + } return new MonkeyResponse("Profile retrieved", profileData); } @@ -979,7 +984,13 @@ export async function updateProfile( req: MonkeyRequest ): Promise { const { uid } = req.ctx.decodedToken; - const { bio, keyboard, socialProfiles, selectedBadgeId } = req.body; + const { + bio, + keyboard, + socialProfiles, + selectedBadgeId, + showActivityOnPublicProfile, + } = req.body; const user = await UserDAL.getPartialUser(uid, "update user profile", [ "banned", @@ -1005,6 +1016,7 @@ export async function updateProfile( socialProfiles, sanitizeString ) as UserProfileDetails["socialProfiles"], + showActivityOnPublicProfile, }; await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory); diff --git a/frontend/src/html/pages/account.html b/frontend/src/html/pages/account.html index d63054c03..f2e997760 100644 --- a/frontend/src/html/pages/account.html +++ b/frontend/src/html/pages/account.html @@ -287,7 +287,7 @@ - diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 0a8b89f89..ea438de3e 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -1377,6 +1377,17 @@
+
+ + +
diff --git a/frontend/src/styles/account.scss b/frontend/src/styles/account.scss index f870a9d4c..911bb64eb 100644 --- a/frontend/src/styles/account.scss +++ b/frontend/src/styles/account.scss @@ -459,7 +459,7 @@ background-color: var(--sub-alt-color); } -#testActivity { +.testActivity { // width: max-content; // justify-self: center; background: var(--sub-alt-color); diff --git a/frontend/src/styles/media-queries-blue.scss b/frontend/src/styles/media-queries-blue.scss index 8dcc8d722..5261733b5 100644 --- a/frontend/src/styles/media-queries-blue.scss +++ b/frontend/src/styles/media-queries-blue.scss @@ -231,7 +231,7 @@ } } } - #testActivity { + .testActivity { --box-size: 0.58em; // .activity div, // .legend div { diff --git a/frontend/src/styles/media-queries-brown.scss b/frontend/src/styles/media-queries-brown.scss index 07746e557..64787a3e7 100644 --- a/frontend/src/styles/media-queries-brown.scss +++ b/frontend/src/styles/media-queries-brown.scss @@ -54,7 +54,7 @@ border-radius: 0.3rem; font-size: 0.5rem; } - #testActivity { + .testActivity { display: none; } diff --git a/frontend/src/styles/media-queries-green.scss b/frontend/src/styles/media-queries-green.scss index c91870a5a..036cabc4e 100644 --- a/frontend/src/styles/media-queries-green.scss +++ b/frontend/src/styles/media-queries-green.scss @@ -272,7 +272,7 @@ } } } - #testActivity { + .testActivity { --box-size: 0.7em; .wrapper { grid-template-areas: diff --git a/frontend/src/styles/media-queries-orange.scss b/frontend/src/styles/media-queries-orange.scss index f9672bb4e..f1ba7174e 100644 --- a/frontend/src/styles/media-queries-orange.scss +++ b/frontend/src/styles/media-queries-orange.scss @@ -8,7 +8,7 @@ .content-grid { --content-max-width: 1280px; } - #testActivity { + .testActivity { --box-size: 1.05em; .daysFull { margin-right: 1rem; diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss index 607069265..8cffe9b3a 100644 --- a/frontend/src/styles/media-queries-purple.scss +++ b/frontend/src/styles/media-queries-purple.scss @@ -301,7 +301,7 @@ } } } - #testActivity { + .testActivity { .wrapper { width: 100%; .top { diff --git a/frontend/src/styles/media-queries-yellow.scss b/frontend/src/styles/media-queries-yellow.scss index 85af2f495..dca45ab15 100644 --- a/frontend/src/styles/media-queries-yellow.scss +++ b/frontend/src/styles/media-queries-yellow.scss @@ -53,7 +53,7 @@ } } } - #testActivity { + .testActivity { --box-size: 0.9em; --font-size: 0.9em; // .days { diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index 422e48c9a..0b033ce2d 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -1594,9 +1594,12 @@ body.darkMode { margin-bottom: 0.25em; display: block; } - input { + input:not([type="checkbox"]) { width: 100%; } + input[type="checkbox"] { + vertical-align: text-bottom; + } textarea { resize: vertical; width: 100%; @@ -1635,6 +1638,10 @@ body.darkMode { .badgeSelectionItem:hover { opacity: 100%; } + + span { + color: var(--text-color); + } } } diff --git a/frontend/src/styles/profile.scss b/frontend/src/styles/profile.scss index 72c51c5b7..6fab6f991 100644 --- a/frontend/src/styles/profile.scss +++ b/frontend/src/styles/profile.scss @@ -65,6 +65,14 @@ color: var(--sub-color); font-size: 0.8em; } + + .testActivity { + margin-top: 2rem; + + .top { + grid-template-areas: "title title legend"; + } + } } .profile { diff --git a/frontend/src/ts/elements/test-activity.ts b/frontend/src/ts/elements/test-activity.ts index f49f484cb..18eae1268 100644 --- a/frontend/src/ts/elements/test-activity.ts +++ b/frontend/src/ts/elements/test-activity.ts @@ -12,26 +12,35 @@ import { safeNumber } from "@monkeytype/util/numbers"; let yearSelector: SlimSelect | undefined = undefined; export function init( + element: HTMLElement, calendar?: TestActivityCalendar, userSignUpDate?: Date ): void { if (calendar === undefined) { - $("#testActivity").addClass("hidden"); + clear(element); return; } - $("#testActivity").removeClass("hidden"); + element.classList.remove("hidden"); - yearSelector = getYearSelector(); - initYearSelector( - "current", - safeNumber(userSignUpDate?.getFullYear()) ?? 2022 - ); - updateLabels(calendar.firstDayOfWeek); - update(calendar); + if (element.querySelector(".yearSelect") !== null) { + yearSelector = getYearSelector(element); + initYearSelector( + element, + "current", + safeNumber(userSignUpDate?.getFullYear()) ?? 2022 + ); + } + updateLabels(element, calendar.firstDayOfWeek); + update(element, calendar); } -function update(calendar?: TestActivityCalendar): void { - const container = document.querySelector("#testActivity .activity"); +export function clear(element: HTMLElement): void { + element.classList.add("hidden"); + element.querySelector(".activity")?.replaceChildren(); +} + +function update(element: HTMLElement, calendar?: TestActivityCalendar): void { + const container = element.querySelector(".activity"); if (container === null) { return; @@ -41,13 +50,15 @@ function update(calendar?: TestActivityCalendar): void { if (calendar === undefined) { updateMonths([]); - $("#testActivity .nodata").removeClass("hidden"); + element.querySelector(".nodata")?.classList.remove("hidden"); + return; } updateMonths(calendar.getMonths()); - $("#testActivity .nodata").addClass("hidden"); - const title = document.querySelector("#testActivity .title"); + element.querySelector(".nodata")?.classList.add("hidden"); + + const title = element.querySelector(".title"); { if (title !== null) { title.innerHTML = calendar.getTotalTests() + " tests"; @@ -66,6 +77,7 @@ function update(calendar?: TestActivityCalendar): void { } export function initYearSelector( + element: HTMLElement, selectedYear: number | "current", startYear: number ): void { @@ -91,7 +103,7 @@ export function initYearSelector( } } - const yearSelect = getYearSelector(); + const yearSelect = getYearSelector(element); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument yearSelect.setData(years); // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -99,7 +111,7 @@ export function initYearSelector( } function updateMonths(months: TestActivityMonth[]): void { - const element = document.querySelector("#testActivity .months") as Element; + const element = document.querySelector(".testActivity .months") as Element; element.innerHTML = months .map( @@ -109,10 +121,10 @@ function updateMonths(months: TestActivityMonth[]): void { .join(""); } -function getYearSelector(): SlimSelect { +function getYearSelector(element: HTMLElement): SlimSelect { if (yearSelector !== undefined) return yearSelector; yearSelector = new SlimSelect({ - select: "#testActivity .yearSelect", + select: element.querySelector(".yearSelect") as Element, settings: { showSearch: false, }, @@ -122,7 +134,7 @@ function getYearSelector(): SlimSelect { yearSelector?.disable(); const selected = newVal[0]?.value as string; const activity = await getTestActivityCalendar(selected); - update(activity); + update(element, activity); // eslint-disable-next-line @typescript-eslint/no-unsafe-call if ((yearSelector?.getData() ?? []).length > 1) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -143,7 +155,7 @@ const daysDisplay = [ "friday", "saturday", ]; -function updateLabels(firstDayOfWeek: number): void { +function updateLabels(element: HTMLElement, firstDayOfWeek: number): void { const days: (string | undefined)[] = []; for (let i = 0; i < 7; i++) { days.push( @@ -167,6 +179,6 @@ function updateLabels(firstDayOfWeek: number): void { .join(""); }; - $("#testActivity .daysFull").html(buildHtml()); - $("#testActivity .days").html(buildHtml(3)); + (element.querySelector(".daysFull") as HTMLElement).innerHTML = buildHtml(); + (element.querySelector(".days") as HTMLElement).innerHTML = buildHtml(3); } diff --git a/frontend/src/ts/modals/edit-profile.ts b/frontend/src/ts/modals/edit-profile.ts index 2cff8f102..3b0c1ae02 100644 --- a/frontend/src/ts/modals/edit-profile.ts +++ b/frontend/src/ts/modals/edit-profile.ts @@ -50,6 +50,9 @@ const twitterInput = $("#editProfileModal .twitter"); const githubInput = $("#editProfileModal .github"); const websiteInput = $("#editProfileModal .website"); const badgeIdsSelect = $("#editProfileModal .badgeSelectionContainer"); +const showActivityOnPublicProfileInput = document.querySelector( + "#editProfileModal .editProfileShowActivityOnPublicProfile" +) as HTMLInputElement; const indicators = [ addValidation(twitterInput, TwitterProfileSchema), @@ -63,7 +66,8 @@ function hydrateInputs(): void { const snapshot = DB.getSnapshot(); if (!snapshot) return; const badges = snapshot.inventory?.badges ?? []; - const { bio, keyboard, socialProfiles } = snapshot.details ?? {}; + const { bio, keyboard, socialProfiles, showActivityOnPublicProfile } = + snapshot.details ?? {}; currentSelectedBadgeId = -1; bioInput.val(bio ?? ""); @@ -72,6 +76,8 @@ function hydrateInputs(): void { githubInput.val(socialProfiles?.github ?? ""); websiteInput.val(socialProfiles?.website ?? ""); badgeIdsSelect.html(""); + showActivityOnPublicProfileInput.checked = + showActivityOnPublicProfile || false; badges?.forEach((badge: Badge) => { if (badge.selected) { @@ -118,6 +124,8 @@ function buildUpdatesFromInputs(): UserProfileDetails { const twitter = (twitterInput.val() ?? "") as string; const github = (githubInput.val() ?? "") as string; const website = (websiteInput.val() ?? "") as string; + const showActivityOnPublicProfile = + showActivityOnPublicProfileInput.checked ?? false; const profileUpdates: UserProfileDetails = { bio, @@ -127,6 +135,7 @@ function buildUpdatesFromInputs(): UserProfileDetails { github, website, }, + showActivityOnPublicProfile, }; return profileUpdates; diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 3d6f51683..91186bef0 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -52,6 +52,7 @@ export function toggleFilterDebug(): void { let filteredResults: SnapshotResult[] = []; let visibleTableLines = 0; +let testActivityEl: HTMLElement | null; function loadMoreLines(lineIndex?: number): void { if (filteredResults === undefined || filteredResults.length === 0) return; @@ -223,7 +224,11 @@ async function fillContent(): Promise { PbTables.update(snapshot.personalBests); void Profile.update("account", snapshot); - TestActivity.init(snapshot.testActivity, new Date(snapshot.addedAt)); + TestActivity.init( + testActivityEl as HTMLElement, + snapshot.testActivity, + new Date(snapshot.addedAt) + ); void ResultBatches.update(); chartData = []; @@ -1362,7 +1367,12 @@ export const page = new Page({ ResultFilters.updateActive(); await Misc.sleep(0); + testActivityEl = document.querySelector( + ".page.pageAccount .testActivity" + ) as HTMLElement; + TestActivity.initYearSelector( + testActivityEl, "current", snapshot !== undefined ? new Date(snapshot.addedAt).getFullYear() : 2020 ); diff --git a/frontend/src/ts/pages/profile.ts b/frontend/src/ts/pages/profile.ts index 64a78cfe1..20457410e 100644 --- a/frontend/src/ts/pages/profile.ts +++ b/frontend/src/ts/pages/profile.ts @@ -8,6 +8,11 @@ import * as UserReportModal from "../modals/user-report"; import * as Skeleton from "../utils/skeleton"; import { UserProfile } from "@monkeytype/schemas/users"; import { PersonalBests } from "@monkeytype/schemas/shared"; +import * as TestActivity from "../elements/test-activity"; +import { TestActivityCalendar } from "../elements/test-activity-calendar"; +import { getFirstDayOfTheWeek } from "../utils/date-and-time"; + +const firstDayOfTheWeek = getFirstDayOfTheWeek(); function reset(): void { $(".page.pageProfile .error").addClass("hidden"); @@ -149,7 +154,15 @@ function reset(): void {
-
- `); + + `); + + const testActivityEl = document.querySelector( + ".page.pageProfile .testActivity" + ); + if (testActivityEl !== null) { + TestActivity.clear(testActivityEl as HTMLElement); + } } type UpdateOptions = { @@ -183,17 +196,28 @@ async function update(options: UpdateOptions): Promise { $(".page.pageProfile .error").removeClass("hidden"); $(".page.pageProfile .error .message").text(message); } else if (response.status === 200) { - window.history.replaceState( - null, - "", - `/profile/${response.body.data.name}` - ); - await Profile.update("profile", response.body.data); + const profile = response.body.data; + window.history.replaceState(null, "", `/profile/${profile.name}`); + await Profile.update("profile", profile); // this cast is fine because pb tables can handle the partial data inside user profiles - PbTables.update( - response.body.data.personalBests as unknown as PersonalBests, - true - ); + PbTables.update(profile.personalBests as unknown as PersonalBests, true); + + const testActivity = document.querySelector( + ".page.pageProfile .testActivity" + ) as HTMLElement; + + if (profile.testActivity !== undefined) { + const calendar = new TestActivityCalendar( + profile.testActivity.testsByDays, + new Date(profile.testActivity.lastDay), + firstDayOfTheWeek + ); + TestActivity.init(testActivity, calendar); + const title = testActivity.querySelector(".top .title") as HTMLElement; + title.innerHTML = title?.innerHTML + " in last 12 months"; + } else { + TestActivity.clear(testActivity); + } } else { // $(".page.pageProfile .failedToLoad").removeClass("hidden"); Notifications.add("Failed to load profile: " + response.body.message, -1); diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index d3c6eda45..f129563cf 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -118,6 +118,7 @@ export const UserProfileDetailsSchema = z }) .strict() .optional(), + showActivityOnPublicProfile: z.boolean().optional(), }) .strict(); export type UserProfileDetails = z.infer; @@ -317,6 +318,7 @@ export const UserProfileSchema = UserSchema.pick({ isPremium: true, inventory: true, allTimeLbs: true, + testActivity: true, }) .extend({ typingStats: TypingStatsSchema,