From 31964549441e7fa4361c51e02e2a940e3675456c Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 30 Aug 2026 05:59:19 +0000 Subject: [PATCH] fix(settings): persist complete display preferences Accept the author display fields already emitted by the client and include them in automatic account sync. Keep older payloads compatible with defaults and cover the client-server contract with regression tests. Fixes #1209 --- .../__tests__/useDisplaySettings.spec.ts | 2 ++ .../__tests__/useDisplaySettingsSync.spec.ts | 36 +++++++++++++++++++ .../src/composables/useDisplaySettingsSync.ts | 2 ++ .../user-preferences.controller.test.ts | 4 +++ .../user-preferences.service.test.ts | 32 +++++++++++++++++ .../user-preferences.service.ts | 4 ++- 6 files changed, 79 insertions(+), 1 deletion(-) diff --git a/client/src/composables/__tests__/useDisplaySettings.spec.ts b/client/src/composables/__tests__/useDisplaySettings.spec.ts index 73a964441..d9fa105c2 100644 --- a/client/src/composables/__tests__/useDisplaySettings.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettings.spec.ts @@ -16,6 +16,8 @@ function resetDisplaySettings() { settings.smartScopeFilterExpanded.value = true settings.authorCoverSize.value = 120 settings.authorCoverShape.value = 'circle' + settings.authorRowDensity.value = 'comfortable' + settings.authorCoverFallback.value = false settings.tableZebraStriping.value = false settings.tableDensity.value = 'comfortable' settings.bookSpineOverlay.value = 'off' diff --git a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts index 0e8b1eb02..a5b164b18 100644 --- a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts @@ -215,6 +215,42 @@ describe('useDisplaySettingsSync', () => { ) }) + it('syncs authorRowDensity changes to the server', async () => { + vi.useFakeTimers() + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValue({ ok: true }) + + sync.initDisplaySettingsSync() + displaySettings.useDisplaySettings().authorRowDensity.value = 'compact' + await vi.advanceTimersByTimeAsync(1500) + + expect(apiMock).toHaveBeenCalledWith( + '/api/v1/user-preferences/display', + expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"authorRowDensity":"compact"'), + }), + ) + }) + + it('syncs authorCoverFallback changes to the server', async () => { + vi.useFakeTimers() + const { apiMock, displaySettings, sync } = await loadModules() + apiMock.mockResolvedValue({ ok: true }) + + sync.initDisplaySettingsSync() + displaySettings.useDisplaySettings().authorCoverFallback.value = true + await vi.advanceTimersByTimeAsync(1500) + + expect(apiMock).toHaveBeenCalledWith( + '/api/v1/user-preferences/display', + expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"authorCoverFallback":true'), + }), + ) + }) + it('flushes a pending save with keepalive on pagehide', async () => { vi.useFakeTimers() const fetchMock = vi.fn<() => Promise>().mockResolvedValue({ ok: true } as Response) diff --git a/client/src/composables/useDisplaySettingsSync.ts b/client/src/composables/useDisplaySettingsSync.ts index 1fe068f08..3da71a6e2 100644 --- a/client/src/composables/useDisplaySettingsSync.ts +++ b/client/src/composables/useDisplaySettingsSync.ts @@ -113,6 +113,8 @@ export function initDisplaySettingsSync(): void { settings.smartScopeFilterExpanded.value, settings.authorCoverSize.value, settings.authorCoverShape.value, + settings.authorRowDensity.value, + settings.authorCoverFallback.value, settings.tableZebraStriping.value, settings.tableDensity.value, settings.bookSpineOverlay.value, diff --git a/server/src/modules/user-preferences/user-preferences.controller.test.ts b/server/src/modules/user-preferences/user-preferences.controller.test.ts index c785b5931..bbdff0a7f 100644 --- a/server/src/modules/user-preferences/user-preferences.controller.test.ts +++ b/server/src/modules/user-preferences/user-preferences.controller.test.ts @@ -51,11 +51,15 @@ const validDisplayPreferences: DisplayPreferences = { smartScopeFilterExpanded: true, authorCoverSize: 140, authorCoverShape: 'circle', + authorRowDensity: 'comfortable', + authorCoverFallback: false, tableZebraStriping: false, tableDensity: 'comfortable', bookSpineOverlay: 'subtle', + showSpineOnComics: false, bookShadowStrength: 'strong', bookCoverDisplayMode: 'natural-bottom', + bookDetailCoverTint: 'duotone', seriesCardCoverMode: 'stack', gridCardPrimaryLabel: 'hidden', gridCardSecondaryLabel: 'hidden', diff --git a/server/src/modules/user-preferences/user-preferences.service.test.ts b/server/src/modules/user-preferences/user-preferences.service.test.ts index 12813be94..443eedf03 100644 --- a/server/src/modules/user-preferences/user-preferences.service.test.ts +++ b/server/src/modules/user-preferences/user-preferences.service.test.ts @@ -71,6 +71,8 @@ const validDisplayPreferences: DisplayPreferences = { smartScopeFilterExpanded: true, authorCoverSize: 140, authorCoverShape: 'circle', + authorRowDensity: 'comfortable', + authorCoverFallback: false, tableZebraStriping: false, tableDensity: 'comfortable', bookSpineOverlay: 'subtle', @@ -404,6 +406,36 @@ describe('UserPreferencesService', () => { expect(repo.upsert).not.toHaveBeenCalled(); }); + it('upsertDisplayPreferences accepts and persists author display preferences', async () => { + const preferences = { + ...validDisplayPreferences, + authorRowDensity: 'compact', + authorCoverFallback: true, + } satisfies DisplayPreferences; + + await expect(service.upsertDisplayPreferences(11, preferences)).resolves.toBeUndefined(); + + expect(repo.upsert).toHaveBeenCalledWith(11, 'display', preferences); + }); + + it('upsertDisplayPreferences defaults author display preferences omitted by older clients', async () => { + const { authorRowDensity, authorCoverFallback, ...olderPreferences } = validDisplayPreferences; + void authorRowDensity; + void authorCoverFallback; + + await expect(service.upsertDisplayPreferences(11, olderPreferences)).resolves.toBeUndefined(); + + expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ authorRowDensity: 'comfortable', authorCoverFallback: false })); + }); + + it.each([ + ['authorRowDensity', 'dense'], + ['authorCoverFallback', 'yes'], + ])('upsertDisplayPreferences rejects invalid %s values', async (field, value) => { + await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, [field]: value })).rejects.toBeInstanceOf(BadRequestException); + expect(repo.upsert).not.toHaveBeenCalled(); + }); + it('upsertDisplayPreferences rejects invalid cover display modes', async () => { await expect( service.upsertDisplayPreferences(11, { ...validDisplayPreferences, bookCoverDisplayMode: 'stretched' } as never), diff --git a/server/src/modules/user-preferences/user-preferences.service.ts b/server/src/modules/user-preferences/user-preferences.service.ts index a1dd2a5dd..3210c4bbb 100644 --- a/server/src/modules/user-preferences/user-preferences.service.ts +++ b/server/src/modules/user-preferences/user-preferences.service.ts @@ -61,6 +61,8 @@ const DISPLAY_PREFERENCES_SCHEMA = z smartScopeFilterExpanded: z.boolean(), authorCoverSize: z.number().int().min(100).max(400), authorCoverShape: z.enum(AUTHOR_COVER_SHAPES), + authorRowDensity: z.enum(TABLE_DENSITIES).default('comfortable'), + authorCoverFallback: z.boolean().default(false), tableZebraStriping: z.boolean(), tableDensity: z.enum(TABLE_DENSITIES), bookSpineOverlay: z.enum(BOOK_SPINE_OVERLAYS), @@ -83,7 +85,7 @@ const DISPLAY_PREFERENCES_SCHEMA = z message: 'cardOverlays must not contain duplicate values', }); } - }); + }) satisfies z.ZodType; const COVER_SEARCH_PREFERENCES_SCHEMA = z .object({