From 0859c661522ffdbc0a0a48cf4f78abc988409869 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 19 Aug 2026 22:45:19 -0400 Subject: [PATCH] feat: add library date-added sorting and filtering --- fe/src/__tests__/AudiobooksView.spec.ts | 87 + fe/src/__tests__/CustomFilterModal.spec.ts | 55 + .../__tests__/customFilterEvaluator.spec.ts | 24 + .../domain/collection/CustomFilterModal.vue | 31 +- fe/src/types/index.ts | 1 + fe/src/utils/customFilterEvaluator.ts | 56 + fe/src/views/library/AudiobooksView.vue | 14 +- .../Features/Library/LibraryAddWorkflow.cs | 1 + .../Audiobooks/Catalog/LibraryAddService.cs | 1 + .../Catalog/LibraryAudiobookListItem.cs | 1 + .../Audiobooks/Catalog/LibraryListService.cs | 1 + .../Audiobooks/Common/AudiobookDto.cs | 1 + .../Mapping/AudiobookDtoFactory.cs | 1 + listenarr.domain/Audiobooks/Audiobook.cs | 1 + ...rastructureStartupCompositionExtensions.cs | 6 + .../Configurations/AudiobookConfiguration.cs | 16 + .../ListenarrDatabaseMigrationPreflight.cs | 54 +- ...20015101_AddAudiobookAddedDate.Designer.cs | 2565 +++++++++++++++++ .../20260820015101_AddAudiobookAddedDate.cs | 29 + .../ListenArrDbContextModelSnapshot.cs | 41 +- ...yController_LibraryListSlimPayloadTests.cs | 4 + .../Api/Models/AudiobookDtoFactoryTests.cs | 3 + ...uctureStartupCompositionExtensionsTests.cs | 75 + .../Migrations/MigrationMetadataTests.cs | 30 + .../Migrations/ReleasedSchemaUpgradeTests.cs | 42 + .../Persistence/SqliteMigrationSchemaTests.cs | 5 +- 26 files changed, 3120 insertions(+), 25 deletions(-) create mode 100644 fe/src/__tests__/CustomFilterModal.spec.ts create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260820015101_AddAudiobookAddedDate.Designer.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260820015101_AddAudiobookAddedDate.cs diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de9..bdec6f5cd 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/__tests__/AudiobooksView.spec.ts @@ -39,6 +39,11 @@ type AudiobooksVm = { showItemDetails?: boolean groupBy?: string visibleRange?: { start: number; end: number } + sortKey?: string + sortOrder?: 'asc' | 'desc' + sortKeyProxy?: string + sortOptions?: Array<{ value: string; label: string }> + audiobooks?: Array<{ title?: string }> } const getVm = (wrapper: ReturnType) => wrapper.vm as unknown as AudiobooksVm @@ -118,6 +123,88 @@ describe('AudiobooksView', () => { expect(wrapper.text()).toContain('Test Publisher') expect(wrapper.text()).toContain('2020') }) + + it('sorts by canonical date added with unknown dates last', async () => { + 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 () { + /* noop */ + } + } + + 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 = [ + { id: 1, title: 'Oldest', authors: ['A'], added: '2023-01-01T00:00:00Z', files: [] }, + { id: 2, title: 'Newest', authors: ['A'], added: '2025-06-01T00:00:00Z', files: [] }, + { id: 3, title: 'Middle', authors: ['A'], added: '2024-03-15T00:00:00Z', files: [] }, + { id: 4, title: 'Unknown', authors: ['A'], added: null, files: [] }, + ] 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((resolve) => setTimeout(resolve, 0)) + + const vm = getVm(wrapper) + await vm.setGroupBy?.('books') + await wrapper.vm.$nextTick() + expect(vm.sortOptions?.map((option) => option.value)).toContain('added') + + vm.sortKey = 'added' + vm.sortOrder = 'desc' + await wrapper.vm.$nextTick() + expect(vm.audiobooks?.map((book) => book.title)).toEqual([ + 'Newest', + 'Middle', + 'Oldest', + 'Unknown', + ]) + + vm.sortOrder = 'asc' + await wrapper.vm.$nextTick() + expect(vm.audiobooks?.map((book) => book.title)).toEqual([ + 'Oldest', + 'Middle', + 'Newest', + 'Unknown', + ]) + + vm.sortKey = 'title' + vm.sortOrder = 'asc' + vm.sortKeyProxy = 'added' + await wrapper.vm.$nextTick() + expect(vm.sortKey).toBe('added') + expect(vm.sortOrder).toBe('desc') + }) }) describe('AudiobooksView Grouping', () => { diff --git a/fe/src/__tests__/CustomFilterModal.spec.ts b/fe/src/__tests__/CustomFilterModal.spec.ts new file mode 100644 index 000000000..f0a52552e --- /dev/null +++ b/fe/src/__tests__/CustomFilterModal.spec.ts @@ -0,0 +1,55 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import CustomFilterModal from '@/components/domain/collection/CustomFilterModal.vue' + +const modalStub = { + template: '
', +} +const modalBodyStub = { + template: '
', +} + +describe('CustomFilterModal', () => { + it('resets the operator and value when a rule changes to Date Added', async () => { + const wrapper = mount(CustomFilterModal, { + props: { + isOpen: true, + filter: { + id: 'recent', + label: 'Recent', + rules: [{ field: 'title', operator: 'contains', value: 'existing title' }], + }, + }, + global: { + stubs: { + Modal: modalStub, + ModalHeader: true, + ModalBody: modalBodyStub, + }, + }, + }) + + const field = wrapper.get('select.field-select') + await field.setValue('added') + + expect((wrapper.get('select.op-select').element as HTMLSelectElement).value).toBe('eq') + expect((wrapper.get('input[type="date"]').element as HTMLInputElement).value).toBe('') + }) +}) diff --git a/fe/src/__tests__/customFilterEvaluator.spec.ts b/fe/src/__tests__/customFilterEvaluator.spec.ts index 5069595e5..630b3a730 100644 --- a/fe/src/__tests__/customFilterEvaluator.spec.ts +++ b/fe/src/__tests__/customFilterEvaluator.spec.ts @@ -70,6 +70,30 @@ describe('customFilterEvaluator - grouping and precedence', () => { expect(evaluateRules(b4 as Audiobook, rules)).toBe(false) }) + it('compares date-added rules by the user-local calendar date and excludes unknown dates', () => { + const added = new Date(2026, 7, 19, 12, 30, 0) + const addedKey = `${added.getFullYear().toString().padStart(4, '0')}-${(added.getMonth() + 1) + .toString() + .padStart(2, '0')}-${added.getDate().toString().padStart(2, '0')}` + const previousDay = new Date(added.getFullYear(), added.getMonth(), added.getDate() - 1) + const previousDayKey = `${previousDay.getFullYear().toString().padStart(4, '0')}-${( + previousDay.getMonth() + 1 + ) + .toString() + .padStart(2, '0')}-${previousDay.getDate().toString().padStart(2, '0')}` + const book = { ...base, added: added.toISOString() } as Audiobook + + expect(evaluateRules(book, [{ field: 'added', operator: 'eq', value: addedKey }])).toBe(true) + expect(evaluateRules(book, [{ field: 'added', operator: 'gt', value: previousDayKey }])).toBe( + true, + ) + expect( + evaluateRules({ ...book, added: null }, [ + { field: 'added', operator: 'ne', value: addedKey }, + ]), + ).toBe(false) + }) + it('uses slim list file summary fields for path, filesize, and file count filters', () => { const slimBook = { ...base, diff --git a/fe/src/components/domain/collection/CustomFilterModal.vue b/fe/src/components/domain/collection/CustomFilterModal.vue index c6697c346..8e594d0c3 100644 --- a/fe/src/components/domain/collection/CustomFilterModal.vue +++ b/fe/src/components/domain/collection/CustomFilterModal.vue @@ -55,7 +55,11 @@ > ( - @@ -64,6 +68,7 @@ + @@ -75,7 +80,9 @@ +