From 2f7b439c27fed85cacefc07710075171d1f74f4a Mon Sep 17 00:00:00 2001 From: chinmay dhar dwivedi <146489427+chinmaydwivedi@users.noreply.github.com> Date: Tue, 12 May 2026 17:24:48 +0530 Subject: [PATCH 1/5] fix(backend): optimize leaderboard counts and PB/log updates (@chinmaydwivedi) (#7837) ## Summary - **Efficient friends leaderboard count**: Replaced full document fetch + `.length` with a `$count` aggregation stage in `getCount()` for the friends-only path. - **Atomic PB update**: Merged two separate `updateOne` calls in `checkIfPb()` into a single update, so `personalBests` and optional `lbPersonalBests` are written together. - **Remove debug console.log**: Removed the `console.log` in `addImportantLog()` that printed event name, payload, and UID. ## Files Changed | File | Change | |------|--------| | `backend/src/dal/leaderboards.ts` | Friends count now uses `$count` aggregation | | `backend/src/dal/user.ts` | Atomic PB update (streak migration guard retained) | | `backend/src/dal/logs.ts` | Remove debug `console.log` | ## Test Plan - [x] Verify friends leaderboard count returns correct values with `$count` - [x] Verify PB updates persist both `personalBests` and `lbPersonalBests` in one update - [x] Run existing test suite (`pnpm test-be`) ## Notes - Tests were not run in this local environment because the repo requires Node `>=24 <25` and the current environment is Node `v20.16.0`. Co-authored-by: Jack --- backend/src/dal/leaderboards.ts | 19 ++++++++++--------- backend/src/dal/logs.ts | 1 - backend/src/dal/user.ts | 15 ++++++--------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index f2a48658f6af..2bc20a4329ce 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -115,15 +115,16 @@ export async function getCount( cachedCounts.set(key, count); return count; } else { - return ( - await aggregateWithAcceptedConnections( - { - collectionName: getCollectionName({ language, mode, mode2 }), - uid, - }, - [{ $project: { _id: true } }], - ) - ).length; + const result = await aggregateWithAcceptedConnections<{ + total: number; + }>( + { + collectionName: getCollectionName({ language, mode, mode2 }), + uid, + }, + [{ $count: "total" }], + ); + return result[0]?.total ?? 0; } } } diff --git a/backend/src/dal/logs.ts b/backend/src/dal/logs.ts index 9680ab9c761d..a7fb5641438a 100644 --- a/backend/src/dal/logs.ts +++ b/backend/src/dal/logs.ts @@ -56,7 +56,6 @@ export async function addImportantLog( message: string | Record, uid = "", ): Promise { - console.log("log", event, message, uid); await insertIntoDb(event, message, uid, true); } diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 8349bf8bb674..ada92f0ee764 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -493,17 +493,14 @@ export async function checkIfPb( if (!pb.isPb) return false; - await getUsersCollection().updateOne( - { uid }, - { $set: { personalBests: pb.personalBests } }, - ); - + const setFields: Record = { + personalBests: pb.personalBests, + }; if (pb.lbPersonalBests) { - await getUsersCollection().updateOne( - { uid }, - { $set: { lbPersonalBests: pb.lbPersonalBests } }, - ); + setFields["lbPersonalBests"] = pb.lbPersonalBests; } + + await getUsersCollection().updateOne({ uid }, { $set: setFields }); return true; } From 964fcf02e3009b2d91e000f7c1bc50a8f902e6ff Mon Sep 17 00:00:00 2001 From: ares?? <146799833+AzureNightlock@users.noreply.github.com> Date: Tue, 12 May 2026 17:25:15 +0530 Subject: [PATCH 2/5] fix(profile-search): prevent form.reset() from clearing first character on input (@AzureNightlock) (#7851) ### Description This PR: Fixes the first character being skipped on input by only clearing the form when the page isn't open. Changes Made: * form.reset() runs only when isOpen is false, Hence clearing the form when the user leaves the page. When the user comes back to the page, the form is cleared instead of clearing when the user first enters the page Additional Changes Made: * Removed the unused catch parameter ("e") from onChangeAsync. * showIndicator doesn't show up when the field is blank ### Checks - [x] Check if any open issues are related to this PR; if so, be sure to tag them below. - [x] Make sure the PR title follows the Conventional Commits standard. (https://www.conventionalcommits.org for more info) - [x] Make sure to include your GitHub username prefixed with @ inside parentheses at the end of the PR title. Closes #7850 Co-authored-by: Christian Fehmer --- .../src/ts/components/pages/profile/ProfileSearchPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx b/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx index 3b168dde5153..d3989caae5e5 100644 --- a/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx +++ b/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx @@ -42,10 +42,11 @@ export function ProfileSearchPage(): JSXElement { createEffect(() => { if (isOpen()) { - form.reset(); requestAnimationFrame(() => { inputEl()?.qs("input")?.focus({ preventScroll: true }); }); + } else { + form.reset(); } }); @@ -77,7 +78,7 @@ export function ProfileSearchPage(): JSXElement { getUserProfile(field.value), ); return result !== null ? undefined : "Unknown user"; - } catch (e) { + } catch { return "Unknown user"; } }, From e2f5e2c63a955681ab6abfe147d5a240a58627bd Mon Sep 17 00:00:00 2001 From: Darshan Paccha Date: Tue, 12 May 2026 17:28:58 +0530 Subject: [PATCH 3/5] fix(typed effect): allow fade effect with reduced motion (@d1rshan) (#7849) ### Current Behavior for Reduced Motion 1. keep - Letters stay (No change) 2. hide - Letters vanish instantly (No change) 3. fade - Letters stay **(Effect is lost)** 4. dots - Letters become dots instantly (has a Fallback) ### Changes This PR makes sure that `fade` effect works for reduced motion by adding it to the global exclude list for reduced motion. Closes #7847 --- frontend/src/styles/media-queries.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/styles/media-queries.scss b/frontend/src/styles/media-queries.scss index e9358c2d5290..b080cdc9b3b7 100644 --- a/frontend/src/styles/media-queries.scss +++ b/frontend/src/styles/media-queries.scss @@ -43,7 +43,12 @@ body { @media (prefers-reduced-motion) { body:not(.ignore-reduced-motion) - *:not(.fa-spin, .animate-\[loader\], .preloader) { + *:not( + .fa-spin, + .animate-\[loader\], + .preloader, + .typed-effect-fade .word.typed + ) { animation: none !important; transition: none !important; From c5da846c00debb3ae55dd8c76380849fabfec1a6 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 12 May 2026 14:00:11 +0200 Subject: [PATCH 4/5] ci(assets): add github step summary (@fehmer) (#7922) image --- frontend/package.json | 1 + frontend/scripts/check-assets.ts | 25 ++++++++- pnpm-lock.yaml | 96 +++++++++++++------------------- 3 files changed, 62 insertions(+), 60 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index e90f7a3721c0..59d12ce58300 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,6 +76,7 @@ "zod-urlsearchparams": "0.0.16" }, "devDependencies": { + "@actions/core": "3.0.1", "@eslint/json": "1.2.0", "@fortawesome/fontawesome-free": "5.15.4", "@monkeytype/oxlint-config": "workspace:*", diff --git a/frontend/scripts/check-assets.ts b/frontend/scripts/check-assets.ts index f1246caa68fd..a0b267d5ee31 100644 --- a/frontend/scripts/check-assets.ts +++ b/frontend/scripts/check-assets.ts @@ -22,6 +22,9 @@ import { ChallengeSchema, Challenge } from "@monkeytype/schemas/challenges"; import { LayoutObject, LayoutObjectSchema } from "@monkeytype/schemas/layouts"; import { QuoteDataSchema, QuoteData } from "@monkeytype/schemas/quotes"; import { clickSoundConfig } from "../src/ts/constants/sounds"; +import * as ghCore from "@actions/core"; + +const stepSummary = ghCore.summary; class Problems { private type: string; @@ -51,10 +54,21 @@ class Problems { return Object.keys(this.problems).length !== 0; } public toString(): string { + stepSummary.addHeading(`${this.type} Checks`, 2); if (!this.hasError()) { + stepSummary.addRaw("✅ all checks passed").addEOL(); return `${this.type} are all \u001b[32mvalid\u001b[0m`; } + Object.entries(this.problems).forEach(([key, problems]) => { + let label: string = this.labels[key as T] ?? `${key}`; + stepSummary + .addRaw(`❌ ${label}`) + .addEOL() + .addList(problems as string[]) + .addEOL(); + }); + return ( `${this.type} are \u001b[31minvalid\u001b[0m\n` + Object.entries(this.problems) @@ -513,8 +527,15 @@ async function main(): Promise { } if (tasks.size > 0) { - await Promise.all([...tasks].map(async (validator) => validator())); - return; + const results = await Promise.allSettled( + [...tasks].map(async (validator) => validator()), + ); + + await stepSummary.write(); + + if (results.find((it) => it.status === "rejected") !== undefined) { + throw new Error("One or more checks failed."); + } } } void main(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77e98b7f1bd5..1d50e9ca12b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,9 @@ importers: specifier: 0.0.16 version: 0.0.16(zod@3.23.8) devDependencies: + '@actions/core': + specifier: 3.0.1 + version: 3.0.1 '@eslint/json': specifier: 1.2.0 version: 1.2.0 @@ -677,7 +680,7 @@ importers: version: 6.0.2 vitest: specifier: 4.1.0 - version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) packages/funbox: dependencies: @@ -840,6 +843,18 @@ packages: '@acemir/cssom@0.9.30': resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + '@actions/core@3.0.1': + resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + + '@actions/exec@3.0.0': + resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} + + '@actions/http-client@4.0.1': + resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + + '@actions/io@3.0.2': + resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} @@ -10885,6 +10900,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + turbo-darwin-64@2.7.5: resolution: {integrity: sha512-nN3wfLLj4OES/7awYyyM7fkU8U8sAFxsXau2bYJwAWi6T09jd87DgHD8N31zXaJ7LcpyppHWPRI2Ov9MuZEwnQ==} cpu: [x64] @@ -11698,6 +11717,22 @@ snapshots: '@acemir/cssom@0.9.30': {} + '@actions/core@3.0.1': + dependencies: + '@actions/exec': 3.0.0 + '@actions/http-client': 4.0.1 + + '@actions/exec@3.0.0': + dependencies: + '@actions/io': 3.0.2 + + '@actions/http-client@4.0.1': + dependencies: + tunnel: 0.0.6 + undici: 6.24.0 + + '@actions/io@3.0.2': {} + '@adobe/css-tools@4.4.4': {} '@anatine/zod-openapi@1.14.2(openapi3-ts@2.0.2)(zod@3.23.8)': @@ -16054,14 +16089,6 @@ snapshots: optionalDependencies: vite: 7.3.2(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.0(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 4.1.0 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.0(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.70.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.0 @@ -23154,6 +23181,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel@0.0.6: {} + turbo-darwin-64@2.7.5: optional: true @@ -23516,26 +23545,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0) - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.9.1 - esbuild: 0.25.0 - fsevents: 2.3.3 - jiti: 2.6.1 - sass: 1.98.0 - terser: 5.46.2 - tsx: 4.21.0 - yaml: 2.8.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.70.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -23664,35 +23673,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.25.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.1 - happy-dom: 20.8.9 - jsdom: 27.4.0 - transitivePeerDependencies: - - msw - vitest@4.1.0(@types/node@24.9.1)(happy-dom@20.8.9)(jsdom@27.4.0)(vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@24.9.1)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.70.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 From a4cfbb4f1eb0c2815afb7c7b40218da954d693f9 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 12 May 2026 18:10:52 +0200 Subject: [PATCH 5/5] refactor: decouple collections from db/snapshot (@fehmer) (#7917) --- .../controllers/preset-controller.spec.ts | 1 + frontend/src/ts/ape/user.ts | 54 +++++++++++++++++++ frontend/src/ts/auth.tsx | 6 ++- frontend/src/ts/collections/presets.ts | 34 ++++++------ .../ts/collections/result-filter-presets.ts | 20 +++---- frontend/src/ts/collections/tags.ts | 29 ++++------ frontend/src/ts/config/remote.ts | 2 +- frontend/src/ts/db.ts | 49 ++++------------- frontend/src/ts/queries/index.ts | 10 ++-- frontend/src/ts/utils/snapshot-init-error.ts | 8 +++ 10 files changed, 118 insertions(+), 95 deletions(-) create mode 100644 frontend/src/ts/ape/user.ts create mode 100644 frontend/src/ts/utils/snapshot-init-error.ts diff --git a/frontend/__tests__/controllers/preset-controller.spec.ts b/frontend/__tests__/controllers/preset-controller.spec.ts index ed1ed72e3ab4..ad250c5f494e 100644 --- a/frontend/__tests__/controllers/preset-controller.spec.ts +++ b/frontend/__tests__/controllers/preset-controller.spec.ts @@ -56,6 +56,7 @@ describe("PresetController", () => { dbGetSnapshotMock.mockReturnValue({} as any); configApplyMock.mockResolvedValue(); + tagsClearMock.mockResolvedValue(); }); it("should apply for full preset", async () => { diff --git a/frontend/src/ts/ape/user.ts b/frontend/src/ts/ape/user.ts new file mode 100644 index 000000000000..2b15084c1a70 --- /dev/null +++ b/frontend/src/ts/ape/user.ts @@ -0,0 +1,54 @@ +import { GetUserResponse } from "@monkeytype/contracts/users"; +import Ape from "."; +import { createEffectOn } from "../hooks/effects"; +import { isAuthenticated } from "../states/core"; +import { SnapshotInitError } from "../utils/snapshot-init-error"; + +type CacheType = GetUserResponse["data"]; + +let fetchPromise: Promise | null = null; +let cache: CacheType | undefined = undefined; + +export async function fetchUserFromApi(): Promise { + await sync(); + return cache; +} + +async function sync(): Promise { + if (!isAuthenticated()) { + return; + } + + if (cache !== undefined) return; + + fetchPromise ??= (async () => { + const response = await Ape.users.get(); + + if (response.status !== 200) { + throw new SnapshotInitError( + `${response.body.message} (user)`, + response.status, + ); + } + + cache = response.body.data; + })(); + + try { + await fetchPromise; + } finally { + fetchPromise = null; + } +} + +function reset(): void { + cache = undefined; + fetchPromise = null; +} + +// clear cache + reset promise on logout +createEffectOn(isAuthenticated, (isAuthenticated) => { + if (!isAuthenticated) { + reset(); + } +}); diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 9986737851ba..b0e684ec60bd 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -9,6 +9,7 @@ import { } from "firebase/auth"; import Ape from "./ape"; +import { waitForPresetsReady } from "./collections/presets"; import { updateFromServer as updateConfigFromServer } from "./config/remote"; import * as DB from "./db"; import { authEvent } from "./events/auth"; @@ -30,6 +31,7 @@ import { showSuccessNotification, } from "./states/notifications"; import { createErrorMessage } from "./utils/error"; +import { SnapshotInitError } from "./utils/snapshot-init-error"; export type AuthResult = | { @@ -64,6 +66,8 @@ async function getDataAndInit(): Promise { try { console.log("getting account data"); const snapshot = await DB.initSnapshot(); + //TODO: always load presets for now, remove when __nonReactive is removed from presets collection + await waitForPresetsReady(); if (snapshot === false) { throw new Error( @@ -77,7 +81,7 @@ async function getDataAndInit(): Promise { return true; } catch (error) { console.error(error); - if (error instanceof DB.SnapshotInitError) { + if (error instanceof SnapshotInitError) { if (error.responseCode === 429) { showNoticeNotification( "Doing so will save you bandwidth, make the next test be ready faster and will not sign you out (which could mean your new personal best would not save to your account).", diff --git a/frontend/src/ts/collections/presets.ts b/frontend/src/ts/collections/presets.ts index a06a7318895b..98a9a0b6725b 100644 --- a/frontend/src/ts/collections/presets.ts +++ b/frontend/src/ts/collections/presets.ts @@ -10,6 +10,8 @@ import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { ConfigGroupName } from "@monkeytype/schemas/configs"; import { tempId } from "./utils/misc"; +import { isAuthenticated } from "../states/core"; +import { replaceUnderscoresWithSpaces } from "../utils/strings"; export type PresetItem = Preset; @@ -29,13 +31,21 @@ export function usePresetsLiveQuery() { const presetsCollection = createCollection( queryCollectionOptions({ staleTime: Infinity, - startSync: true, queryKey: queryKeys.root(), - queryClient, getKey: (it) => it._id, queryFn: async () => { - return [] as PresetItem[]; + if (!isAuthenticated()) return []; + const response = await Ape.presets.get(); + + if (response.status !== 200) { + throw new Error("Error fetching presets:" + response.body.message); + } + + return response.body.data.map((it) => ({ + ...it, + name: replaceUnderscoresWithSpaces(it.name), + })); }, }), ); @@ -149,6 +159,9 @@ const actions = { }; // --- Public API --- +export async function waitForPresetsReady(): Promise { + await presetsCollection.stateWhenReady(); +} function getPresets(): PresetItem[] { return [...presetsCollection.values()].sort((a, b) => @@ -160,21 +173,6 @@ function getPreset(id: string): PresetItem | undefined { return presetsCollection.get(id); } -export function fillPresetsCollection(presets: Preset[]): void { - const presetItems = presets.map((preset) => ({ - _id: preset._id, - name: preset.name.replace(/_/g, " "), - config: preset.config, - settingGroups: preset.settingGroups, - })); - - presetsCollection.utils.writeBatch(() => { - presetItems.forEach((item) => { - presetsCollection.utils.writeInsert(item); - }); - }); -} - export async function addPreset( params: ActionType["addPreset"], ): Promise { diff --git a/frontend/src/ts/collections/result-filter-presets.ts b/frontend/src/ts/collections/result-filter-presets.ts index d283c86d0a6c..774771d67930 100644 --- a/frontend/src/ts/collections/result-filter-presets.ts +++ b/frontend/src/ts/collections/result-filter-presets.ts @@ -13,6 +13,7 @@ import { replaceUnderscoresWithSpaces, } from "../utils/strings"; import { tempId } from "./utils/misc"; +import { fetchUserFromApi } from "../ape/user"; const queryKeys = { root: () => [...baseKey("resultFilterPresets", { isUserSpecific: true })], @@ -27,8 +28,13 @@ const resultFilterPresetsCollection = createCollection( queryClient, getKey: (it) => it._id, queryFn: async () => { - //return emtpy array. We load the user with the snapshot and fill the collection from there - return [] as ResultFilters[]; + const userData = await fetchUserFromApi(); + if (userData === undefined) return []; + + return (userData.resultFilterPresets ?? []).map((it) => ({ + ...it, + name: replaceUnderscoresWithSpaces(it.name), + })); }, }), ); @@ -118,13 +124,3 @@ export async function deleteResultFilterPreset( const transaction = actions.deleteResultFilterPreset(params); await transaction.isPersisted.promise; } - -export function fillResultFilterPresetsCollection( - presets: ResultFilters[], -): void { - resultFilterPresetsCollection.utils.writeBatch(() => { - presets - .map((it) => ({ ...it, name: replaceUnderscoresWithSpaces(it.name) })) - .forEach((it) => resultFilterPresetsCollection.utils.writeInsert(it)); - }); -} diff --git a/frontend/src/ts/collections/tags.ts b/frontend/src/ts/collections/tags.ts index 311d148aff1f..b4f3bf45b174 100644 --- a/frontend/src/ts/collections/tags.ts +++ b/frontend/src/ts/collections/tags.ts @@ -21,6 +21,7 @@ import { import { Difficulty } from "@monkeytype/schemas/configs"; import { Language } from "@monkeytype/schemas/languages"; import { tempId } from "./utils/misc"; +import { fetchUserFromApi } from "../ape/user"; export type TagItem = UserTag & { active: boolean }; @@ -31,13 +32,19 @@ const queryKeys = { const tagsCollection = createCollection( queryCollectionOptions({ staleTime: Infinity, - startSync: true, queryKey: queryKeys.root(), - queryClient, getKey: (it) => it._id, queryFn: async () => { - return [] as TagItem[]; + const activeIds = activeTagsLS.get(); + const userData = await fetchUserFromApi(); + if (userData === undefined) return []; + + return (userData.tags ?? []).map((tag) => ({ + ...tag, + name: tag.name.replace(/_/g, " "), + active: activeIds.includes(tag._id), + })); }, }), ); @@ -205,22 +212,6 @@ function getActiveTags(): TagItem[] { return getTags().filter((tag) => tag.active); } -export function fillTagsCollection(userTags: UserTag[]): void { - const activeIds = activeTagsLS.get(); - - const tagItems = userTags.map((tag) => ({ - ...tag, - name: tag.name.replace(/_/g, " "), - active: activeIds.includes(tag._id), - })); - - tagsCollection.utils.writeBatch(() => { - tagItems.forEach((item) => { - tagsCollection.utils.writeInsert(item); - }); - }); -} - // --- Active state --- const activeTagsLS = new LocalStorageWithSchema({ diff --git a/frontend/src/ts/config/remote.ts b/frontend/src/ts/config/remote.ts index 2fb74f71400b..91f27a53b044 100644 --- a/frontend/src/ts/config/remote.ts +++ b/frontend/src/ts/config/remote.ts @@ -4,7 +4,7 @@ import { migrateConfig } from "./utils"; import { applyConfig } from "./lifecycle"; import { saveFullConfigToLocalStorage } from "./persistence"; import Ape from "../ape"; -import { SnapshotInitError } from "../db"; +import { SnapshotInitError } from "../utils/snapshot-init-error"; import { getDefaultConfig } from "../constants/default-config"; import { Config } from "./store"; diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 8538ccd13799..02f2119d7913 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -33,7 +33,6 @@ import { } from "./ape/server-configuration"; import { Connection } from "@monkeytype/schemas/connections"; import { insertLocalResult } from "./collections/results"; -import { fillResultFilterPresetsCollection } from "./collections/result-filter-presets"; import { setLastResult, setSnapshot as setSolidSnapshot, @@ -41,22 +40,14 @@ import { import { XpBreakdown } from "@monkeytype/schemas/results"; import { setXpBarData } from "./states/header"; import { FunboxMetadata } from "@monkeytype/funbox"; -import { fillTagsCollection, __nonReactive } from "./collections/tags"; +import { __nonReactive } from "./collections/tags"; import { updateTagsInFilterStorage } from "./states/result-filters"; -import { fillPresetsCollection } from "./collections/presets"; +import { fetchUserFromApi } from "./ape/user"; +import { SnapshotInitError } from "./utils/snapshot-init-error"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); -export class SnapshotInitError extends Error { - public responseCode: number; - constructor(message: string, responseCode: number) { - super(message); - this.name = "SnapshotInitError"; - this.responseCode = responseCode; - } -} - export function getSnapshot(): Snapshot | undefined { return dbSnapshot; } @@ -105,25 +96,12 @@ export async function initSnapshot(): Promise { ? Ape.connections.get() : { status: 200, body: { message: "", data: [] } }; - const [userResponse, presetsResponse, connectionsResponse] = - await Promise.all([ - Ape.users.get(), - Ape.presets.get(), - connectionsRequest, - ]); + const [userData, connectionsResponse] = await Promise.all([ + fetchUserFromApi(), + + connectionsRequest, + ]); - if (userResponse.status !== 200) { - throw new SnapshotInitError( - `${userResponse.body.message} (user)`, - userResponse.status, - ); - } - if (presetsResponse.status !== 200) { - throw new SnapshotInitError( - `${presetsResponse.body.message} (presets)`, - presetsResponse.status, - ); - } if (connectionsResponse.status !== 200) { throw new SnapshotInitError( `${connectionsResponse.body.message} (connections)`, @@ -131,13 +109,11 @@ export async function initSnapshot(): Promise { ); } - const userData = userResponse.body.data; - const presetsData = presetsResponse.body.data; const connectionsData = connectionsResponse.body.data; - if (userData === null) { + if (userData === null || userData === undefined) { throw new SnapshotInitError( - `Request was successful but user data is null`, + `Request was successful but user data is null/undefined`, 200, ); } @@ -197,14 +173,9 @@ export async function initSnapshot(): Promise { snap.customThemes = userData.customThemes ?? []; - fillTagsCollection(userData.tags ?? []); - fillPresetsCollection(presetsData ?? []); - - fillResultFilterPresetsCollection(userData.resultFilterPresets ?? []); updateTagsInFilterStorage(userData.tags?.map((it) => it._id) ?? []); snap.connections = convertConnections(connectionsData); - dbSnapshot = snap; return dbSnapshot; diff --git a/frontend/src/ts/queries/index.ts b/frontend/src/ts/queries/index.ts index 5a8c223d5853..3cacc98d43b3 100644 --- a/frontend/src/ts/queries/index.ts +++ b/frontend/src/ts/queries/index.ts @@ -4,9 +4,9 @@ import { isAuthenticated } from "../states/core"; export const queryClient = new QueryClient(); -createEffectOn(isAuthenticated, (state) => { - if (!state) { - console.debug("QueryClient clear all user related queries."); - void queryClient.resetQueries({ queryKey: ["user"] }); - } +createEffectOn(isAuthenticated, () => { + //reset user related queries and collections whenever the state changes. + //for legacy access we initialize some user-bound collections without a user being present (e.g. tags, presets). + //to avoid empty collections after login, reset the queries on every state change not just logout + void queryClient.resetQueries({ queryKey: ["user"] }); }); diff --git a/frontend/src/ts/utils/snapshot-init-error.ts b/frontend/src/ts/utils/snapshot-init-error.ts new file mode 100644 index 000000000000..83774f40e1e7 --- /dev/null +++ b/frontend/src/ts/utils/snapshot-init-error.ts @@ -0,0 +1,8 @@ +export class SnapshotInitError extends Error { + public responseCode: number; + constructor(message: string, responseCode: number) { + super(message); + this.name = "SnapshotInitError"; + this.responseCode = responseCode; + } +}