From 25bc7a21d4fe44ba3d7b7f1dbeb15e489a73200c Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:48:23 +0000 Subject: [PATCH] perf(dashboard): optimize random book lookups and scroller batching Add index on books table for random dashboard scroller queries, batch dashboard scroller requests, instrument pg pool logging, and cover dashboard scroller batching in e2e tests. --- .../__tests__/useBookViewWindow.spec.ts | 25 +- .../__tests__/useBookWindow.spec.ts | 12 + .../book/composables/useBookViewWindow.ts | 10 +- .../book/composables/useBookWindow.ts | 13 +- .../__tests__/useDashboardScroller.spec.ts | 63 +- .../composables/useDashboardScroller.ts | 77 +- .../statistics/api/statistics.api.test.ts | 42 + .../features/statistics/api/statistics.api.ts | 31 +- .../views/EntityManagerView.spec.ts | 23 + .../views/EntityManagerView.vue | 26 +- client/src/views/SmartScopeView.vue | 10 +- client/vite.config.ts | 4 + packages/types/src/dashboard.ts | 24 + scripts/e2e/suite-registry.mjs | 19 + server/src/db/db.module.ts | 4 +- server/src/db/instrumented-pg-pool.test.ts | 61 + server/src/db/instrumented-pg-pool.ts | 60 + ...0063_add_dashboard_random_lookup_index.sql | 2 + .../src/db/migrations/meta/0063_snapshot.json | 16001 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema/books.ts | 2 +- .../src/modules/book/book.repository.test.ts | 12 + server/src/modules/book/book.repository.ts | 15 + server/src/modules/book/book.service.ts | 12 + .../dashboard/dashboard.controller.test.ts | 14 + .../modules/dashboard/dashboard.controller.ts | 9 +- .../dashboard/dashboard.repository.test.ts | 133 +- .../modules/dashboard/dashboard.repository.ts | 137 +- .../dashboard/dashboard.service.test.ts | 66 + .../modules/dashboard/dashboard.service.ts | 106 +- .../dto/dashboard-scroller-batch.dto.test.ts | 54 + .../dto/dashboard-scroller-batch.dto.ts | 48 + .../smart-scope/smart-scope.service.test.ts | 45 + .../smart-scope/smart-scope.service.ts | 12 +- server/test/authorization-matrix.e2e-spec.ts | 63 +- server/test/dashboard-random.e2e-spec.ts | 71 + .../route-inventory/manifest.json | 4 +- .../route-inventory/routes-03.json | 12 + 38 files changed, 17212 insertions(+), 117 deletions(-) create mode 100644 server/src/db/instrumented-pg-pool.test.ts create mode 100644 server/src/db/instrumented-pg-pool.ts create mode 100644 server/src/db/migrations/0063_add_dashboard_random_lookup_index.sql create mode 100644 server/src/db/migrations/meta/0063_snapshot.json create mode 100644 server/src/modules/dashboard/dto/dashboard-scroller-batch.dto.test.ts create mode 100644 server/src/modules/dashboard/dto/dashboard-scroller-batch.dto.ts create mode 100644 server/test/dashboard-random.e2e-spec.ts diff --git a/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts b/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts index ef5f0a02c..77855e9a1 100644 --- a/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts +++ b/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts @@ -87,10 +87,11 @@ async function flush() { await nextTick() } -function setup(options: { scope?: number | null; viewMode?: string; collapse?: boolean; q?: string; railEnabled?: boolean } = {}) { +function setup(options: { scope?: number | null; viewMode?: string; collapse?: boolean; q?: string; railEnabled?: boolean; enabled?: boolean } = {}) { const scopeId = ref(options.scope === undefined ? 1 : options.scope) const viewMode = ref(options.viewMode ?? 'table') const railEnabled = ref(options.railEnabled ?? true) + const enabled = ref(options.enabled ?? true) const collapseEnabled = ref(options.collapse ?? false) const q = ref(options.q ?? '') const win = useBookViewWindow({ @@ -101,8 +102,9 @@ function setup(options: { scope?: number | null; viewMode?: string; collapse?: b railEnabled, collapseEnabled, q, + enabled, }) - return { win, scopeId, viewMode, railEnabled, collapseEnabled, q } + return { win, scopeId, viewMode, railEnabled, enabled, collapseEnabled, q } } function listRequests(): number[] { @@ -162,6 +164,25 @@ describe('useBookViewWindow', () => { expect(win.total.value).toBe(0) }) + it('waits until enabled and sends only the prepared sort', async () => { + mockApi(250) + const { win, enabled } = setup({ enabled: false }) + await flush() + + expect(fetchMock).not.toHaveBeenCalled() + + win.sort.value = [{ field: 'author', dir: 'desc' }] + enabled.value = true + await flush() + + const listCalls = fetchMock.mock.calls.filter(([url]) => !url.includes('jump-buckets')) + expect(listCalls).toHaveLength(1) + expect(JSON.parse(String(listCalls[0]?.[1]?.body))).toMatchObject({ + sort: [{ field: 'author', dir: 'desc' }], + pagination: { page: 0 }, + }) + }) + it('loadMorePrefix resolves only after the next contiguous chunk has loaded', async () => { mockApi(500) const { win } = setup() diff --git a/client/src/features/book/composables/__tests__/useBookWindow.spec.ts b/client/src/features/book/composables/__tests__/useBookWindow.spec.ts index dd37bbf5d..7d54cb975 100644 --- a/client/src/features/book/composables/__tests__/useBookWindow.spec.ts +++ b/client/src/features/book/composables/__tests__/useBookWindow.spec.ts @@ -182,6 +182,18 @@ describe('useBookWindow', () => { expect(window.slots.value).toHaveLength(80) }) + it('does not refetch when only query property order changes', async () => { + mockBlocks(80) + const { query } = makeWindow() + await flush() + fetchMock.mockClear() + + query.value = { sort: [{ dir: 'asc', field: 'title' }] } + await flush() + + expect(fetchMock).not.toHaveBeenCalled() + }) + it('exposes the contiguous prefix for list mode', async () => { mockBlocks(250) const { window } = makeWindow() diff --git a/client/src/features/book/composables/useBookViewWindow.ts b/client/src/features/book/composables/useBookViewWindow.ts index 8c4e13645..8d360d4a2 100644 --- a/client/src/features/book/composables/useBookViewWindow.ts +++ b/client/src/features/book/composables/useBookViewWindow.ts @@ -32,6 +32,7 @@ export function useBookViewWindow(options: { railViewport?: Ref collapseEnabled?: Ref q?: Ref + enabled?: Ref }) { const filter = ref(undefined) const sort = ref([{ field: 'title', dir: 'asc' }]) @@ -43,8 +44,13 @@ export function useBookViewWindow(options: { ...(options.q?.value.trim() ? { q: options.q.value.trim() } : {}), })) - const listEndpoint = computed(() => (isValidScopeId(options.scopeId.value) ? options.listEndpoint(options.scopeId.value) : null)) - const bucketsEndpoint = computed(() => (isValidScopeId(options.scopeId.value) ? options.bucketsEndpoint(options.scopeId.value) : null)) + const queryEnabled = computed(() => options.enabled?.value ?? true) + const listEndpoint = computed(() => + queryEnabled.value && isValidScopeId(options.scopeId.value) ? options.listEndpoint(options.scopeId.value) : null, + ) + const bucketsEndpoint = computed(() => + queryEnabled.value && isValidScopeId(options.scopeId.value) ? options.bucketsEndpoint(options.scopeId.value) : null, + ) const window = useBookWindow({ endpoint: listEndpoint, query }) diff --git a/client/src/features/book/composables/useBookWindow.ts b/client/src/features/book/composables/useBookWindow.ts index 1a0683c6d..5d97ef30b 100644 --- a/client/src/features/book/composables/useBookWindow.ts +++ b/client/src/features/book/composables/useBookWindow.ts @@ -15,6 +15,17 @@ export function isBookPlaceholder(slot: BookSlot): slot is BookPlaceholder { return (slot as BookPlaceholder).placeholder === true } +function canonicalizeQueryValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalizeQueryValue) + if (value === null || typeof value !== 'object') return value + const record = value as Record + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeQueryValue(record[key])]), + ) +} + /** * Placeholder-window store for book listings: holds a slot array sized to the * query total, where unloaded positions are placeholder objects rendered as @@ -253,7 +264,7 @@ export function useBookWindow(options: { endpoint: Ref; query: Re }, }) - const queryKey = computed(() => `${options.endpoint.value ?? ''}|${JSON.stringify(options.query.value)}`) + const queryKey = computed(() => `${options.endpoint.value ?? ''}|${JSON.stringify(canonicalizeQueryValue(options.query.value))}`) watch(queryKey, reset, { immediate: true }) diff --git a/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts b/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts index 6a8c662ec..321bfc917 100644 --- a/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts +++ b/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts @@ -32,6 +32,18 @@ function mockResponse(data: unknown, ok = true): Response { } as Response } +function mockSuccessfulBatch(books: unknown[], failed = false): void { + mockApi.mockImplementation(async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { items: Array<{ id: string }> } + return mockResponse({ items: body.items.map((item) => ({ id: item.id, books, failed })) }) + }) +} + +function batchBody(): { items: Array<{ id: string; type: string; limit: number; smartScopeId?: number }> } { + const init = mockApi.mock.calls.at(-1)?.[1] + return JSON.parse(String(init?.body)) +} + function mountComposable(type: ScrollerType, limit = 20, smartScopeId?: number) { let result!: ReturnType mount( @@ -56,41 +68,60 @@ describe('useDashboardScroller', () => { }) it('loads books for up-next-in-series on mount', async () => { - mockApi.mockResolvedValue(mockResponse([{ id: 7 }, { id: 2 }])) + mockSuccessfulBatch([{ id: 7 }, { id: 2 }]) const state = mountComposable('up-next-in-series', 12) expect(state.loading.value).toBe(true) await flushPromises() - expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/up-next-in-series?limit=12') + expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: expect.any(String), + }) + expect(batchBody().items).toEqual([{ id: expect.any(String), type: 'up-next-in-series', limit: 12 }]) expect(state.books.value).toEqual([{ id: 7 }, { id: 2 }]) expect(state.error.value).toBe(false) expect(state.loading.value).toBe(false) }) it('includes smartScopeId only for smart-scope requests', async () => { - mockApi.mockResolvedValue(mockResponse([])) + mockSuccessfulBatch([]) mountComposable('smart-scope', 30, 99) await flushPromises() - expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/smart-scope?limit=30&smartScopeId=99') + expect(batchBody().items).toEqual([{ id: expect.any(String), type: 'smart-scope', limit: 30, smartScopeId: 99 }]) }) it.each([ - ['continue-listening', '/api/v1/dashboard/scrollers/continue-listening?limit=8'], - ['want-to-read', '/api/v1/dashboard/scrollers/want-to-read?limit=9'], - ] as const)('loads books for %s on mount', async (type, expectedPath) => { - mockApi.mockResolvedValue(mockResponse([{ id: 1 }])) + ['continue-listening', 8], + ['want-to-read', 9], + ] as const)('loads books for %s on mount', async (type, limit) => { + mockSuccessfulBatch([{ id: 1 }]) - mountComposable(type, type === 'continue-listening' ? 8 : 9) + mountComposable(type, limit) await flushPromises() - expect(mockApi).toHaveBeenCalledWith(expectedPath) + expect(batchBody().items[0]).toMatchObject({ type, limit }) + }) + + it('consolidates shelves mounted in the same turn into one request', async () => { + mockSuccessfulBatch([{ id: 1 }]) + + mountComposable('recently-added', 20) + mountComposable('want-to-read', 10) + await flushPromises() + + expect(mockApi).toHaveBeenCalledOnce() + expect(batchBody().items.map(({ type, limit }) => ({ type, limit }))).toEqual([ + { type: 'recently-added', limit: 20 }, + { type: 'want-to-read', limit: 10 }, + ]) }) it('sets error=true when API response is not ok', async () => { - mockApi.mockResolvedValue(mockResponse([], false)) + mockApi.mockResolvedValue(mockResponse({}, false)) const state = mountComposable('continue-reading', 5) await flushPromises() @@ -101,7 +132,10 @@ describe('useDashboardScroller', () => { }) it('refresh retries after an error and updates books', async () => { - mockApi.mockResolvedValueOnce(mockResponse([], false)).mockResolvedValueOnce(mockResponse([{ id: 42 }], true)) + mockApi.mockResolvedValueOnce(mockResponse({}, false)).mockImplementationOnce(async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { items: Array<{ id: string }> } + return mockResponse({ items: body.items.map((item) => ({ id: item.id, books: [{ id: 42 }], failed: false })) }) + }) const state = mountComposable('continue-reading', 5) await flushPromises() @@ -116,7 +150,7 @@ describe('useDashboardScroller', () => { it('debounces progress events and refreshes dashboard membership', async () => { vi.useFakeTimers() - mockApi.mockResolvedValue(mockResponse([{ id: 42 }])) + mockSuccessfulBatch([{ id: 42 }]) mountComposable('continue-reading', 5) await flushPromises() mockApi.mockClear() @@ -128,6 +162,7 @@ describe('useDashboardScroller', () => { await vi.advanceTimersByTimeAsync(1) await flushPromises() - expect(mockApi).toHaveBeenCalledExactlyOnceWith('/api/v1/dashboard/scrollers/continue-reading?limit=5') + expect(mockApi).toHaveBeenCalledOnce() + expect(batchBody().items[0]).toMatchObject({ type: 'continue-reading', limit: 5 }) }) }) diff --git a/client/src/features/dashboard/composables/useDashboardScroller.ts b/client/src/features/dashboard/composables/useDashboardScroller.ts index 297ef131c..02d3fcdb4 100644 --- a/client/src/features/dashboard/composables/useDashboardScroller.ts +++ b/client/src/features/dashboard/composables/useDashboardScroller.ts @@ -1,9 +1,76 @@ import { onMounted, ref } from 'vue' -import type { BookCard, ScrollerType } from '@bookorbit/types' +import { + DASHBOARD_SCROLLER_BATCH_MAX, + type BookCard, + type DashboardScrollerBatchRequest, + type DashboardScrollerBatchResponse, + type DashboardScrollerBatchResult, + type ScrollerType, +} from '@bookorbit/types' import { api } from '@/lib/api' import { useBookProgressRefresh } from '@/features/book/composables/useBookProgressRefresh' +type PendingScrollerRequest = { + item: DashboardScrollerBatchRequest['items'][number] + resolve: (result: DashboardScrollerBatchResult) => void + reject: (reason?: unknown) => void +} + +const pendingRequests: PendingScrollerRequest[] = [] +let batchScheduled = false +let requestSequence = 0 + +function scheduleBatch(): void { + if (batchScheduled) return + batchScheduled = true + queueMicrotask(() => void flushBatch()) +} + +async function flushBatch(): Promise { + batchScheduled = false + const batch = pendingRequests.splice(0, DASHBOARD_SCROLLER_BATCH_MAX) + if (batch.length === 0) return + if (pendingRequests.length > 0) scheduleBatch() + + try { + const body: DashboardScrollerBatchRequest = { items: batch.map((request) => request.item) } + const response = await api('/api/v1/dashboard/scrollers/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) throw new Error('Dashboard scroller batch failed') + + const payload: DashboardScrollerBatchResponse = await response.json() + const resultsById = new Map(payload.items.map((item) => [item.id, item])) + for (const request of batch) { + const result = resultsById.get(request.item.id) + if (result) request.resolve(result) + else request.reject(new Error('Dashboard scroller batch result missing')) + } + } catch (error) { + for (const request of batch) request.reject(error) + } +} + +function requestScroller(type: ScrollerType, limit: number, smartScopeId?: number): Promise { + return new Promise((resolve, reject) => { + requestSequence += 1 + pendingRequests.push({ + item: { + id: String(requestSequence), + type, + limit, + ...(type === 'smart-scope' && smartScopeId ? { smartScopeId } : {}), + }, + resolve, + reject, + }) + scheduleBatch() + }) +} + export function useDashboardScroller(type: ScrollerType, limit = 20, smartScopeId?: number) { const books = ref([]) const loading = ref(true) @@ -13,11 +80,9 @@ export function useDashboardScroller(type: ScrollerType, limit = 20, smartScopeI loading.value = true error.value = false try { - const params = new URLSearchParams({ limit: String(limit) }) - if (type === 'smart-scope' && smartScopeId) params.set('smartScopeId', String(smartScopeId)) - const res = await api(`/api/v1/dashboard/scrollers/${type}?${params}`) - if (!res.ok) throw new Error() - books.value = await res.json() + const result = await requestScroller(type, limit, smartScopeId) + books.value = result.books + error.value = result.failed } catch { error.value = true } finally { diff --git a/client/src/features/statistics/api/statistics.api.test.ts b/client/src/features/statistics/api/statistics.api.test.ts index 417e1c33c..ba4dd1db4 100644 --- a/client/src/features/statistics/api/statistics.api.test.ts +++ b/client/src/features/statistics/api/statistics.api.test.ts @@ -381,6 +381,27 @@ describe('fetchUserPeakReadingHours', () => { await expect(fetchUserPeakReadingHours(filters)).rejects.toThrow('User peak hours request failed: 503') }) + + it('coalesces concurrent requests and starts a fresh request after settlement', async () => { + const data = [{ hour: 9, minutes: 60 }] + let resolveResponse: ((response: Response) => void) | undefined + mockedApi.mockReturnValueOnce( + new Promise((resolve) => { + resolveResponse = resolve + }), + ) + + const first = fetchUserPeakReadingHours(filters) + const second = fetchUserPeakReadingHours(filters) + expect(mockedApi).toHaveBeenCalledTimes(1) + + resolveResponse?.(mockOkResponse(data)) + await expect(Promise.all([first, second])).resolves.toEqual([data, data]) + + mockedApi.mockResolvedValueOnce(mockOkResponse(data)) + await expect(fetchUserPeakReadingHours(filters)).resolves.toEqual(data) + expect(mockedApi).toHaveBeenCalledTimes(2) + }) }) describe('fetchUserFavoriteReadingDays', () => { @@ -421,6 +442,27 @@ describe('fetchUserCompletionTimeline', () => { await expect(fetchUserCompletionTimeline(filters)).rejects.toThrow('User completion timeline request failed: 503') }) + + it('coalesces concurrent requests and starts a fresh request after settlement', async () => { + const data = [{ date: '2026-01-01', completedBooks: 1 }] + let resolveResponse: ((response: Response) => void) | undefined + mockedApi.mockReturnValueOnce( + new Promise((resolve) => { + resolveResponse = resolve + }), + ) + + const first = fetchUserCompletionTimeline(filters) + const second = fetchUserCompletionTimeline(filters) + expect(mockedApi).toHaveBeenCalledTimes(1) + + resolveResponse?.(mockOkResponse(data)) + await expect(Promise.all([first, second])).resolves.toEqual([data, data]) + + mockedApi.mockResolvedValueOnce(mockOkResponse(data)) + await expect(fetchUserCompletionTimeline(filters)).resolves.toEqual(data) + expect(mockedApi).toHaveBeenCalledTimes(2) + }) }) describe('fetchUserGoalTrajectory', () => { diff --git a/client/src/features/statistics/api/statistics.api.ts b/client/src/features/statistics/api/statistics.api.ts index 133f172b9..368ec7906 100644 --- a/client/src/features/statistics/api/statistics.api.ts +++ b/client/src/features/statistics/api/statistics.api.ts @@ -43,6 +43,19 @@ async function parseResult(res: Response): Promise> { return res.json() as Promise> } +// Only endpoints with known concurrent chart consumers opt into request coalescing. +const inFlightRequests = new Map>() + +function coalesceRequest(key: string, request: () => Promise): Promise { + const existing = inFlightRequests.get(key) as Promise | undefined + if (existing) return existing + const pending = request().finally(() => { + if (inFlightRequests.get(key) === pending) inFlightRequests.delete(key) + }) + inFlightRequests.set(key, pending) + return pending +} + function buildParams(filters: StatisticsFilterConfig, extra?: Record): string { const params = new URLSearchParams() filters.libraryIds.forEach((id) => params.append('libraryIds', String(id))) @@ -145,9 +158,12 @@ export async function fetchUserReadingSourceDistribution(filters: StatisticsFilt } export async function fetchUserPeakReadingHours(filters: StatisticsFilterConfig): Promise { - const res = await api(`/api/v1/user-statistics/peak-hours${buildParams(filters, { days: '365' })}`) - if (!res.ok) throw new Error(`User peak hours request failed: ${res.status}`) - return res.json() as Promise + const url = `/api/v1/user-statistics/peak-hours${buildParams(filters, { days: '365' })}` + return coalesceRequest(url, async () => { + const res = await api(url) + if (!res.ok) throw new Error(`User peak hours request failed: ${res.status}`) + return res.json() as Promise + }) } export async function fetchUserFavoriteReadingDays(filters: StatisticsFilterConfig): Promise { @@ -157,9 +173,12 @@ export async function fetchUserFavoriteReadingDays(filters: StatisticsFilterConf } export async function fetchUserCompletionTimeline(filters: StatisticsFilterConfig): Promise { - const res = await api(`/api/v1/user-statistics/completion-timeline${buildParams(filters, { days: '1825' })}`) - if (!res.ok) throw new Error(`User completion timeline request failed: ${res.status}`) - return res.json() as Promise + const url = `/api/v1/user-statistics/completion-timeline${buildParams(filters, { days: '1825' })}` + return coalesceRequest(url, async () => { + const res = await api(url) + if (!res.ok) throw new Error(`User completion timeline request failed: ${res.status}`) + return res.json() as Promise + }) } export async function fetchUserGoalTrajectory(filters: StatisticsFilterConfig, goalBooks = 12): Promise { diff --git a/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts b/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts index 4179e4a57..64554e2d4 100644 --- a/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts +++ b/client/src/features/tools/entity-manager/views/EntityManagerView.spec.ts @@ -92,6 +92,29 @@ describe('EntityManagerView selection', () => { vi.clearAllMocks() }) + it('loads browse mode exactly once on mount', () => { + const entityManager = makeEntityManagerMock() + mocks.useEntityManager.mockReturnValue(entityManager) + + shallowMount(EntityManagerView) + + expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1) + }) + + it('refreshes exactly once when an entity change also resets pagination', async () => { + const entityManager = makeEntityManagerMock() + entityManager.browsePage.value = 3 + mocks.useEntityManager.mockReturnValue(entityManager) + shallowMount(EntityManagerView) + entityManager.fetchBrowse.mockClear() + + entityManager.entityType.value = 'series' + entityManager.browsePage.value = 1 + await nextTick() + + expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1) + }) + it('routes ordinary browse selection events to single-item toggle', () => { const entityManager = makeEntityManagerMock() mocks.useEntityManager.mockReturnValue(entityManager) diff --git a/client/src/features/tools/entity-manager/views/EntityManagerView.vue b/client/src/features/tools/entity-manager/views/EntityManagerView.vue index 82bb7b217..967b74e4c 100644 --- a/client/src/features/tools/entity-manager/views/EntityManagerView.vue +++ b/client/src/features/tools/entity-manager/views/EntityManagerView.vue @@ -36,11 +36,8 @@ const bulkDeleteDefaultMode = computed<'soft' | 'hard'>(() => let searchDebounce: ReturnType | null = null function refreshBrowseFromFirstPage(): void { - if (em.browsePage.value === 1) { - em.fetchBrowse() - return - } em.browsePage.value = 1 + em.fetchBrowse() } watch(em.browseSearch, () => { @@ -50,13 +47,9 @@ watch(em.browseSearch, () => { }, 300) }) -watch(em.browsePage, () => { - em.fetchBrowse() -}) - watch( - em.mode, - (newMode) => { + [em.mode, em.entityType], + ([newMode]) => { if (newMode === 'browse') { em.fetchBrowse() } else if (newMode === 'duplicates') { @@ -66,18 +59,6 @@ watch( { immediate: true }, ) -watch( - em.entityType, - () => { - if (em.mode.value === 'browse') { - em.fetchBrowse() - } else if (em.mode.value === 'duplicates') { - em.fetchScanStatus() - } - }, - { immediate: true }, -) - function handleUpdateMode(value: EntityManagerMode): void { em.mode.value = value } @@ -114,6 +95,7 @@ function handleUpdateSearch(value: string): void { function handleUpdatePage(value: number): void { em.browsePage.value = value + em.fetchBrowse() } function handleBrowseSortChange(sortBy: 'name' | 'bookCount', sortOrder: 'asc' | 'desc'): void { diff --git a/client/src/views/SmartScopeView.vue b/client/src/views/SmartScopeView.vue index 62a0abd25..45a9d2b4b 100644 --- a/client/src/views/SmartScopeView.vue +++ b/client/src/views/SmartScopeView.vue @@ -1,5 +1,5 @@