feat(profile): optionally include test activity on users public profile (@fehmer) (#6824)

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-08-15 14:31:58 +02:00 committed by GitHub
parent 91f64d211e
commit c5d43dd673
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 208 additions and 45 deletions

View file

@ -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 }],

View file

@ -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<undefined, UpdateUserProfileRequest>
): Promise<UpdateUserProfileResponse> {
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);

View file

@ -287,7 +287,7 @@
<!-- <div class="group createdDate">Account created on -</div> -->
<div id="testActivity" class="hidden">
<div class="testActivity hidden">
<div class="wrapper">
<div class="top">
<div class="year"><select class="yearSelect"></select></div>

View file

@ -162,5 +162,27 @@
</div>
<div class="lbOptOutReminder hidden"></div>
</div>
<div class="testActivity hidden">
<div class="wrapper">
<div class="top">
<div class="title"></div>
<div class="legend">
<span>less</span>
<div data-level="0"></div>
<div data-level="1"></div>
<div data-level="2"></div>
<div data-level="3"></div>
<div data-level="4"></div>
<span>more</span>
</div>
</div>
<div class="activity"></div>
<div class="months"></div>
<div class="daysFull"></div>
<div class="days"></div>
<div class="nodata hidden">No data found.</div>
<div class="note">Note: All activity data is using UTC time.</div>
</div>
</div>
</div>
</div>

View file

@ -1377,6 +1377,17 @@
<label>badge</label>
<div class="badgeSelectionContainer"></div>
</div>
<div>
<label>public activity</label>
<label class="checkbox">
<input
class="editProfileShowActivityOnPublicProfile"
type="checkbox"
checked
/>
<span>Include test activity graph on your public profile.</span>
</label>
</div>
<button class="edit-profile-submit" type="submit">save</button>
</form>
</dialog>

View file

@ -459,7 +459,7 @@
background-color: var(--sub-alt-color);
}
#testActivity {
.testActivity {
// width: max-content;
// justify-self: center;
background: var(--sub-alt-color);

View file

@ -231,7 +231,7 @@
}
}
}
#testActivity {
.testActivity {
--box-size: 0.58em;
// .activity div,
// .legend div {

View file

@ -54,7 +54,7 @@
border-radius: 0.3rem;
font-size: 0.5rem;
}
#testActivity {
.testActivity {
display: none;
}

View file

@ -272,7 +272,7 @@
}
}
}
#testActivity {
.testActivity {
--box-size: 0.7em;
.wrapper {
grid-template-areas:

View file

@ -8,7 +8,7 @@
.content-grid {
--content-max-width: 1280px;
}
#testActivity {
.testActivity {
--box-size: 1.05em;
.daysFull {
margin-right: 1rem;

View file

@ -301,7 +301,7 @@
}
}
}
#testActivity {
.testActivity {
.wrapper {
width: 100%;
.top {

View file

@ -53,7 +53,7 @@
}
}
}
#testActivity {
.testActivity {
--box-size: 0.9em;
--font-size: 0.9em;
// .days {

View file

@ -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);
}
}
}

View file

@ -65,6 +65,14 @@
color: var(--sub-color);
font-size: 0.8em;
}
.testActivity {
margin-top: 2rem;
.top {
grid-template-areas: "title title legend";
}
}
}
.profile {

View file

@ -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);
}

View file

@ -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;

View file

@ -52,6 +52,7 @@ export function toggleFilterDebug(): void {
let filteredResults: SnapshotResult<Mode>[] = [];
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<void> {
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
);

View file

@ -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 {
<div class="acc">-</div>
</div>
</div>
</div><div class="lbOptOutReminder hidden"></div>`);
</div><div class="lbOptOutReminder hidden"></div>
`);
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<void> {
$(".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);

View file

@ -118,6 +118,7 @@ export const UserProfileDetailsSchema = z
})
.strict()
.optional(),
showActivityOnPublicProfile: z.boolean().optional(),
})
.strict();
export type UserProfileDetails = z.infer<typeof UserProfileDetailsSchema>;
@ -317,6 +318,7 @@ export const UserProfileSchema = UserSchema.pick({
isPremium: true,
inventory: true,
allTimeLbs: true,
testActivity: true,
})
.extend({
typingStats: TypingStatsSchema,