From cdd435bfc1007de312a23543777aadfde71d2bb9 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 26 Oct 2026 12:56:07 +0100 Subject: [PATCH] refactor: single-row user collection replaces the snapshot (U1) Adds collections/user.ts: a local-only TanStack DB collection holding the signed-in user, read through getUser() (module-level reactive mirror) and written through named functions only: direct writes for what the server already did, optimistic actions for user changes. All writers move over; the states/snapshot mirror and DB.setSnapshot/initSnapshot are deleted. DB.getSnapshot stays as a read-only shim over getUser until U4. Co-Authored-By: Claude Opus 5.5 --- docs/specs/solid-monkeytype/roadmap.md | 6 +- docs/specs/solid-monkeytype/spec.md | 10 +- frontend/__tests__/__harness__/app-shell.tsx | 14 +- frontend/__tests__/collections/user.spec.ts | 320 +++++++++++ frontend/src/ts/auth.tsx | 14 +- frontend/src/ts/collections/connections.ts | 6 +- frontend/src/ts/collections/inbox.ts | 2 +- frontend/src/ts/collections/results.ts | 2 +- frontend/src/ts/collections/user.ts | 503 ++++++++++++++++++ .../components/layout/header/AccountMenu.tsx | 4 +- .../src/ts/components/layout/header/Nav.tsx | 8 +- .../ts/components/layout/overlays/Banners.tsx | 4 +- .../ts/components/modals/DevOptionsModal.tsx | 2 +- .../ts/components/modals/EditProfileModal.tsx | 29 +- .../ts/components/modals/QuoteRateModal.tsx | 34 +- .../modals/StreakHourOffsetModal.tsx | 21 +- .../account-settings/ReauthConfirmModals.tsx | 18 +- .../account-settings/UnlinkDiscordModal.tsx | 22 +- .../account-settings/UpdateNameModal.tsx | 16 +- .../src/ts/components/modals/alerts/Inbox.tsx | 4 +- .../pages/account-settings/AccountTab.tsx | 10 +- .../ts/components/pages/account/MyProfile.tsx | 13 +- .../pages/connections/FriendsList.tsx | 4 +- .../pages/leaderboard/LeaderboardPage.tsx | 3 +- .../pages/profile/ActivityCalendar.tsx | 25 +- .../components/pages/profile/UserDetails.tsx | 2 +- .../pages/test/modes-notice/PbNotice.tsx | 4 +- .../test/result/ResultSecondaryStats.tsx | 47 +- .../pages/test/result/TestResult.tsx | 4 +- frontend/src/ts/constants/default-snapshot.ts | 14 +- .../src/ts/controllers/quotes-controller.ts | 46 +- frontend/src/ts/db.ts | 388 +------------- frontend/src/ts/queries/profile.ts | 4 +- frontend/src/ts/router/routes.ts | 4 +- frontend/src/ts/states/last-result.ts | 8 + .../src/ts/states/leaderboard-selection.ts | 4 +- frontend/src/ts/states/snapshot.ts | 41 -- .../ts/url-actions/actions/discord-oauth.ts | 15 +- frontend/src/ts/utils/types.ts | 6 + 39 files changed, 1017 insertions(+), 664 deletions(-) create mode 100644 frontend/__tests__/collections/user.spec.ts create mode 100644 frontend/src/ts/collections/user.ts create mode 100644 frontend/src/ts/states/last-result.ts delete mode 100644 frontend/src/ts/states/snapshot.ts diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index e782d2fa4d39..8e2fe173fb07 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -108,9 +108,9 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | Notes | |---|---|---|---|---|---| -| [ ] | U1 | L · PR | Single-row user collection with `getUser()` and named writes (direct writes for server-side effects, optimistic actions for user changes, incl. quote ratings); convert **all writers** (result save, xp, badges, inbox count, lb memory, streak offset, quote rate, favourite quotes, edit profile, update name, unlink Discord, Discord OAuth, auth); delete the reactive mirror; `DB.getSnapshot()` becomes a read-only shim over the row | none | Writers must move in the same PR: there's no mutable object left | -| [ ] | U2 | M · PR | Activity calendar: plain activity data in the user row; the calendar becomes a Solid component; delete the imperative test-activity modules | U1 (data shape) | Can share a PR with U1 | -| [ ] | U3 | M · PR | Remove the snapshot-updated auth event (PSA, last-signed-out-result modal read `getUser()`); auth becomes a signal the router reads, with the user-data load as state (loading/loaded/failed) plus `reloadUserData()` replacing the event's promise; delete the auth bus | U1 | | +| [x] | U1 | L · PR | Single-row user collection with `getUser()` and named writes (direct writes for server-side effects, optimistic actions for user changes, incl. quote ratings); convert **all writers** (result save, xp, badges, inbox count, lb memory, streak offset, quote rate, favourite quotes, edit profile, update name, unlink Discord, Discord OAuth, auth); delete the reactive mirror; `DB.getSnapshot()` becomes a read-only shim over the row | none | Done: `collections/user.ts` (local-only collection; `loadUserData`/`clearUser` for auth); `DB.getSnapshot = getUser`; last-result signal in `states/last-result.ts` | +| [ ] | U2 | M · PR | Activity calendar (row already holds plain activity data; the per-year cache sits in `db.ts`): the calendar becomes a Solid component; delete the imperative test-activity modules | U1 (data shape) | Can share a PR with U1 | +| [ ] | U3 | M · PR | Remove the snapshot-updated auth event (PSA, last-signed-out-result modal read `getUser()`); auth becomes a signal the router reads, with the user-data load as state (loading/loaded/failed) plus `reloadUserData()` (reruns `loadUserData()`) replacing the event's promise; delete the auth bus | U1 | | | [ ] | U4 | S · commit per area | Readers move from the `DB.getSnapshot()` shim to `getUser()`, by area; the last PR deletes the shim and the snapshot code in `db.ts` | U1 | Parallel by area | | [ ] | U5 | M · PR | Tags, presets and custom themes get the `getUser()` pattern: plain getters backed by a module-level reactive mirror, replacing their live-query hooks (keep `gcTime: Infinity`). Rewrite the non-test `__nonReactive` callers as Solid code reading the getters: commandline tag/preset lists, the custom-themes check in config metadata, the tag PB helpers in `db.ts` (or they go with U4) | none | Test-page callers go with T6a/T6c | | [ ] | U6 | S · commit | Delete `__nonReactive` from every module once no caller is left | U5, T6a, T6c | Pure deletion: a caller still left means a step missed it | diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index 2596a7a4509c..44b5dfcae14c 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -133,11 +133,11 @@ Users should see no behaviour change, except where a bug at today's seams disapp - Interim rule until the event bus is gone: the store must be updated *before* the event fires, so listeners never see stale values. - **Config metadata** is the single source for settings UI, commandline generation, validation and restart behaviour (`changeRequiresRestart`). The separate commandline-metadata module is merged into it. Commandline icons use the icon component's types, not strings. - **User**: a single-row TanStack DB collection keyed by uid replaces both the mutable user snapshot and its reactive mirror (and the planned snapshot store). - - It's a query collection on the existing cached user response (as tags already are); the mapping from the API shape lives in its `queryFn`. `enabled` while signed in, `staleTime`/`gcTime: Infinity`. + - It's a local-only collection: the auth flow loads the row (the mapping from the API shape lives in the user module) and sign-out deletes it. Not a query collection: a refetch resolves before the row lands in the collection, and a disabled query doesn't clear the row on sign-out. - The row holds what the snapshot holds today. Test activity is plain data (days and last day); the calendar class and per-year cache live in the calendar component. Tags, presets etc. stay in their own collections; the last-result signal stays a signal. - Signed out means no row; consumers handle "no user". No default user stands in. A failed user fetch shows the error and leaves no row (today it silently falls back to defaults). Sign-in waits for the row inside the blocking user-data step, as it does for tags and presets. - **Reads**: one `getUser()`, backed by a module-level reactive mirror of the row: reactive inside components and effects, a plain synchronous read elsewhere (engine at finish, plain modules). No separate hook, no `__nonReactive`, no direct collection access outside the module. - - **Writes**: named functions only; the raw update stays private. Mirrors of what the server already did (xp, streak, typing stats, PBs, leaderboard memory, badges, inbox count) are direct writes with no API call or refetch. User-initiated changes (name, Discord unlink, streak offset, quote ratings, favourite quotes) are optimistic actions that call the API and roll back on failure, as custom themes and connections do. Server-owned flags (banned, verified, leaderboard opt-out) have no setters. + - **Writes**: named functions only; the raw update stays private. Mirrors of what the server already did (xp, streak, typing stats, PBs and PB reset, leaderboard memory, badges, inbox count, Discord link) are direct writes with no API call or refetch. User-initiated changes (name, Discord unlink, streak offset, quote ratings, favourite quotes, profile) are optimistic actions that call the API and roll back on failure, as custom themes and connections do. On success an action keeps the current row with its change applied, not the row as it was when the action started, so direct writes made meanwhile survive. Server-owned flags (banned, verified, leaderboard opt-out) have no setters. - The auth bus's snapshot-updated event goes away; consumers read `getUser()`. - **Auth**: auth state is a signal (unknown, signed in, signed out), which the router's auth context and UI read. The user-data load sits next to it as state (loading, loaded, failed with an error) plus a `reloadUserData()` command; the router's auth context reads it for the blocking user-data step (user row, tags, presets) and its retry, instead of receiving a promise through an event. The auth bus (both its auth-state-changed and snapshot-updated events) is deleted. - **Server data**: Query for read-only server data, DB collections for user-owned lists (results, tags, presets, connections, ape keys, inbox, custom themes, filter presets) and the single-row user collection above. @@ -259,6 +259,12 @@ From config (C1): - A key that fails to apply during a whole-config apply holds its default in the store (it used to hold the rejected value). - A single setting change runs store effects before config event listeners. A whole-config apply runs them once, before failed keys are re-saved and before full-config-change finished (they used to run after it). +From user data (U1): + +- Resetting PBs updates the PBs shown straight away (the reactive copy used to keep the old ones). +- User changes (name, Discord unlink, streak offset, quote rating, favourite quote, profile) show at once and roll back if the request fails; they used to show only after the request. +- The account page's activity calendar updates after a test without a reload. + ## Settled decisions (grilling, 2026-10-24) | # | Decision | diff --git a/frontend/__tests__/__harness__/app-shell.tsx b/frontend/__tests__/__harness__/app-shell.tsx index 5cc5f483f432..866eaade19fa 100644 --- a/frontend/__tests__/__harness__/app-shell.tsx +++ b/frontend/__tests__/__harness__/app-shell.tsx @@ -87,10 +87,10 @@ import { import { createSignal, For, JSXElement } from "solid-js"; import { vi } from "vitest"; +import { __testing as userTesting } from "../../src/ts/collections/user"; import { blockingDownloadOptions } from "../../src/ts/collections/utils/blocking-download"; import { __testing as configTesting } from "../../src/ts/config/testing"; import { getDefaultSnapshot } from "../../src/ts/constants/default-snapshot"; -import { setSnapshot } from "../../src/ts/db"; import { AppRouter, createAppRouter, @@ -342,9 +342,7 @@ export function createFakeAuth( function setSignedIn(signedIn: boolean, userData?: UserDataDownload): void { isSignedIn = signedIn; // the account route checks the user data downloaded - setSnapshot(signedIn ? getDefaultSnapshot() : undefined, { - dispatchEvent: false, - }); + userTesting.setUser(signedIn ? getDefaultSnapshot() : undefined); auth.stateChanged(userData); } @@ -568,9 +566,9 @@ export function renderApp(options: RenderAppOptions = {}): App { blockingData.friends.collection = createDownloadCollection("friends"); blockingData.friendsRefresh.set(options.friendsRefresh !== "pending"); // the account route checks the user data downloaded - setSnapshot(options.auth === "signedIn" ? getDefaultSnapshot() : undefined, { - dispatchEvent: false, - }); + userTesting.setUser( + options.auth === "signedIn" ? getDefaultSnapshot() : undefined, + ); for (const [name, content] of Object.entries(options.pageContent ?? {})) { pageContent.set(name, content); } @@ -723,7 +721,7 @@ export function cleanupApp(): void { setIsTestRestarting(false); setResultCalculating(false); configTesting.replaceConfig({}); - setSnapshot(undefined, { dispatchEvent: false }); + userTesting.setUser(undefined); for (const name of ["results", "friends"] as const) { void blockingData[name].collection?.cleanup(); blockingData[name].collection = undefined; diff --git a/frontend/__tests__/collections/user.spec.ts b/frontend/__tests__/collections/user.spec.ts new file mode 100644 index 000000000000..104bf2f9b861 --- /dev/null +++ b/frontend/__tests__/collections/user.spec.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import Ape from "../../src/ts/ape"; +import { + __testing, + addBadge, + addXp, + clearUser, + getUser, + loadUserData, + recordResult, + resetPersonalBests, + setDiscordLink, + setInboxUnreadSize, + setQuoteFavorite, + setStreak, + updateLbMemory, + updateName, + updateProfile, +} from "../../src/ts/collections/user"; +import { + getDefaultSnapshot, + Snapshot, + SnapshotResult, +} from "../../src/ts/constants/default-snapshot"; +import { setUserId } from "../../src/ts/states/core"; +import { getXpBarData } from "../../src/ts/states/header"; +import { SnapshotInitError } from "../../src/ts/utils/snapshot-init-error"; + +vi.mock( + import("../../src/ts/ape/server-configuration"), + async (importOriginal) => ({ + ...(await importOriginal()), + configurationPromise: Promise.resolve(true), + }), +); + +const DAY = 24 * 60 * 60 * 1000; +const LAST_DAY = Date.UTC(2026, 9, 20, 12); + +function user(overrides: Partial = {}): Snapshot { + return { + ...getDefaultSnapshot(), + uid: "uid1", + name: "bob", + testActivity: { testsByDays: [3, null, 2], lastDay: LAST_DAY }, + inventory: { + badges: [ + { id: 1, selected: true }, + { id: 2, selected: false }, + ], + }, + ...overrides, + }; +} + +function result( + overrides: Partial> = {}, +): SnapshotResult<"time"> { + return { + mode: "time", + mode2: "15", + language: "english", + difficulty: "normal", + punctuation: false, + numbers: false, + lazyMode: false, + wpm: 100, + acc: 98, + rawWpm: 105, + consistency: 80, + timestamp: LAST_DAY, + testDuration: 15, + incompleteTestSeconds: 2, + afkDuration: 1, + restartCount: 3, + ...overrides, + } as SnapshotResult<"time">; +} + +function ok(data: T = null as T): never { + return { status: 200, body: { message: "ok", data } } as never; +} + +function fail(message: string): never { + return { status: 500, body: { message } } as never; +} + +beforeEach(() => { + __testing.setUser(user()); +}); + +afterEach(() => { + vi.restoreAllMocks(); + __testing.setUser(undefined); +}); + +describe("getUser", () => { + it("returns the row without collection props", () => { + expect(Object.keys(getUser() ?? {}).some((k) => k.startsWith("$"))).toBe( + false, + ); + }); + + it("is undefined without a user", () => { + clearUser(); + expect(getUser()).toBeUndefined(); + }); +}); + +describe("loadUserData", () => { + afterEach(() => setUserId(null)); + + it("downloads the user into the row", async () => { + clearUser(); + setUserId("uid2"); + vi.spyOn(Ape.users, "get").mockResolvedValue( + ok({ + uid: "uid2", + name: "alice", + email: "a@b.c", + addedAt: 1, + personalBests: { time: {}, words: {} }, + timeTyping: 10, + startedTests: 3, + completedTests: 2, + xp: 50, + streak: { length: 4, maxLength: 9, hourOffset: 2 }, + testActivity: { testsByDays: [1], lastDay: LAST_DAY }, + }), + ); + + await loadUserData(); + + expect(getUser()).toMatchObject({ + uid: "uid2", + name: "alice", + personalBests: { time: {}, words: {}, quote: {}, zen: {}, custom: {} }, + typingStats: { timeTyping: 10, startedTests: 3, completedTests: 2 }, + xp: 50, + streak: 4, + maxStreak: 9, + streakHourOffset: 2, + inboxUnreadSize: 0, + isPremium: false, + testActivity: { testsByDays: [1], lastDay: LAST_DAY }, + }); + }); + + it("leaves no user when the download fails", async () => { + setUserId("uid3"); + vi.spyOn(Ape.users, "get").mockResolvedValue({ + status: 429, + body: { message: "slow down" }, + } as never); + + await expect(loadUserData()).rejects.toBeInstanceOf(SnapshotInitError); + expect(getUser()).toBeUndefined(); + }); +}); + +describe("direct writes", () => { + it("records a result: typing stats, activity and pb", () => { + recordResult(result(), { isPb: true }); + + const u = getUser(); + expect(u?.typingStats).toEqual({ + timeTyping: 16, + startedTests: 4, + completedTests: 1, + }); + expect(u?.testActivity?.testsByDays).toEqual([3, null, 3]); + expect(u?.personalBests.time["15"]).toEqual([ + expect.objectContaining({ wpm: 100, raw: 105, language: "english" }), + ]); + + recordResult(result({ wpm: 120 }), { isPb: true }); + expect(getUser()?.personalBests.time["15"]).toEqual([ + expect.objectContaining({ wpm: 120 }), + ]); + }); + + it("records a result on a later day, padding missed days", () => { + recordResult(result({ timestamp: LAST_DAY + 3 * DAY }), { isPb: false }); + + expect(getUser()?.testActivity).toEqual({ + testsByDays: [3, null, 2, null, null, 1], + lastDay: LAST_DAY + 3 * DAY, + }); + expect(getUser()?.personalBests.time).toEqual({}); + }); + + it("adds xp and shows it on the xp bar", () => { + __testing.setUser(user({ xp: 10 })); + addXp(5); + + expect(getUser()?.xp).toBe(15); + expect(getXpBarData()).toMatchObject({ addedXp: 5, resultingXp: 15 }); + }); + + it("sets the streak, raising the max streak", () => { + __testing.setUser(user({ streak: 1, maxStreak: 2 })); + setStreak(3); + expect(getUser()).toMatchObject({ streak: 3, maxStreak: 3 }); + setStreak(1); + expect(getUser()).toMatchObject({ streak: 1, maxStreak: 3 }); + }); + + it("adds badges, sets inbox size, links discord, resets pbs", () => { + recordResult(result(), { isPb: true }); + + addBadge({ id: 3 }); + setInboxUnreadSize(4); + setDiscordLink({ discordId: "d1", discordAvatar: "a1" }); + resetPersonalBests(); + + expect(getUser()?.inventory?.badges.map((b) => b.id)).toEqual([1, 2, 3]); + expect(getUser()).toMatchObject({ + inboxUnreadSize: 4, + discordId: "d1", + discordAvatar: "a1", + personalBests: { time: {}, words: {}, quote: {}, zen: {}, custom: {} }, + }); + }); + + it("does nothing without a user", () => { + clearUser(); + addXp(5); + setStreak(5); + expect(getUser()).toBeUndefined(); + }); + + it("saves lb memory, sending only a changed rank", async () => { + const send = vi + .spyOn(Ape.users, "updateLeaderboardMemory") + .mockResolvedValue(ok()); + + await updateLbMemory("time", "15", "english", 7, true); + expect(getUser()?.lbMemory?.time?.["15"]?.["english"]).toBe(7); + expect(send).toHaveBeenCalledOnce(); + + await updateLbMemory("time", "15", "english", 7, true); + expect(send).toHaveBeenCalledOnce(); + }); +}); + +describe("optimistic actions", () => { + it("applies at once and keeps the change on success", async () => { + let respond = (_: unknown): void => undefined; + vi.spyOn(Ape.users, "updateName").mockReturnValue( + new Promise((resolve) => (respond = resolve)) as never, + ); + + const done = updateName("carl"); + expect(getUser()?.name).toBe("carl"); + + respond(ok()); + await done; + expect(getUser()?.name).toBe("carl"); + }); + + it("rolls back and rejects on failure", async () => { + vi.spyOn(Ape.users, "updateName").mockResolvedValue(fail("taken")); + + await expect(updateName("carl")).rejects.toThrow("taken"); + expect(getUser()?.name).toBe("bob"); + }); + + it("keeps a direct write made while the action was pending", async () => { + let respond = (_: unknown): void => undefined; + vi.spyOn(Ape.users, "updateName").mockReturnValue( + new Promise((resolve) => (respond = resolve)) as never, + ); + + const done = updateName("carl"); + addXp(5); + respond(ok()); + await done; + + expect(getUser()).toMatchObject({ name: "carl", xp: 5 }); + }); + + it("adds and removes a favourite quote", async () => { + vi.spyOn(Ape.users, "addQuoteToFavorites").mockResolvedValue(ok()); + vi.spyOn(Ape.users, "removeQuoteFromFavorites").mockResolvedValue(ok()); + + await setQuoteFavorite({ + language: "english", + quoteId: "5", + favorite: true, + }); + await setQuoteFavorite({ + language: "english", + quoteId: "6", + favorite: true, + }); + expect(getUser()?.favoriteQuotes?.["english"]).toEqual(["5", "6"]); + + await setQuoteFavorite({ + language: "english", + quoteId: "5", + favorite: false, + }); + expect(getUser()?.favoriteQuotes?.["english"]).toEqual(["6"]); + }); + + it("updates the profile with the server's sanitized details", async () => { + vi.spyOn(Ape.users, "updateProfile").mockResolvedValue( + ok({ bio: "clean" }), + ); + + await updateProfile({ details: { bio: "dirty" }, selectedBadgeId: 2 }); + + expect(getUser()?.details).toEqual({ bio: "clean" }); + expect(getUser()?.inventory?.badges).toEqual([ + { id: 1, selected: false }, + { id: 2, selected: true }, + ]); + }); +}); diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 0c000a1fb4cd..1dbe32e3597b 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -23,8 +23,8 @@ import { z, ZodString } from "zod"; import Ape from "./ape"; import { waitForPresetsReady } from "./collections/presets"; import { waitForTagsReady } from "./collections/tags"; +import { clearUser, loadUserData } from "./collections/user"; import { updateFromServer as updateConfigFromServer } from "./config/remote"; -import * as DB from "./db"; import { authEvent } from "./events/auth"; import { signOut as authSignOut, @@ -168,18 +168,12 @@ export async function sendVerificationEmail(): Promise { async function getDataAndInit(): Promise { try { console.log("getting account data"); - const snapshot = await DB.initSnapshot(); + const user = await loadUserData(); //TODO: preload collections for now, remove when __nonReactive is removed from collections await waitForPresetsReady(); await waitForTagsReady(); - if (snapshot === false) { - throw new Error( - "Snapshot didn't initialize due to lacking authentication even though user is authenticated", - ); - } - - void Sentry.setUser(snapshot.uid, snapshot.name); + void Sentry.setUser(user.uid, user.name); await updateConfigFromServer(); return true; @@ -232,7 +226,7 @@ export async function onAuthStateChanged( userPromise = loadUser(user); } else { setUserId(null); - DB.setSnapshot(undefined); + clearUser(); } } diff --git a/frontend/src/ts/collections/connections.ts b/frontend/src/ts/collections/connections.ts index 841df6f21384..c6a2479b022a 100644 --- a/frontend/src/ts/collections/connections.ts +++ b/frontend/src/ts/collections/connections.ts @@ -17,7 +17,7 @@ import { configurationPromise, get as getServerConfiguration, } from "../ape/server-configuration"; -import { getSnapshot } from "../states/snapshot"; +import { getUser } from "./user"; import { Connection } from "@monkeytype/schemas/connections"; import { showNoticeNotification } from "../states/notifications"; import { invalidateFriendsList } from "../queries/friends"; @@ -171,8 +171,8 @@ const actions = { status: "pending", receiverName, receiverUid: receiverUid ?? tempId(), - initiatorName: getSnapshot()?.name ?? "", - initiatorUid: getSnapshot()?.uid ?? "", + initiatorName: getUser()?.name ?? "", + initiatorUid: getUser()?.uid ?? "", lastModified: Date.now(), }); }, diff --git a/frontend/src/ts/collections/inbox.ts b/frontend/src/ts/collections/inbox.ts index 812d6a95452e..f006c2dcca1b 100644 --- a/frontend/src/ts/collections/inbox.ts +++ b/frontend/src/ts/collections/inbox.ts @@ -19,7 +19,7 @@ import { showSuccessNotification, } from "../states/notifications"; import * as BadgeController from "../controllers/badge-controller"; -import { addBadge, addXp } from "../db"; +import { addBadge, addXp } from "./user"; const flushStrategy = flushDebounceStrategy({ maxWait: 1000 * 60 * 5 }); export function applyPendingInboxActions(): void { diff --git a/frontend/src/ts/collections/results.ts b/frontend/src/ts/collections/results.ts index d33e5c71e20f..cbd7df3e9e1c 100644 --- a/frontend/src/ts/collections/results.ts +++ b/frontend/src/ts/collections/results.ts @@ -28,7 +28,7 @@ import { createEffectOn } from "../hooks/effects"; import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { isAuthenticated } from "../states/core"; -import { getLastResult, setLastResult } from "../states/snapshot"; +import { getLastResult, setLastResult } from "../states/last-result"; import { getActiveTagsOnce, getTagsOnce, diff --git a/frontend/src/ts/collections/user.ts b/frontend/src/ts/collections/user.ts new file mode 100644 index 000000000000..da627c673869 --- /dev/null +++ b/frontend/src/ts/collections/user.ts @@ -0,0 +1,503 @@ +import { UTCDateMini } from "@date-fns/utc/date/mini"; +import { Difficulty } from "@monkeytype/schemas/configs"; +import { Language } from "@monkeytype/schemas/languages"; +import { XpBreakdown } from "@monkeytype/schemas/results"; +import { + Mode, + Mode2, + PersonalBest, + PersonalBests, +} from "@monkeytype/schemas/shared"; +import { + Badge, + TestActivity, + UserProfileDetails, +} from "@monkeytype/schemas/users"; +import { + createCollection, + createOptimisticAction, + localOnlyCollectionOptions, +} from "@tanstack/solid-db"; +import { differenceInDays, isBefore, isSameDay } from "date-fns"; +import { createStore, reconcile } from "solid-js/store"; + +import Ape from "../ape"; +import { configurationPromise } from "../ape/server-configuration"; +import { fetchUserFromApi } from "../ape/user"; +import { Snapshot, SnapshotResult } from "../constants/default-snapshot"; +import { isAuthenticated } from "../states/core"; +import { setXpBarData } from "../states/header"; +import { SnapshotInitError } from "../utils/snapshot-init-error"; +import { DeepReadonly } from "../utils/types"; + +/** + * The signed-in user's data, a single row keyed by uid. Local only: the auth + * flow loads it, sign-out clears it. + */ +const userCollection = createCollection( + localOnlyCollectionOptions({ + id: "user", + getKey: (it) => it.uid, + }), +); + +const [mirror, setMirror] = createStore<{ value: Snapshot | undefined }>({ + value: undefined, +}); + +/** A copy of the row, without TanStack's virtual props (`$synced`...). */ +function copyRow(): Snapshot | undefined { + const row = userCollection.toArray[0]; + if (row === undefined) return undefined; + return Object.fromEntries( + Object.entries(structuredClone(row)).filter( + ([key]) => !key.startsWith("$"), + ), + ) as Snapshot; +} + +userCollection.subscribeChanges( + () => { + const row = copyRow(); + if (row === undefined) { + setMirror("value", undefined); + } else { + setMirror("value", reconcile(row, { merge: true })); + } + }, + { includeInitialState: true }, +); + +/** + * The signed-in user, `undefined` while signed out, loading or after a failed + * load. Reactive inside components and effects, a plain read elsewhere. + */ +export function getUser(): DeepReadonly | undefined { + return mirror.value; +} + +// --- Loading --- + +/** + * Downloads the user and replaces the row. Throws `SnapshotInitError` if the + * request fails, leaving no user. + */ +export async function loadUserData(): Promise> { + await configurationPromise; + + if (!isAuthenticated()) { + throw new Error( + "Snapshot didn't initialize due to lacking authentication even though user is authenticated", + ); + } + + try { + const userData = await fetchUserFromApi(); + + if (userData === null || userData === undefined) { + throw new SnapshotInitError( + `Request was successful but user data is null/undefined`, + 200, + ); + } + + const personalBests = userData.personalBests ?? {}; + for (const mode of ["time", "words", "quote", "zen", "custom"] as const) { + personalBests[mode] ??= {}; + } + + const row: Snapshot = { + uid: userData.uid, + email: userData.email, + name: userData.name, + addedAt: userData.addedAt, + personalBests, + banned: userData.banned, + lbOptOut: userData.lbOptOut, + verified: userData.verified, + discordId: userData.discordId, + discordAvatar: userData.discordAvatar, + needsToChangeName: userData.needsToChangeName, + typingStats: { + timeTyping: userData.timeTyping ?? 0, + startedTests: userData.startedTests ?? 0, + completedTests: userData.completedTests ?? 0, + }, + quoteMod: userData.quoteMod, + favoriteQuotes: userData.favoriteQuotes ?? {}, + quoteRatings: userData.quoteRatings, + details: userData.profileDetails, + inventory: userData.inventory, + xp: userData.xp ?? 0, + inboxUnreadSize: userData.inboxUnreadSize ?? 0, + streak: userData.streak?.length ?? 0, + maxStreak: userData.streak?.maxLength ?? 0, + streakHourOffset: userData.streak?.hourOffset ?? undefined, + isPremium: userData.isPremium ?? false, + allTimeLbs: userData.allTimeLbs, + testActivity: userData.testActivity, + lbMemory: userData.lbMemory, + }; + + clearUser(); + userCollection.insert(row); + } catch (e) { + clearUser(); + throw e; + } + + return getUser() as DeepReadonly; +} + +export function clearUser(): void { + const keys = [...userCollection.keys()]; + if (keys.length > 0) userCollection.delete(keys); +} + +// --- Direct writes: mirror what the server already did --- + +function write(recipe: (user: Snapshot) => void): void { + const uid = userCollection.toArray[0]?.uid; + if (uid === undefined) return; + userCollection.update(uid, recipe); +} + +export function recordResult( + result: SnapshotResult, + options: { isPb: boolean }, +): void { + write((user) => { + if (user.testActivity !== undefined) { + incrementTestActivity(user.testActivity, result.timestamp); + } + + user.typingStats.timeTyping += + result.testDuration + result.incompleteTestSeconds - result.afkDuration; + user.typingStats.startedTests += result.restartCount + 1; + user.typingStats.completedTests += 1; + + if (options.isPb && result.mode !== "quote") { + savePb(user.personalBests, result); + } + }); +} + +export function addXp(xp: number, breakdown?: XpBreakdown): void { + if (getUser() === undefined) return; + write((user) => { + user.xp += xp; + }); + setXpBarData({ + addedXp: xp, + resultingXp: getUser()?.xp ?? 0, + breakdown, + }); +} + +export function setStreak(streak: number): void { + write((user) => { + user.streak = streak; + if (streak > user.maxStreak) user.maxStreak = streak; + }); +} + +export function setInboxUnreadSize(size: number): void { + write((user) => { + user.inboxUnreadSize = size; + }); +} + +export function addBadge(badge: Badge): void { + write((user) => { + user.inventory ??= { badges: [] }; + user.inventory.badges.push(badge); + }); +} + +export function resetPersonalBests(): void { + write((user) => { + user.personalBests = { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + }); +} + +export function setDiscordLink(link: { + discordId?: string; + discordAvatar?: string; +}): void { + write((user) => { + if (link.discordId !== undefined) user.discordId = link.discordId; + if (link.discordAvatar !== undefined) { + user.discordAvatar = link.discordAvatar; + } + }); +} + +/** + * Saves the user's leaderboard rank. With `api`, also sends a changed rank to + * the server, without rolling back if that fails. + */ +export async function updateLbMemory( + mode: M, + mode2: Mode2 | undefined, + language: Language, + rank: number, + api = false, +): Promise { + if (mode2 === undefined || mode !== "time") return; + const timeMode2 = mode2 as "15" | "60"; + const user = getUser(); + if (user === undefined) return; + + const current = user.lbMemory?.time?.[timeMode2]?.[language]; + write((user) => { + user.lbMemory ??= { time: { "15": { english: 0 }, "60": { english: 0 } } }; + user.lbMemory.time ??= { "15": { english: 0 }, "60": { english: 0 } }; + user.lbMemory.time[timeMode2] ??= {}; + user.lbMemory.time[timeMode2][language] = rank; + }); + + if (api && current !== rank) { + await Ape.users.updateLeaderboardMemory({ + body: { mode, mode2, language, rank }, + }); + } +} + +// --- Optimistic actions: user changes, rolled back if the request fails --- + +/** + * An action that applies `recipe` at once and sends `request`, which resolves + * to the values to keep (the server may change them). On success the recipe + * is applied to the current row and that row is kept: the action's own + * mutation holds the row as it was when the action started, which would drop + * direct writes made while the request was pending. So the recipe must be + * idempotent. + */ +function optimisticAction( + recipe: (user: Snapshot, vars: T) => void, + request: (vars: T) => Promise, +): (vars: T) => Promise { + const action = createOptimisticAction({ + onMutate: (vars) => { + const uid = userCollection.toArray[0]?.uid; + if (uid === undefined) throw new Error("Not signed in"); + userCollection.update(uid, (user) => recipe(user, vars)); + }, + mutationFn: async (vars, { transaction }) => { + const kept = await request(vars); + const modified = copyRow(); + if (modified === undefined) return; + recipe(modified, kept); + userCollection.utils.acceptMutations({ + mutations: transaction.mutations.map((it) => ({ ...it, modified })), + }); + }, + }); + return async (vars) => { + await action(vars).isPersisted.promise; + }; +} + +function throwIfFailed(response: { + status: number; + body: { message: string }; +}): void { + if (response.status !== 200) throw new Error(response.body.message); +} + +const updateNameAction = optimisticAction<{ name: string }>( + (user, { name }) => { + user.name = name; + }, + async (vars) => { + throwIfFailed(await Ape.users.updateName({ body: { name: vars.name } })); + return vars; + }, +); + +export async function updateName(name: string): Promise { + await updateNameAction({ name }); +} + +const unlinkDiscordAction = optimisticAction( + (user) => { + user.discordId = undefined; + user.discordAvatar = undefined; + }, + async () => { + throwIfFailed(await Ape.users.unlinkDiscord()); + return undefined; + }, +); + +export async function unlinkDiscord(): Promise { + await unlinkDiscordAction(undefined); +} + +const setStreakHourOffsetAction = optimisticAction<{ hourOffset: number }>( + (user, { hourOffset }) => { + user.streakHourOffset = hourOffset; + }, + async (vars) => { + throwIfFailed(await Ape.users.setStreakHourOffset({ body: vars })); + return vars; + }, +); + +export async function setStreakHourOffset(hourOffset: number): Promise { + await setStreakHourOffsetAction({ hourOffset }); +} + +type QuoteRating = { language: Language; quoteId: number; rating: number }; + +const rateQuoteAction = optimisticAction( + (user, { language, quoteId, rating }) => { + user.quoteRatings ??= {}; + user.quoteRatings[language] ??= {}; + user.quoteRatings[language][quoteId] = rating; + }, + async (vars) => { + throwIfFailed(await Ape.quotes.addRating({ body: vars })); + return vars; + }, +); + +export async function rateQuote(rating: QuoteRating): Promise { + await rateQuoteAction(rating); +} + +type QuoteFavorite = { language: Language; quoteId: string; favorite: boolean }; + +const setQuoteFavoriteAction = optimisticAction( + (user, { language, quoteId, favorite }) => { + const list = user.favoriteQuotes?.[language] ?? []; + const without = list.filter((id) => id !== quoteId); + user.favoriteQuotes ??= {}; + user.favoriteQuotes[language] = favorite ? [...without, quoteId] : without; + }, + async (vars) => { + const body = { language: vars.language, quoteId: vars.quoteId }; + throwIfFailed( + vars.favorite + ? await Ape.users.addQuoteToFavorites({ body }) + : await Ape.users.removeQuoteFromFavorites({ body }), + ); + return vars; + }, +); + +export async function setQuoteFavorite(favorite: QuoteFavorite): Promise { + await setQuoteFavoriteAction(favorite); +} + +type ProfileUpdate = { + details: UserProfileDetails; + selectedBadgeId: number; +}; + +function applyProfile( + user: Snapshot, + { details, selectedBadgeId }: ProfileUpdate, +): void { + user.details = details; + for (const badge of user.inventory?.badges ?? []) { + badge.selected = badge.id === selectedBadgeId; + } +} + +const updateProfileAction = optimisticAction( + applyProfile, + async (update) => { + const response = await Ape.users.updateProfile({ + body: { ...update.details, selectedBadgeId: update.selectedBadgeId }, + }); + if (response.status !== 200) throw new Error(response.body.message); + // the server sanitizes the details + return { ...update, details: response.body.data ?? update.details }; + }, +); + +export async function updateProfile(update: ProfileUpdate): Promise { + await updateProfileAction(update); +} + +// --- Helpers --- + +function incrementTestActivity( + activity: TestActivity, + timestamp: number, +): void { + const date = new UTCDateMini(timestamp); + const lastDay = new UTCDateMini(activity.lastDay); + if (isSameDay(date, lastDay)) { + const last = activity.testsByDays.length - 1; + activity.testsByDays[last] = (activity.testsByDays[last] ?? 0) + 1; + } else if (isBefore(date, lastDay)) { + throw new Error("cannot alter data in the past."); + } else { + const missedDays = differenceInDays(date, lastDay) - 1; + for (let i = 0; i < missedDays; i++) activity.testsByDays.push(null); + activity.testsByDays.push(1); + activity.lastDay = timestamp; + } +} + +function savePb( + personalBests: PersonalBests, + result: SnapshotResult, +): void { + const { mode, mode2, punctuation, numbers, language, difficulty } = result; + const lazyMode = result.lazyMode; + const pb = { + wpm: result.wpm, + acc: result.acc, + raw: result.rawWpm, + consistency: result.consistency, + timestamp: Date.now(), + lazyMode, + }; + + const byMode = (personalBests[mode] ??= {}) as Record; + const list = (byMode[mode2] ??= []); + const existing = list.find((it) => + matchesPb(it, { punctuation, numbers, difficulty, language, lazyMode }), + ); + if (existing !== undefined) { + Object.assign(existing, pb); + } else { + list.push({ ...pb, language, difficulty, punctuation, numbers }); + } +} + +function matchesPb( + pb: PersonalBest, + key: { + punctuation: boolean; + numbers: boolean; + difficulty: Difficulty; + language: Language; + lazyMode: boolean; + }, +): boolean { + return ( + (pb.punctuation ?? false) === key.punctuation && + (pb.numbers ?? false) === key.numbers && + pb.difficulty === key.difficulty && + pb.language === key.language && + (pb.lazyMode ?? false) === key.lazyMode + ); +} + +export const __testing = { + /** Replaces the row without a download, for tests. */ + setUser(user: Snapshot | undefined): void { + clearUser(); + if (user !== undefined) userCollection.insert(user); + }, +}; diff --git a/frontend/src/ts/components/layout/header/AccountMenu.tsx b/frontend/src/ts/components/layout/header/AccountMenu.tsx index dc41839e4919..fe10ff35bb72 100644 --- a/frontend/src/ts/components/layout/header/AccountMenu.tsx +++ b/frontend/src/ts/components/layout/header/AccountMenu.tsx @@ -2,7 +2,7 @@ import { JSXElement, Show } from "solid-js"; import { get as getServerConfiguration } from "../../../ape/server-configuration"; import { signOut } from "../../../auth"; -import { getSnapshot } from "../../../states/snapshot"; +import { getUser } from "../../../collections/user"; import { Button } from "../../common/Button"; import { NotificationBubble } from "../../common/NotificationBubble"; @@ -58,7 +58,7 @@ export function AccountMenu(props: Props): JSXElement { fixedWidth: true, }} to="/profile/$uidOrName" - params={{ uidOrName: getSnapshot()?.name ?? "" }} + params={{ uidOrName: getUser()?.name ?? "" }} /> getSnapshot()?.needsToChangeName, + () => getUser()?.needsToChangeName, (needsToChange) => { if (needsToChange && !nameChangeAdded) { nameChangeAdded = true; diff --git a/frontend/src/ts/components/modals/DevOptionsModal.tsx b/frontend/src/ts/components/modals/DevOptionsModal.tsx index 0095131a30a6..dc8c4af12ef6 100644 --- a/frontend/src/ts/components/modals/DevOptionsModal.tsx +++ b/frontend/src/ts/components/modals/DevOptionsModal.tsx @@ -4,7 +4,7 @@ import { z } from "zod"; import Ape from "../../ape"; import { refetchInboxCollection } from "../../collections/inbox"; -import { addXp } from "../../db"; +import { addXp } from "../../collections/user"; import { quickLogin } from "../../dev/quick-login"; import { toggleCaretDebug } from "../../elements/caret"; import { hideModal, showModal } from "../../states/modals"; diff --git a/frontend/src/ts/components/modals/EditProfileModal.tsx b/frontend/src/ts/components/modals/EditProfileModal.tsx index eaf81d13ce51..2a26382b8ce5 100644 --- a/frontend/src/ts/components/modals/EditProfileModal.tsx +++ b/frontend/src/ts/components/modals/EditProfileModal.tsx @@ -7,8 +7,8 @@ import { import { createForm } from "@tanstack/solid-form"; import { For } from "solid-js"; -import Ape from "../../ape"; -import { getSnapshot, setSnapshot } from "../../db"; +import { updateProfile } from "../../collections/user"; +import { getSnapshot } from "../../db"; import { invalidateMyProfile } from "../../queries/profile"; import { hideModal } from "../../states/modals"; import { @@ -54,31 +54,18 @@ export function EditProfile() { showActivityOnPublicProfile: value.showActivityOnPublicProfile, }; - const response = await Ape.users.updateProfile({ - body: { - ...updates, + try { + await updateProfile({ + details: updates, selectedBadgeId: value.badgeId, - }, - }); - - if (response.status !== 200) { - showErrorNotification("Failed to update profile", { response }); + }); + } catch (error) { + showErrorNotification("Failed to update profile", { error }); return; } - const newBadges = - snapshot.inventory?.badges?.map((it) => ({ - ...it, - selected: it.id === value.badgeId, - })) ?? []; - form.reset(value); hideModal("EditProfile"); - setSnapshot({ - ...snapshot, - details: response.body.data ?? updates, - inventory: { ...snapshot.inventory, badges: newBadges }, - }); void invalidateMyProfile(); showSuccessNotification("Profile updated"); }, diff --git a/frontend/src/ts/components/modals/QuoteRateModal.tsx b/frontend/src/ts/components/modals/QuoteRateModal.tsx index 3335133f0c08..eddf986bd868 100644 --- a/frontend/src/ts/components/modals/QuoteRateModal.tsx +++ b/frontend/src/ts/components/modals/QuoteRateModal.tsx @@ -1,7 +1,7 @@ import { isSafeNumber } from "@monkeytype/util/numbers"; import { JSXElement, createSignal, For } from "solid-js"; -import Ape from "../../ape"; +import { rateQuote } from "../../collections/user"; import * as DB from "../../db"; import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { hideModalAndClearChain } from "../../states/modals"; @@ -62,27 +62,27 @@ export function QuoteRateModal(): JSXElement { hideModalAndClearChain("QuoteRate"); - showLoaderBar(); - const response = await Ape.quotes.addRating({ - body: { quoteId: quote.id, language: quote.language, rating: rating() }, - }); - hideLoaderBar(); + const oldRating = + DB.getSnapshot()?.quoteRatings?.[quote.language]?.[quote.id]; - if (response.status !== 200) { - showErrorNotification("Failed to submit quote rating", { response }); + showLoaderBar(); + try { + await rateQuote({ + language: quote.language, + quoteId: quote.id, + rating: rating(), + }); + } catch (error) { + hideLoaderBar(); + showErrorNotification("Failed to submit quote rating", { error }); return; } + hideLoaderBar(); - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - const quoteRatings = snapshot.quoteRatings ?? {}; - const languageRatings = quoteRatings?.[quote.language] ?? {}; const stats = quoteStats() ?? {}; - if (isSafeNumber(languageRatings?.[quote.id])) { - const oldRating = quoteRatings[quote.language]?.[quote.id] as number; + if (isSafeNumber(oldRating)) { const diff = rating() - oldRating; - languageRatings[quote.id] = rating(); const newStats = { ratings: stats?.ratings, totalRating: isNaN(stats?.totalRating as number) @@ -94,7 +94,6 @@ export function QuoteRateModal(): JSXElement { updateQuoteStats(newStats); showSuccessNotification("Rating updated"); } else { - languageRatings[quote.id] = rating(); if (isSafeNumber(stats?.ratings) && isSafeNumber(stats.totalRating)) { const newStats = { ratings: stats.ratings + 1, @@ -114,9 +113,6 @@ export function QuoteRateModal(): JSXElement { showSuccessNotification("Rating submitted"); } - snapshot.quoteRatings = quoteRatings; - DB.setSnapshot(snapshot); - const currentStats = quoteStats(); if (currentStats) { const avg = getRatingAverage(currentStats); diff --git a/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx b/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx index 38a35a05ea81..fe2523456c0c 100644 --- a/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx +++ b/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx @@ -1,9 +1,7 @@ import { StreakHourOffsetSchema } from "@monkeytype/schemas/users"; import { createForm } from "@tanstack/solid-form"; -import Ape from "../../ape"; -import { Snapshot } from "../../constants/default-snapshot"; -import { getSnapshot, setSnapshot } from "../../db"; +import { setStreakHourOffset } from "../../collections/user"; import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { hideModal } from "../../states/modals"; import { @@ -34,21 +32,16 @@ export function StreakHourOffsetModal() { showLoaderBar(); - const response = await Ape.users.setStreakHourOffset({ - body: { hourOffset }, - }); - hideLoaderBar(); - - if (response.status !== 200) { - showErrorNotification("Failed to set streak hour offset", { response }); + try { + await setStreakHourOffset(hourOffset); + } catch (error) { + hideLoaderBar(); + showErrorNotification("Failed to set streak hour offset", { error }); hideModal("StreakHourOffset"); return; } + hideLoaderBar(); showSuccessNotification("Streak hour offset set"); - const snap = getSnapshot() as Snapshot; - - snap.streakHourOffset = hourOffset; - setSnapshot(snap); hideModal("StreakHourOffset"); }, })); diff --git a/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx b/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx index d3a5f7068058..2371e6b217c8 100644 --- a/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx +++ b/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx @@ -6,8 +6,8 @@ import { isUsingPasswordAuthentication, reauthenticate, } from "../../../auth"; +import { resetPersonalBests } from "../../../collections/user"; import { resetConfig } from "../../../config/lifecycle"; -import { getSnapshot } from "../../../db"; import { isAuthenticated } from "../../../states/core"; import { showNoticeNotification } from "../../../states/notifications"; import { ExecReturn, showSimpleModal } from "../../../states/simple-modal"; @@ -112,21 +112,7 @@ export function showResetPersonalBestsModal(): void { }; } - const snapshot = getSnapshot(); - if (!snapshot) { - return { - status: "error", - message: "Failed to reset personal bests: no snapshot", - }; - } - - snapshot.personalBests = { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; + resetPersonalBests(); return { status: "success", diff --git a/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx b/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx index 879087b1bc9a..0b452685cf54 100644 --- a/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx @@ -1,5 +1,4 @@ -import Ape from "../../../ape"; -import { getSnapshot, setSnapshot } from "../../../db"; +import { unlinkDiscord } from "../../../collections/user"; import { showSimpleModal } from "../../../states/simple-modal"; export function showUnlinkDiscordModal(): void { @@ -8,27 +7,16 @@ export function showUnlinkDiscordModal(): void { text: "Are you sure you want to unlink your Discord account?", buttonText: "unlink", execFn: async () => { - const snap = getSnapshot(); - if (!snap) { - return { - status: "error", - message: "Failed to unlink Discord: no snapshot", - }; - } - - const response = await Ape.users.unlinkDiscord(); - if (response.status !== 200) { + try { + await unlinkDiscord(); + } catch (error) { return { status: "error", message: "Failed to unlink Discord", - notificationOptions: { response }, + notificationOptions: { error }, }; } - snap.discordAvatar = undefined; - snap.discordId = undefined; - setSnapshot(snap); - return { status: "success", message: "Discord unlinked", diff --git a/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx index 0dbf627f9a60..3a3acb677d4c 100644 --- a/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx @@ -7,7 +7,8 @@ import { isUsingPasswordAuthentication, reauthenticate, } from "../../../auth"; -import { setSnapshot, getSnapshot } from "../../../db"; +import { updateName } from "../../../collections/user"; +import { getSnapshot } from "../../../db"; import { isAuthenticated } from "../../../states/core"; import { showSimpleModal } from "../../../states/simple-modal"; import { remoteValidation } from "../../../utils/remote-validation"; @@ -57,21 +58,16 @@ export function showUpdateNameModal(): void { }; } - const response = await Ape.users.updateName({ - body: { name: newName }, - }); - if (response.status !== 200) { + try { + await updateName(newName); + } catch (error) { return { status: "error", message: "Failed to update name", - notificationOptions: { response }, + notificationOptions: { error }, }; } - snapshot.name = newName; - - setSnapshot(snapshot); - return { status: "success", message: "Name updated", diff --git a/frontend/src/ts/components/modals/alerts/Inbox.tsx b/frontend/src/ts/components/modals/alerts/Inbox.tsx index 07a00c9b04c9..beb6a071b365 100644 --- a/frontend/src/ts/components/modals/alerts/Inbox.tsx +++ b/frontend/src/ts/components/modals/alerts/Inbox.tsx @@ -9,7 +9,7 @@ import { mutateInboxItem, useInboxQuery, } from "../../../collections/inbox"; -import { updateInboxUnreadSize } from "../../../db"; +import { setInboxUnreadSize } from "../../../collections/user"; import { getModalVisibility } from "../../../states/modals"; import { cn } from "../../../utils/cn"; import AsyncContent from "../../common/AsyncContent"; @@ -29,7 +29,7 @@ export function Inbox(): JSXElement { const count = items.filter( (it) => it.status === "unclaimed" || it.status === "unread", ).length; - updateInboxUnreadSize(count); + setInboxUnreadSize(count); }); const inboxSize = () => inboxQuery().length; diff --git a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx index 99e8ee1350a9..6bc2aefdc6aa 100644 --- a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx +++ b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx @@ -1,10 +1,10 @@ import { Show } from "solid-js"; import Ape from "../../../ape"; +import { getUser } from "../../../collections/user"; import { showLoaderBar } from "../../../states/loader-bar"; import { showModal } from "../../../states/modals"; import { showErrorNotification } from "../../../states/notifications"; -import { getSnapshot } from "../../../states/snapshot"; import { Button } from "../../common/Button"; import { Fa } from "../../common/Fa"; import { @@ -29,7 +29,7 @@ export function AccountTab() { } function Discord() { - const isLinked = () => getSnapshot()?.discordId !== undefined; + const isLinked = () => getUser()?.discordId !== undefined; return (
showModal("StreakHourOffset"), }} - disabled={getSnapshot()?.streakHourOffset !== undefined} + disabled={getUser()?.streakHourOffset !== undefined} disabledDescription=<> You have already set your streak hour offset to{" "} - {`${(getSnapshot()?.streakHourOffset ?? 0) > 0 ? "+" : ""} ${getSnapshot()?.streakHourOffset}`} + {`${(getUser()?.streakHourOffset ?? 0) > 0 ? "+" : ""} ${getUser()?.streakHourOffset}`} . /> @@ -153,7 +153,7 @@ function OptOutLeaderboard() { text: "opt out", onClick: () => showOptOutOfLeaderboardsModal(), }} - disabled={getSnapshot()?.lbOptOut === true} + disabled={getUser()?.lbOptOut === true} disabledDescription=<> You have opted out of leaderboards. diff --git a/frontend/src/ts/components/pages/account/MyProfile.tsx b/frontend/src/ts/components/pages/account/MyProfile.tsx index d1b780cb378d..3331d1cea0bf 100644 --- a/frontend/src/ts/components/pages/account/MyProfile.tsx +++ b/frontend/src/ts/components/pages/account/MyProfile.tsx @@ -1,12 +1,19 @@ +import { UserProfile as UserProfileType } from "@monkeytype/schemas/users"; import { JSXElement, Show } from "solid-js"; -import { getSnapshot } from "../../../states/snapshot"; +import { getUser } from "../../../collections/user"; import { UserProfile } from "../profile/UserProfile"; export function MyProfile(): JSXElement { return ( - - {(p) => } + + {/* UserProfile only reads the profile */} + {(p) => ( + + )} ); } diff --git a/frontend/src/ts/components/pages/connections/FriendsList.tsx b/frontend/src/ts/components/pages/connections/FriendsList.tsx index afaf71c64bd9..f0cca74f2414 100644 --- a/frontend/src/ts/components/pages/connections/FriendsList.tsx +++ b/frontend/src/ts/components/pages/connections/FriendsList.tsx @@ -13,10 +13,10 @@ import { findConnectionToUser, rejectConnection, } from "../../../collections/connections"; +import { getUser } from "../../../collections/user"; import { getFriendsListQuery } from "../../../queries/friends"; import { getFormatting } from "../../../states/core"; import { showSimpleModal } from "../../../states/simple-modal"; -import { getSnapshot } from "../../../states/snapshot"; import { formatAge, secondsToString } from "../../../utils/date-and-time"; import { Formatting } from "../../../utils/format"; import { formatXp, getXpDetails } from "../../../utils/levels"; @@ -60,7 +60,7 @@ export function FriendsList() { validation: { isValid: async (name: string) => { - if (name === getSnapshot()?.name) { + if (name === getUser()?.name) { return "That is not how you make friends."; } diff --git a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx index 2735701c7512..3146b73a9fdd 100644 --- a/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx +++ b/frontend/src/ts/components/pages/leaderboard/LeaderboardPage.tsx @@ -7,7 +7,8 @@ import { Show, } from "solid-js"; -import { getSnapshot, updateLbMemory } from "../../../db"; +import { updateLbMemory } from "../../../collections/user"; +import { getSnapshot } from "../../../db"; import { queryClient } from "../../../queries"; import { getLeaderboardQueryOptions, diff --git a/frontend/src/ts/components/pages/profile/ActivityCalendar.tsx b/frontend/src/ts/components/pages/profile/ActivityCalendar.tsx index ba622c2504db..34c287e6a51c 100644 --- a/frontend/src/ts/components/pages/profile/ActivityCalendar.tsx +++ b/frontend/src/ts/components/pages/profile/ActivityCalendar.tsx @@ -26,27 +26,20 @@ export function ActivityCalendar(props: { createEffect(() => (() => { - if ( - (!props.isAccountPage && props.testActivity === undefined) || - (props.isAccountPage && getSnapshot()?.testActivity === undefined) || - element() === undefined - ) { + const testActivity = props.isAccountPage + ? getSnapshot()?.testActivity + : props.testActivity; + if (testActivity === undefined || element() === undefined) { calendar = undefined; clearTestActivity(element()?.native); return; } - if (props.isAccountPage) { - //signals cannot store classes, use the testActivity from the snapshot for now - calendar = getSnapshot()?.testActivity; - } else { - const testActivity = props.testActivity as TestActivity; - calendar = new TestActivityCalendar( - testActivity.testsByDays, - new Date(testActivity.lastDay), - firstDayOfTheWeek, - ); - } + calendar = new TestActivityCalendar( + [...testActivity.testsByDays], + new Date(testActivity.lastDay), + firstDayOfTheWeek, + ); initTestActivity( // oxlint-disable-next-line typescript/no-non-null-assertion diff --git a/frontend/src/ts/components/pages/profile/UserDetails.tsx b/frontend/src/ts/components/pages/profile/UserDetails.tsx index 70fe2fbb8b3b..8598622e1fa2 100644 --- a/frontend/src/ts/components/pages/profile/UserDetails.tsx +++ b/frontend/src/ts/components/pages/profile/UserDetails.tsx @@ -18,9 +18,9 @@ import { addConnection, hasConnection } from "../../../collections/connections"; import { Snapshot } from "../../../constants/default-snapshot"; import { bp } from "../../../states/breakpoints"; import { getUserId, isAuthenticated } from "../../../states/core"; +import { getLastResult } from "../../../states/last-result"; import { showModal } from "../../../states/modals"; import { showNoticeNotification } from "../../../states/notifications"; -import { getLastResult } from "../../../states/snapshot"; import { setUserToReport } from "../../../states/user-report"; import { cn } from "../../../utils/cn"; import { secondsToString } from "../../../utils/date-and-time"; diff --git a/frontend/src/ts/components/pages/test/modes-notice/PbNotice.tsx b/frontend/src/ts/components/pages/test/modes-notice/PbNotice.tsx index baeff783213d..e50331131741 100644 --- a/frontend/src/ts/components/pages/test/modes-notice/PbNotice.tsx +++ b/frontend/src/ts/components/pages/test/modes-notice/PbNotice.tsx @@ -1,9 +1,9 @@ import { createMemo } from "solid-js"; +import { getUser } from "../../../../collections/user"; import { getConfig } from "../../../../config/store"; import { getLocalPB } from "../../../../db"; import { getFormatting, isAuthenticated } from "../../../../states/core"; -import { getSnapshot } from "../../../../states/snapshot"; import { getCurrentQuote } from "../../../../states/test"; import { getActiveFunboxes } from "../../../../test/funbox/list"; import { getMode2 } from "../../../../utils/misc"; @@ -17,7 +17,7 @@ export function PbNotice() { //react on config.funbox const _funbox = getConfig.funbox; //react on new localPB - const _snapshot = getSnapshot(); + const _snapshot = getUser(); const mode2 = getMode2(getConfig, getCurrentQuote()); const pb = getLocalPB( diff --git a/frontend/src/ts/components/pages/test/result/ResultSecondaryStats.tsx b/frontend/src/ts/components/pages/test/result/ResultSecondaryStats.tsx index 1b2d654eab59..2539e902e0dd 100644 --- a/frontend/src/ts/components/pages/test/result/ResultSecondaryStats.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultSecondaryStats.tsx @@ -10,13 +10,12 @@ import { Suspense, } from "solid-js"; -import Ape from "../../../../ape"; import { useTagsLiveQuery } from "../../../../collections/tags"; +import { getUser, setQuoteFavorite } from "../../../../collections/user"; import { getConfig } from "../../../../config/store"; import QuotesController, { Quote, } from "../../../../controllers/quotes-controller"; -import * as DB from "../../../../db"; import { createTrigger } from "../../../../hooks/createTrigger"; import { getFormatting, isAuthenticated } from "../../../../states/core"; import { showEditResultTagsModal } from "../../../../states/edit-result-tags"; @@ -29,7 +28,6 @@ import { } from "../../../../states/quote-rate"; import { showQuoteReportModal } from "../../../../states/quote-report"; import { ResultDetails, resultState } from "../../../../states/result"; -import { getSnapshot } from "../../../../states/snapshot"; import { CompletedResult } from "../../../../states/test"; import { cn } from "../../../../utils/cn"; import { secondsToString } from "../../../../utils/date-and-time"; @@ -298,7 +296,7 @@ function QuoteButtons(props: { quote: Quote }): JSXElement { const isRated = (): boolean => isSafeNumber( - getSnapshot()?.quoteRatings?.[props.quote.language]?.[props.quote.id], + getUser()?.quoteRatings?.[props.quote.language]?.[props.quote.id], ); const rating = (): string => { @@ -307,34 +305,27 @@ function QuoteButtons(props: { quote: Quote }): JSXElement { }; const toggleFavorite = async (): Promise => { - const dbSnapshot = DB.getSnapshot(); - if (!dbSnapshot) return; - const language = props.quote.language; - const quoteId = props.quote.id.toString(); + if (getUser() === undefined) return; const remove = isFavorite(); showLoaderBar(); - const response = remove - ? await Ape.users.removeQuoteFromFavorites({ - body: { language, quoteId }, - }) - : await Ape.users.addQuoteToFavorites({ body: { language, quoteId } }); - hideLoaderBar(); - - addNotificationWithLevel( - response.body.message, - response.status === 200 ? "success" : "error", - ); - if (response.status !== 200) return; - - if (remove) { - const list = dbSnapshot.favoriteQuotes?.[language]; - list?.splice(list.indexOf(quoteId), 1); - } else { - dbSnapshot.favoriteQuotes ??= {}; - dbSnapshot.favoriteQuotes[language] ??= []; - dbSnapshot.favoriteQuotes[language]?.push(quoteId); + try { + await setQuoteFavorite({ + language: props.quote.language, + quoteId: props.quote.id.toString(), + favorite: !remove, + }); + addNotificationWithLevel( + remove ? "Quote removed from favorites" : "Quote added to favorites", + "success", + ); + } catch (error) { + addNotificationWithLevel( + error instanceof Error ? error.message : String(error), + "error", + ); } + hideLoaderBar(); refreshFavorites(); }; diff --git a/frontend/src/ts/components/pages/test/result/TestResult.tsx b/frontend/src/ts/components/pages/test/result/TestResult.tsx index ddbaadf79932..25a3a2be842e 100644 --- a/frontend/src/ts/components/pages/test/result/TestResult.tsx +++ b/frontend/src/ts/components/pages/test/result/TestResult.tsx @@ -10,13 +10,13 @@ import { Switch, } from "solid-js"; +import { getUser } from "../../../../collections/user"; import { getConfig } from "../../../../config/store"; import { useRefWithUtils } from "../../../../hooks/useRefWithUtils"; import { getAdBlock, getCookieBlocker } from "../../../../states/ads"; import { getIsScreenshotting, isAuthenticated } from "../../../../states/core"; import { getGlarsesMode } from "../../../../states/glarses-mode"; import { resultState } from "../../../../states/result"; -import { getSnapshot } from "../../../../states/snapshot"; import { getLastEventLog, getLastResult } from "../../../../states/test"; import { restartTest, retrySavingResult } from "../../../../test/test-logic"; import { setResultElement } from "../../../../test/test-ui"; @@ -145,7 +145,7 @@ function Watermark(): JSXElement { const date = format(new Date(), "dd MMM yyyy HH:mm"); return (
- + {(snapshot) => ( <> diff --git a/frontend/src/ts/constants/default-snapshot.ts b/frontend/src/ts/constants/default-snapshot.ts index 4f5a5e9fad52..7878592a1391 100644 --- a/frontend/src/ts/constants/default-snapshot.ts +++ b/frontend/src/ts/constants/default-snapshot.ts @@ -1,12 +1,12 @@ -import { User, UserProfileDetails } from "@monkeytype/schemas/users"; +import { + TestActivity, + User, + UserProfileDetails, +} from "@monkeytype/schemas/users"; import { getDefaultConfig } from "./default-config"; import { Mode } from "@monkeytype/schemas/shared"; import { Result } from "@monkeytype/schemas/results"; import { Difficulty, FunboxName } from "@monkeytype/schemas/configs"; -import { - ModifiableTestActivityCalendar, - TestActivityCalendar, -} from "../elements/test-activity-calendar"; import { Language } from "@monkeytype/schemas/languages"; export type SnapshotResult = Omit< @@ -69,9 +69,7 @@ export type Snapshot = Omit< isPremium: boolean; streakHourOffset?: number; xp: number; - testActivity?: ModifiableTestActivityCalendar; - testActivityByYear?: { [key: string]: TestActivityCalendar }; - isMiniSnapshot?: never; + testActivity?: TestActivity; }; const defaultSnap = { diff --git a/frontend/src/ts/controllers/quotes-controller.ts b/frontend/src/ts/controllers/quotes-controller.ts index 6f13d384fab2..b0b6c7d4b48a 100644 --- a/frontend/src/ts/controllers/quotes-controller.ts +++ b/frontend/src/ts/controllers/quotes-controller.ts @@ -3,7 +3,7 @@ import { randomElementFromArray, shuffle } from "../utils/arrays"; import { cachedFetchJson } from "../utils/json-data"; import { configEvent } from "../events/config"; import * as DB from "../db"; -import Ape from "../ape"; +import { setQuoteFavorite } from "../collections/user"; import { tryCatch } from "@monkeytype/util/trycatch"; import { Language } from "@monkeytype/schemas/languages"; import { QuoteData } from "@monkeytype/schemas/quotes"; @@ -204,45 +204,11 @@ class QuotesController { } async setQuoteFavorite(quote: Quote, isFavorite: boolean): Promise { - const snapshot = DB.getSnapshot(); - if (!snapshot) { - throw new Error("Snapshot is not available"); - } - - if (!isFavorite) { - // Remove from favorites - const response = await Ape.users.removeQuoteFromFavorites({ - body: { - language: quote.language, - quoteId: `${quote.id}`, - }, - }); - - if (response.status === 200) { - const quoteIndex = snapshot.favoriteQuotes?.[quote.language]?.indexOf( - `${quote.id}`, - ) as number; - snapshot.favoriteQuotes?.[quote.language]?.splice(quoteIndex, 1); - } else { - throw new Error(response.body.message); - } - } else { - // Remove from favorites - const response = await Ape.users.addQuoteToFavorites({ - body: { - language: quote.language, - quoteId: `${quote.id}`, - }, - }); - - if (response.status === 200) { - snapshot.favoriteQuotes ??= {}; - snapshot.favoriteQuotes[quote.language] ??= []; - snapshot.favoriteQuotes[quote.language]?.push(`${quote.id}`); - } else { - throw new Error(response.body.message); - } - } + await setQuoteFavorite({ + language: quote.language, + quoteId: `${quote.id}`, + favorite: isFavorite, + }); } } diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index cb17d5f81dc3..998ad88eb64d 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -7,157 +7,22 @@ import { ModifiableTestActivityCalendar, } from "./elements/test-activity-calendar"; import { showLoaderBar, hideLoaderBar } from "./states/loader-bar"; -import { Badge } from "@monkeytype/schemas/users"; import { Difficulty } from "@monkeytype/schemas/configs"; -import { - Mode, - Mode2, - PersonalBest, - PersonalBests, -} from "@monkeytype/schemas/shared"; -import { - getDefaultSnapshot, - Snapshot, - SnapshotResult, -} from "./constants/default-snapshot"; +import { Mode, Mode2, PersonalBest } from "@monkeytype/schemas/shared"; +import { SnapshotResult } from "./constants/default-snapshot"; import { getFirstDayOfTheWeek } from "./utils/date-and-time"; -import { Language } from "@monkeytype/schemas/languages"; -import { authEvent } from "./events/auth"; -import { configurationPromise } from "./ape/server-configuration"; import { insertLocalResult } from "./collections/results"; -import { - setLastResult, - _setSnapshot as setSolidSnapshot, -} from "./states/snapshot"; +import { addXp, getUser, recordResult, setStreak } from "./collections/user"; +import { setLastResult } from "./states/last-result"; import { XpBreakdown } from "@monkeytype/schemas/results"; -import { setXpBarData } from "./states/header"; import { FunboxMetadata } from "@monkeytype/funbox"; -import { __nonReactive } from "./collections/tags"; -import { fetchUserFromApi } from "./ape/user"; -import { SnapshotInitError } from "./utils/snapshot-init-error"; -import { updateTagsInFilterStorage } from "./states/result-filters"; -let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); +let testActivityByYear: Record | undefined; -export function getSnapshot(): Snapshot | undefined { - return dbSnapshot; -} - -export function setSnapshot( - newSnapshot: Snapshot | undefined, - options?: { dispatchEvent?: boolean }, -): void { - const originalBanned = dbSnapshot?.banned; - const originalVerified = dbSnapshot?.verified; - const lbOptOut = dbSnapshot?.lbOptOut; - - //not allowing user to override these values i guess? - try { - delete newSnapshot?.banned; - } catch {} - try { - delete newSnapshot?.verified; - } catch {} - try { - delete newSnapshot?.lbOptOut; - } catch {} - dbSnapshot = newSnapshot; - if (dbSnapshot) { - dbSnapshot.banned = originalBanned; - dbSnapshot.verified = originalVerified; - dbSnapshot.lbOptOut = lbOptOut; - } - - if (options?.dispatchEvent !== false) { - authEvent.dispatch({ type: "snapshotUpdated", data: { isInitial: false } }); - } - - setSolidSnapshot(newSnapshot); -} - -export async function initSnapshot(): Promise { - //send api request with token that returns tags, presets, and data needed for snap - const snap = getDefaultSnapshot(); - await configurationPromise; - - try { - if (!isAuthenticated()) return false; - - const [userData] = await Promise.all([fetchUserFromApi()]); - - if (userData === null || userData === undefined) { - throw new SnapshotInitError( - `Request was successful but user data is null/undefined`, - 200, - ); - } - - snap.name = userData.name; - snap.personalBests = userData.personalBests; - snap.personalBests ??= { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - - for (const mode of ["time", "words", "quote", "zen", "custom"]) { - snap.personalBests[mode as keyof PersonalBests] ??= {}; - } - - snap.banned = userData.banned; - snap.lbOptOut = userData.lbOptOut; - snap.verified = userData.verified; - snap.discordId = userData.discordId; - snap.discordAvatar = userData.discordAvatar; - snap.needsToChangeName = userData.needsToChangeName; - snap.typingStats = { - timeTyping: userData.timeTyping ?? 0, - startedTests: userData.startedTests ?? 0, - completedTests: userData.completedTests ?? 0, - }; - snap.quoteMod = userData.quoteMod; - snap.favoriteQuotes = userData.favoriteQuotes ?? {}; - snap.quoteRatings = userData.quoteRatings; - snap.details = userData.profileDetails; - snap.addedAt = userData.addedAt; - snap.inventory = userData.inventory; - snap.xp = userData.xp ?? 0; - snap.inboxUnreadSize = userData.inboxUnreadSize ?? 0; - snap.streak = userData?.streak?.length ?? 0; - snap.maxStreak = userData?.streak?.maxLength ?? 0; - snap.isPremium = userData?.isPremium ?? false; - snap.allTimeLbs = userData.allTimeLbs; - - if (userData.testActivity !== undefined) { - snap.testActivity = new ModifiableTestActivityCalendar( - userData.testActivity.testsByDays, - new Date(userData.testActivity.lastDay), - firstDayOfTheWeek, - ); - } - - const hourOffset = userData?.streak?.hourOffset; - snap.streakHourOffset = hourOffset ?? undefined; - - if (userData.lbMemory !== undefined) { - snap.lbMemory = userData.lbMemory; - } - - updateTagsInFilterStorage(userData.tags?.map((it) => it._id) ?? []); - - dbSnapshot = snap; +/** Read-only shim over the user collection, until readers use `getUser()`. */ +export const getSnapshot = getUser; - return dbSnapshot; - } catch (e) { - dbSnapshot = getDefaultSnapshot(); - throw e; - } finally { - setSolidSnapshot(dbSnapshot); - } -} export function getLocalPB( mode: M, mode2: Mode2, @@ -172,9 +37,9 @@ export function getLocalPB( return undefined; } - const pbs = dbSnapshot?.personalBests?.[mode]?.[mode2] as - | PersonalBest[] - | undefined; + const byMode2: Readonly> | undefined = + getUser()?.personalBests?.[mode]; + const pbs = byMode2?.[mode2]; return pbs?.find( (pb) => @@ -186,119 +51,6 @@ export function getLocalPB( ); } -function saveLocalPB( - mode: M, - mode2: Mode2, - punctuation: boolean, - numbers: boolean, - language: Language, - difficulty: Difficulty, - lazyMode: boolean, - wpm: number, - acc: number, - raw: number, - consistency: number, -): void { - if (mode === "quote") return; - if (!dbSnapshot) return; - function cont(): void { - if (!dbSnapshot) return; - let found = false; - - dbSnapshot.personalBests ??= { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - - dbSnapshot.personalBests[mode] ??= { - [mode2]: [], - }; - - dbSnapshot.personalBests[mode][mode2] ??= - [] as unknown as PersonalBests[M][Mode2]; - - ( - dbSnapshot.personalBests[mode][mode2] as unknown as PersonalBest[] - ).forEach((pb) => { - if ( - (pb.punctuation ?? false) === punctuation && - (pb.numbers ?? false) === numbers && - pb.difficulty === difficulty && - pb.language === language && - (pb.lazyMode ?? false) === lazyMode - ) { - found = true; - pb.wpm = wpm; - pb.acc = acc; - pb.raw = raw; - pb.timestamp = Date.now(); - pb.consistency = consistency; - pb.lazyMode = lazyMode; - } - }); - if (!found) { - //nothing found - (dbSnapshot.personalBests[mode][mode2] as unknown as PersonalBest[]).push( - { - language, - difficulty, - lazyMode, - punctuation, - numbers, - wpm, - acc, - raw, - timestamp: Date.now(), - consistency, - }, - ); - } - } - - if (dbSnapshot !== null) { - cont(); - } -} - -export async function updateLbMemory( - mode: M, - mode2: Mode2 | undefined, - language: Language, - rank: number, - api = false, -): Promise { - if (mode2 === undefined) return; - if (mode === "time") { - const timeMode = mode; - const timeMode2 = mode2 as "15" | "60"; - - const snapshot = getSnapshot(); - if (!snapshot) return; - snapshot.lbMemory ??= { - time: { "15": { english: 0 }, "60": { english: 0 } }, - }; - snapshot.lbMemory[timeMode] ??= { - "15": { english: 0 }, - "60": { english: 0 }, - }; - snapshot.lbMemory[timeMode][timeMode2] ??= {}; - const current = snapshot.lbMemory?.[timeMode]?.[timeMode2]?.[language]; - - //this is protected above so not sure why it would be undefined - const mem = snapshot.lbMemory[timeMode][timeMode2]; - mem[language] = rank; - if (api && current !== rank) { - await Ape.users.updateLeaderboardMemory({ - body: { mode, mode2, language, rank }, - }); - } - setSnapshot(snapshot); - } -} - export type SaveLocalResultData = { xp?: number; xpBreakdown?: XpBreakdown; @@ -308,120 +60,36 @@ export type SaveLocalResultData = { }; export function saveLocalResult(data: SaveLocalResultData): void { - const snapshot = getSnapshot(); - if (!snapshot) return; + if (getUser() === undefined) return; if (data.result !== undefined) { void insertLocalResult({ result: data.result }); setLastResult(data.result); - if (snapshot.testActivity !== undefined) { - snapshot.testActivity.increment(new Date(data.result.timestamp)); - } - snapshot.typingStats ??= { - timeTyping: 0, - startedTests: 0, - completedTests: 0, - }; - - const time = - data.result.testDuration + - data.result.incompleteTestSeconds - - data.result.afkDuration; - - snapshot.typingStats.timeTyping += time; - snapshot.typingStats.startedTests += data.result.restartCount + 1; - snapshot.typingStats.completedTests += 1; - - if (data.isPb) { - saveLocalPB( - data.result.mode, - data.result.mode2, - data.result.punctuation, - data.result.numbers, - data.result.language, - data.result.difficulty, - data.result.lazyMode, - data.result.wpm, - data.result.acc, - data.result.rawWpm, - data.result.consistency, - ); - } - } - - if (data.xp !== undefined) { - snapshot.xp ??= 0; - snapshot.xp += data.xp; - } - - if (data.streak !== undefined) { - snapshot.streak = data.streak; - - if (snapshot.streak > snapshot.maxStreak) { - snapshot.maxStreak = snapshot.streak; - } + recordResult(data.result, { isPb: data.isPb ?? false }); } - setSnapshot(snapshot, { - dispatchEvent: false, - }); - if (data.xp !== undefined) { - setXpBarData({ - addedXp: data.xp, - resultingXp: snapshot.xp, - breakdown: data.xpBreakdown, - }); - } -} - -export function addXp(xp: number, breakdown?: XpBreakdown): void { - const snapshot = getSnapshot(); - if (!snapshot) return; - - snapshot.xp ??= 0; - snapshot.xp += xp; - setSnapshot(snapshot, { - dispatchEvent: false, - }); - setXpBarData({ - addedXp: xp, - resultingXp: snapshot.xp, - breakdown: breakdown, - }); -} - -export function updateInboxUnreadSize(newSize: number): void { - const snapshot = getSnapshot(); - if (!snapshot) return; - - snapshot.inboxUnreadSize = newSize; - setSnapshot(snapshot); -} - -export function addBadge(badge: Badge): void { - const snapshot = getSnapshot(); - if (!snapshot) return; - - snapshot.inventory ??= { - badges: [], - }; - snapshot.inventory.badges.push(badge); - setSnapshot(snapshot); + if (data.streak !== undefined) setStreak(data.streak); + if (data.xp !== undefined) addXp(data.xp, data.xpBreakdown); } export async function getTestActivityCalendar( yearString: string, ): Promise { - if (!isAuthenticated() || dbSnapshot === undefined) return undefined; - - if (yearString === "current") return dbSnapshot.testActivity; + const user = getUser(); + if (!isAuthenticated() || user === undefined) return undefined; const currentYear = new Date().getFullYear().toString(); - if (yearString === currentYear) { - return dbSnapshot.testActivity?.getFullYearCalendar(); + if (yearString === "current" || yearString === currentYear) { + if (user.testActivity === undefined) return undefined; + const calendar = new ModifiableTestActivityCalendar( + [...user.testActivity.testsByDays], + new Date(user.testActivity.lastDay), + firstDayOfTheWeek, + ); + return yearString === "current" ? calendar : calendar.getFullYearCalendar(); } - if (dbSnapshot.testActivityByYear === undefined) { + if (testActivityByYear === undefined) { showLoaderBar(); const response = await Ape.users.getTestActivity(); if (response.status !== 200) { @@ -430,7 +98,7 @@ export async function getTestActivityCalendar( return undefined; } - dbSnapshot.testActivityByYear = {}; + testActivityByYear = {}; for (const year in response.body.data) { if (year === currentYear) continue; const testsByDays = response.body.data[year] ?? []; @@ -439,7 +107,7 @@ export async function getTestActivityCalendar( testsByDays.length, ); - dbSnapshot.testActivityByYear[year] = new TestActivityCalendar( + testActivityByYear[year] = new TestActivityCalendar( testsByDays, lastDay, firstDayOfTheWeek, @@ -449,5 +117,5 @@ export async function getTestActivityCalendar( hideLoaderBar(); } - return dbSnapshot.testActivityByYear[yearString]; + return testActivityByYear[yearString]; } diff --git a/frontend/src/ts/queries/profile.ts b/frontend/src/ts/queries/profile.ts index cdbc5eb95376..16705fb39160 100644 --- a/frontend/src/ts/queries/profile.ts +++ b/frontend/src/ts/queries/profile.ts @@ -2,7 +2,7 @@ import { queryOptions } from "@tanstack/solid-query"; import { baseKey } from "./utils/keys"; import Ape from "../ape"; import { queryClient } from "."; -import { getSnapshot } from "../states/snapshot"; +import { getUser } from "../collections/user"; const queryKeys = { root: () => baseKey("profiles"), @@ -33,7 +33,7 @@ export const getUserProfile = (username: string) => }); export async function invalidateMyProfile(): Promise { - const username = getSnapshot()?.name; + const username = getUser()?.name; if (username !== undefined) { await queryClient.resetQueries({ queryKey: queryKeys.profile(username) }); } diff --git a/frontend/src/ts/router/routes.ts b/frontend/src/ts/router/routes.ts index a80d2bb62d02..0dbec3c39627 100644 --- a/frontend/src/ts/router/routes.ts +++ b/frontend/src/ts/router/routes.ts @@ -45,7 +45,7 @@ import { toSelection, } from "../states/leaderboard-selection"; import { SettingsSearchSchema } from "../states/settings-highlight"; -import { getSnapshot } from "../states/snapshot"; +import { getUser } from "../collections/user"; import * as TodayTracker from "../test/today-tracker"; import type { RouterAuth } from "./create-router-auth"; import { @@ -307,7 +307,7 @@ const account = createRoute({ return; } await runRouteLoadingStep(loader, async () => { - if (getSnapshot() === null || getSnapshot() === undefined) { + if (getUser() === undefined) { throw new Error( "Looks like your account data didn't download correctly. Please refresh the page.
If this error persists, please contact support.", ); diff --git a/frontend/src/ts/states/last-result.ts b/frontend/src/ts/states/last-result.ts new file mode 100644 index 000000000000..1e031c3fff6b --- /dev/null +++ b/frontend/src/ts/states/last-result.ts @@ -0,0 +1,8 @@ +import { Mode } from "@monkeytype/schemas/shared"; +import { createSignal } from "solid-js"; +import { SnapshotResult } from "../constants/default-snapshot"; + +/** The user's latest saved result. */ +export const [getLastResult, setLastResult] = createSignal< + SnapshotResult | undefined +>(undefined); diff --git a/frontend/src/ts/states/leaderboard-selection.ts b/frontend/src/ts/states/leaderboard-selection.ts index dae784a5fc5b..c498a8a66f4f 100644 --- a/frontend/src/ts/states/leaderboard-selection.ts +++ b/frontend/src/ts/states/leaderboard-selection.ts @@ -6,7 +6,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage"; import { get as getServerConfiguration } from "../ape/server-configuration"; import { SearchFlagSchema } from "../router/search-params"; -import { getSnapshot } from "./snapshot"; +import { getUser } from "../collections/user"; export const pageSize = 50; @@ -86,7 +86,7 @@ export function toSelection(search: LeaderboardSearch): Selection { if (search.type === undefined) return getLastSelection(); const friendsOnly = (search.friendsOnly ?? false) && - getSnapshot() !== undefined && + getUser() !== undefined && getServerConfiguration()?.connections.enabled !== false; if (search.type === "weekly") { diff --git a/frontend/src/ts/states/snapshot.ts b/frontend/src/ts/states/snapshot.ts deleted file mode 100644 index 8dbff0462baf..000000000000 --- a/frontend/src/ts/states/snapshot.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createStore, reconcile, unwrap } from "solid-js/store"; -import { Snapshot, SnapshotResult } from "../constants/default-snapshot"; -import { createSignal } from "solid-js"; -import { Mode } from "@monkeytype/schemas/shared"; - -export type MiniSnapshot = Omit< - Snapshot, - "testActivity" | "testActivityByYear" | "isMiniSnapshot" -> & { isMiniSnapshot: boolean }; -const [snapshot, updateSnapshot] = createStore<{ - value: MiniSnapshot | undefined; -}>({ value: undefined }); - -/** - * This does not update the DB.snapshot. Use DB.setSnapshot for now. - */ -export function _setSnapshot(newValue: Snapshot | undefined): void { - if (newValue === undefined) { - updateSnapshot("value", undefined); - } else { - const snapshot = structuredClone(unwrap(newValue)); - - delete snapshot.testActivity; - delete snapshot.testActivityByYear; - - const miniSnapshot = { - ...snapshot, - isMiniSnapshot: true, - }; - - updateSnapshot("value", reconcile(miniSnapshot, { merge: true })); - } -} - -export function getSnapshot(): MiniSnapshot | undefined { - return snapshot.value; -} - -export const [getLastResult, setLastResult] = createSignal< - SnapshotResult | undefined ->(undefined); diff --git a/frontend/src/ts/url-actions/actions/discord-oauth.ts b/frontend/src/ts/url-actions/actions/discord-oauth.ts index 35a8d56c1714..30dd83d28ab1 100644 --- a/frontend/src/ts/url-actions/actions/discord-oauth.ts +++ b/frontend/src/ts/url-actions/actions/discord-oauth.ts @@ -2,7 +2,7 @@ // its hash carries the token. A url action (../action.ts) import Ape from "../../ape"; -import * as DB from "../../db"; +import { setDiscordLink } from "../../collections/user"; import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { showErrorNotification, @@ -55,16 +55,5 @@ async function applyDiscordToken( showSuccessNotification(response.body.message); - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - - const { discordId, discordAvatar } = response.body.data; - if (discordId !== undefined) { - snapshot.discordId = discordId; - } - if (discordAvatar !== undefined) { - snapshot.discordAvatar = discordAvatar; - } - - DB.setSnapshot(snapshot); + setDiscordLink(response.body.data); } diff --git a/frontend/src/ts/utils/types.ts b/frontend/src/ts/utils/types.ts index 61d2e37b543a..35fa76c4cded 100644 --- a/frontend/src/ts/utils/types.ts +++ b/frontend/src/ts/utils/types.ts @@ -13,3 +13,9 @@ export type ExactlyOneTrue> = { [P in Exclude]?: false | never; }; }[keyof T]; + +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T;