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 976e7796..587feba9 100644 --- a/apps/readest-app/src/__tests__/utils/library-utils.test.ts +++ b/apps/readest-app/src/__tests__/utils/library-utils.test.ts @@ -1184,6 +1184,47 @@ describe('secondary sort - createWithinGroupSorter (drilled-in group)', () => { expect(sorted.map((b) => b.metadata?.seriesIndex)).toEqual([1, 2, 3]); }); + it('keeps each series together (in series order) when an author has multiple series', () => { + const books = [ + createMockBook({ + hash: 'b1', + title: 'B One', + metadata: { series: 'Series B', seriesIndex: 1 }, + }), + createMockBook({ + hash: 'a2', + title: 'A Two', + metadata: { series: 'Series A', seriesIndex: 2 }, + }), + createMockBook({ + hash: 'a1', + title: 'A One', + metadata: { series: 'Series A', seriesIndex: 1 }, + }), + createMockBook({ + hash: 'b2', + title: 'B Two', + metadata: { series: 'Series B', seriesIndex: 2 }, + }), + ]; + const sorter = createWithinGroupSorter( + LibraryGroupByType.Author, + LibrarySortByType.Title, + 'en', + true, + LibrarySortByType.Series, + ); + const sorted = [...books].sort(sorter); + // All of Series A consecutively in index order, then all of Series B — + // not interleaved by index across series (#1s together, #2s together). + expect(sorted.map((b) => `${b.metadata?.series} #${b.metadata?.seriesIndex}`)).toEqual([ + 'Series A #1', + 'Series A #2', + 'Series B #1', + 'Series B #2', + ]); + }); + it('falls back to primary when secondary ties (same series index)', () => { const books = [ createMockBook({ diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index 499cce83..dee05380 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -196,8 +196,16 @@ const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); case LibrarySortByType.Format: return a.format.localeCompare(b.format, uiLanguage || navigator.language); - case LibrarySortByType.Series: + case LibrarySortByType.Series: { + // Group by series name first so books of the same series stay consecutive, + // then order within a series by index. Comparing index alone would interleave + // series (all #1s, then all #2s) when this key is used as a secondary sort. + const aSeries = a.metadata?.series || ''; + const bSeries = b.metadata?.series || ''; + const bySeries = aSeries.localeCompare(bSeries, uiLanguage || navigator.language); + if (bySeries !== 0) return bySeries; return (a.metadata?.seriesIndex || 0) - (b.metadata?.seriesIndex || 0); + } case LibrarySortByType.Published: { const aPublished = a.metadata?.published || '0001-01-01'; const bPublished = b.metadata?.published || '0001-01-01'; @@ -227,8 +235,8 @@ const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): /** * @param secondarySortBy - Optional tiebreaker key applied when the primary - * comparison returns 0. Pass `'none'` (or omit) to disable. Series-index ties - * on a Series secondary fall through to the underlying primary tie. + * comparison returns 0. Pass `'none'` (or omit) to disable. A Series secondary + * orders by series name then index; ties on both fall through to the primary tie. */ export const createBookSorter = (sortBy: string, uiLanguage: string, secondarySortBy: LibrarySecondarySortByType = 'none') =>