From 4fb47c4fd63fb6b101bc8856bf05a82a92710c57 Mon Sep 17 00:00:00 2001 From: dny238 Date: Thu, 30 Jul 2026 17:57:48 -0600 Subject: [PATCH] Remember the library sort across visits The Audiobooks library reset its sort to Title on every visit, discarding whatever the user last chose. The per-group sort state now persists to localStorage (listenarr.sortState) and is restored on load, so the last sort key and direction are kept as the user's preference. Invalid saved keys still fall back to the group default (existing grouping-change guard). Adds tests covering persist-on-change and restore-on-mount. Co-Authored-By: Claude Fable 5 --- fe/src/__tests__/AudiobooksView.spec.ts | 71 +++++++++++++++++++++++++ fe/src/views/library/AudiobooksView.vue | 49 +++++++++++++++-- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de9..1f4225de9 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/__tests__/AudiobooksView.spec.ts @@ -796,3 +796,74 @@ describe('AudiobooksView Grouping', () => { expect(wrapper.find('.series-bottom-placard').exists()).toBe(true) }) }) + +describe('AudiobooksView sort persistence', () => { + beforeEach(() => { + localStorage.clear() + const pinia = createPinia() + setActivePinia(pinia) + }) + + function ensureGlobals() { + if (typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as unknown as Record).ResizeObserver = class { + observe() {} + disconnect() {} + } + } + if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as unknown as Record).WebSocket = function () {} + } + } + + async function mountView() { + ensureGlobals() + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/audiobooks', name: 'audiobooks', component: AudiobooksView }, + ], + }) + await router.push('/audiobooks') + await router.isReady().catch(() => {}) + const store = useLibraryStore() + store.audiobooks = [] as unknown as import('@/types').Audiobook[] + store.fetchLibrary = vi.fn(async () => undefined) + const wrapper = mount(AudiobooksView, { + global: { + plugins: [pinia, router], + stubs: ['BulkEditModal', 'EditAudiobookModal', 'CustomFilterModal', 'FiltersDropdown', 'CustomSelect'], + }, + }) + await new Promise((r) => setTimeout(r, 0)) + return wrapper + } + + it('persists the chosen sort to localStorage', async () => { + const wrapper = await mountView() + const vm = wrapper.vm as unknown as { groupBy: string; sortKey: string; sortOrder: string } + + vm.sortKey = 'publisher' + vm.sortOrder = 'desc' + await wrapper.vm.$nextTick() + + const saved = JSON.parse(localStorage.getItem('listenarr.sortState') || '{}') + expect(saved.books).toEqual({ key: 'publisher', order: 'desc' }) + }) + + it('restores the saved sort on mount instead of defaulting to title', async () => { + localStorage.setItem( + 'listenarr.sortState', + JSON.stringify({ books: { key: 'publisher', order: 'desc' } }), + ) + const wrapper = await mountView() + const vm = wrapper.vm as unknown as { groupBy: string; sortKey: string; sortOrder: string } + + expect(vm.groupBy).toBe('books') + expect(vm.sortKey).toBe('publisher') + expect(vm.sortOrder).toBe('desc') + }) +}) diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index 610575b98..7577fcdc3 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -900,11 +900,50 @@ const DEFAULT_SORTS = { series: { key: 'title', order: 'asc' }, } as const -const sortState = reactive({ - books: { key: 'title', order: 'asc' as 'asc' | 'desc' }, - authors: { key: 'author-last', order: 'asc' as 'asc' | 'desc' }, - series: { key: 'title', order: 'asc' as 'asc' | 'desc' }, -}) +type GroupSort = { key: string; order: 'asc' | 'desc' } +type SortStateShape = Record<'books' | 'authors' | 'series', GroupSort> + +// Persist the chosen sort (key + order, per grouping) so the library keeps the user's last +// preference across visits instead of resetting to Title every time. +const SORT_STATE_KEY = 'listenarr.sortState' + +function loadSortState(): SortStateShape { + const state: SortStateShape = { + books: { ...DEFAULT_SORTS.books }, + authors: { ...DEFAULT_SORTS.authors }, + series: { ...DEFAULT_SORTS.series }, + } + try { + const raw = localStorage.getItem(SORT_STATE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + for (const group of ['books', 'authors', 'series'] as const) { + const saved = parsed?.[group] + if (saved && typeof saved.key === 'string' && (saved.order === 'asc' || saved.order === 'desc')) { + state[group] = { key: saved.key, order: saved.order } + } + } + } + } catch { + // Corrupt/unavailable storage — fall back to defaults. + } + return state +} + +const sortState = reactive(loadSortState()) + +// Persist whenever the sort changes (any grouping). +watch( + sortState, + (value) => { + try { + localStorage.setItem(SORT_STATE_KEY, JSON.stringify(value)) + } catch { + // Ignore storage write failures (private mode / quota). + } + }, + { deep: true }, +) const sortKey = computed({ get: () => sortState[groupBy.value].key,