diff --git a/apps/readest-app/src/__tests__/utils/library-utils.test.ts b/apps/readest-app/src/__tests__/utils/library-utils.test.ts index a7ff578e..fe326cca 100644 --- a/apps/readest-app/src/__tests__/utils/library-utils.test.ts +++ b/apps/readest-app/src/__tests__/utils/library-utils.test.ts @@ -333,6 +333,77 @@ describe('createWithinGroupSorter', () => { }); }); + describe('series grouping with sort direction', () => { + it('should always sort by seriesIndex ascending even when sortAscending is false', () => { + const books = [ + createMockBook({ + hash: '1', + title: 'Book 3', + metadata: { seriesIndex: 3 }, + }), + createMockBook({ + hash: '2', + title: 'Book 1', + metadata: { seriesIndex: 1 }, + }), + createMockBook({ + hash: '3', + title: 'Book 2', + metadata: { seriesIndex: 2 }, + }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + false, // descending + ); + const sorted = [...books].sort(sorter); + + // Series index should always be ascending (1, 2, 3) regardless of sort direction + expect(sorted[0]!.metadata?.seriesIndex).toBe(1); + expect(sorted[1]!.metadata?.seriesIndex).toBe(2); + expect(sorted[2]!.metadata?.seriesIndex).toBe(3); + }); + + it('should apply sort direction to fallback sort for books without seriesIndex', () => { + const books = [ + createMockBook({ hash: '1', title: 'Apple', metadata: {} }), + createMockBook({ hash: '2', title: 'Zebra', metadata: {} }), + ]; + + const sorterDesc = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + false, // descending + ); + const sortedDesc = [...books].sort(sorterDesc); + + expect(sortedDesc[0]!.title).toBe('Zebra'); + expect(sortedDesc[1]!.title).toBe('Apple'); + }); + + it('should place books with seriesIndex before those without regardless of sort direction', () => { + const books = [ + createMockBook({ hash: '1', title: 'Apple', metadata: {} }), + createMockBook({ hash: '2', title: 'Book 1', metadata: { seriesIndex: 1 } }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + false, // descending + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.hash).toBe('2'); // Has index, comes first + expect(sorted[1]!.hash).toBe('1'); // No index + }); + }); + describe('author grouping', () => { it('should sort by global sort criteria (title)', () => { const books = [ @@ -463,6 +534,11 @@ describe('getGroupSortValue', () => { expect(getGroupSortValue(group, LibrarySortByType.Published)).toBe(0); }); + it('should return group name for series sort', () => { + const group = createMockGroup({ name: 'My Series' }); + expect(getGroupSortValue(group, LibrarySortByType.Series)).toBe('My Series'); + }); + it('should handle empty groups gracefully', () => { const group = createMockGroup({ books: [] }); diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 539b33d9..659ba77d 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -186,16 +186,21 @@ const Bookshelf: React.FC = ({ const groups = currentBookshelfItems.filter((item): item is BooksGroup => 'books' in item); // Sort books within each group - const withinGroupSorter = createWithinGroupSorter(groupBy, sortBy, uiLanguage); + // For series groups, series index is always ascending; sort direction applies to fallback only + const sortAscending = sortOrder === 'asc'; + const withinGroupSorter = createWithinGroupSorter(groupBy, sortBy, uiLanguage, sortAscending); groups.forEach((group) => { - group.books.sort((a, b) => withinGroupSorter(a, b) * sortOrderMultiplier); + group.books.sort(withinGroupSorter); }); // Sort ungrouped books - use within-group sorter if we're inside a group // (for series, this ensures books are sorted by series index) const bookSorter = createBookSorter(sortBy, uiLanguage); if (groupId && groupBy !== LibraryGroupByType.Group && groupBy !== LibraryGroupByType.None) { - ungroupedBooks.sort((a, b) => withinGroupSorter(a, b) * sortOrderMultiplier); + ungroupedBooks.sort(withinGroupSorter); + // When inside a group, books are already sorted correctly — return directly + // to avoid the merge sort below overriding the within-group sort order + return ungroupedBooks; } else { ungroupedBooks.sort((a, b) => bookSorter(a, b) * sortOrderMultiplier); } diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index 8ff4dba6..91d34012 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -270,17 +270,27 @@ const createAuthorGroups = (books: Book[]): (Book | BooksGroup)[] => { /** * Create a sorter for books within a group. - * For series groups: sort by seriesIndex first, then by global sort for items without index. + * For series groups: sort by seriesIndex first (always ascending), then by global sort for items without index. * For author groups: follow global sort setting. + * @param sortAscending - When true (default), sort direction is ascending. Series index is always + * ascending regardless of this flag; the flag only affects the fallback sort for books without + * series index and non-series groupings. */ export const createWithinGroupSorter = - (groupBy: LibraryGroupByType, sortBy: LibrarySortByType, uiLanguage: string) => + ( + groupBy: LibraryGroupByType, + sortBy: LibrarySortByType, + uiLanguage: string, + sortAscending: boolean = true, + ) => (a: Book, b: Book): number => { + const sortDirection = sortAscending ? 1 : -1; + if (groupBy === LibraryGroupByType.Series) { const aIndex = a.metadata?.seriesIndex; const bIndex = b.metadata?.seriesIndex; - // Both have series index - sort by index + // Both have series index - always sort ascending by index if (aIndex != null && bIndex != null) { return aIndex - bIndex; } @@ -289,12 +299,12 @@ export const createWithinGroupSorter = if (aIndex != null) return -1; if (bIndex != null) return 1; - // Neither has series index - fall back to global sort - return createBookSorter(sortBy, uiLanguage)(a, b); + // Neither has series index - fall back to global sort with direction + return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection; } - // For author and other groupings, use global sort - return createBookSorter(sortBy, uiLanguage)(a, b); + // For author and other groupings, use global sort with direction + return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection; }; /** @@ -341,6 +351,7 @@ export const getGroupSortValue = ( if (books.length === 0) { return sortBy === LibrarySortByType.Title || + sortBy === LibrarySortByType.Series || sortBy === LibrarySortByType.Author || sortBy === LibrarySortByType.Format ? group.name @@ -349,6 +360,7 @@ export const getGroupSortValue = ( switch (sortBy) { case LibrarySortByType.Title: + case LibrarySortByType.Series: case LibrarySortByType.Format: return group.name;