mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-11-08 05:03:39 +08:00
feat(profile): optionally include test activity on users public profile (@fehmer) (#6824)
Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
parent
91f64d211e
commit
c5d43dd673
19 changed files with 208 additions and 45 deletions
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -459,7 +459,7 @@
|
|||
background-color: var(--sub-alt-color);
|
||||
}
|
||||
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
// width: max-content;
|
||||
// justify-self: center;
|
||||
background: var(--sub-alt-color);
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
--box-size: 0.58em;
|
||||
// .activity div,
|
||||
// .legend div {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
border-radius: 0.3rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
--box-size: 0.7em;
|
||||
.wrapper {
|
||||
grid-template-areas:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
.content-grid {
|
||||
--content-max-width: 1280px;
|
||||
}
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
--box-size: 1.05em;
|
||||
.daysFull {
|
||||
margin-right: 1rem;
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
.top {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
.testActivity {
|
||||
--box-size: 0.9em;
|
||||
--font-size: 0.9em;
|
||||
// .days {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@
|
|||
color: var(--sub-color);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.testActivity {
|
||||
margin-top: 2rem;
|
||||
|
||||
.top {
|
||||
grid-template-areas: "title title legend";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue