mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(books): list series stacks before standalones when sorting by series
With series collapsed, the series sort used the same key as title, so stacks and standalone books interleaved alphabetically. Order stacks by series name first, then standalones by title, in both directions. The jump rail now only letters series, matching the flat view. Fixes #1456
This commit is contained in:
@@ -1901,8 +1901,13 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
|
|||||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'title', dir: 'desc' }], 1)).toBe('sort_title DESC NULLS LAST, r.id ASC');
|
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'title', dir: 'desc' }], 1)).toBe('sort_title DESC NULLS LAST, r.id ASC');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates sort_title for series field', () => {
|
it('orders series stacks before standalone books, then by title, in both directions', () => {
|
||||||
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'series', dir: 'asc' }], 1)).toBe('sort_title ASC NULLS LAST, r.id ASC');
|
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'series', dir: 'asc' }], 1)).toBe(
|
||||||
|
'sort_series ASC NULLS LAST, sort_title ASC NULLS LAST, r.id ASC',
|
||||||
|
);
|
||||||
|
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'series', dir: 'desc' }], 1)).toBe(
|
||||||
|
'sort_series DESC NULLS LAST, sort_title DESC NULLS LAST, r.id ASC',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates sort_added_at for addedAt field', () => {
|
it('generates sort_added_at for addedAt field', () => {
|
||||||
@@ -1934,7 +1939,7 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
expect(result).toContain("split_part(series_index::text, '.', 1)::numeric");
|
expect(result).toContain("split_part(series_index::text, '.', 1)::numeric");
|
||||||
expect(result).toContain('series_index COLLATE "C" ASC NULLS LAST, sort_title ASC NULLS LAST, r.id ASC');
|
expect(result).toContain('series_index COLLATE "C" ASC NULLS LAST, sort_series ASC NULLS LAST, sort_title ASC NULLS LAST, r.id ASC');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates user-scoped subquery for readProgress', () => {
|
it('generates user-scoped subquery for readProgress', () => {
|
||||||
|
|||||||
@@ -1135,7 +1135,12 @@ export class BookQueryBuilder {
|
|||||||
}
|
}
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'title':
|
case 'title':
|
||||||
|
parts.push(`sort_title ${D} NULLS LAST`);
|
||||||
|
break;
|
||||||
|
// sort_series is NULL for standalone books, so series stacks lead in both directions
|
||||||
|
// and the standalones that follow are ordered by title.
|
||||||
case 'series':
|
case 'series':
|
||||||
|
parts.push(`sort_series ${D} NULLS LAST`);
|
||||||
parts.push(`sort_title ${D} NULLS LAST`);
|
parts.push(`sort_title ${D} NULLS LAST`);
|
||||||
break;
|
break;
|
||||||
case 'addedAt':
|
case 'addedAt':
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ type CollapsedRawRow = {
|
|||||||
publisher: string | null;
|
publisher: string | null;
|
||||||
page_count: number | null;
|
page_count: number | null;
|
||||||
sort_title: string | null;
|
sort_title: string | null;
|
||||||
|
sort_series: string | null;
|
||||||
sort_added_at: string | null;
|
sort_added_at: string | null;
|
||||||
book_count: string | null;
|
book_count: string | null;
|
||||||
read_count: string | null;
|
read_count: string | null;
|
||||||
@@ -239,8 +240,10 @@ function collapsedDiscreteSourceParts(field: SortField, userId: number): Collaps
|
|||||||
|
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'title':
|
case 'title':
|
||||||
case 'series':
|
|
||||||
return direct(sql.raw('r.sort_title'));
|
return direct(sql.raw('r.sort_title'));
|
||||||
|
// Standalone books have no series letter, matching the flat rail.
|
||||||
|
case 'series':
|
||||||
|
return direct(sql.raw('r.sort_series'));
|
||||||
case 'author':
|
case 'author':
|
||||||
return direct(sql.raw('r.author_sort_name'));
|
return direct(sql.raw('r.author_sort_name'));
|
||||||
case 'publisher':
|
case 'publisher':
|
||||||
@@ -1079,6 +1082,7 @@ export class BookRepository {
|
|||||||
base.metadata_score,
|
base.metadata_score,
|
||||||
base.primary_author_sort_name AS author_sort_name,
|
base.primary_author_sort_name AS author_sort_name,
|
||||||
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
||||||
|
base.norm_series AS sort_series,
|
||||||
COALESCE(sa.latest_added_at, base.added_at) AS sort_added_at,
|
COALESCE(sa.latest_added_at, base.added_at) AS sort_added_at,
|
||||||
COALESCE(sa.first_collection_position, base.collection_position) AS sort_collection_position,
|
COALESCE(sa.first_collection_position, base.collection_position) AS sort_collection_position,
|
||||||
sa.book_count,
|
sa.book_count,
|
||||||
@@ -1311,6 +1315,7 @@ export class BookRepository {
|
|||||||
ubr.rating,
|
ubr.rating,
|
||||||
base.primary_author_sort_name AS author_sort_name,
|
base.primary_author_sort_name AS author_sort_name,
|
||||||
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
||||||
|
base.norm_series AS sort_series,
|
||||||
COALESCE(sl.latest_added_at, base.added_at) AS sort_added_at
|
COALESCE(sl.latest_added_at, base.added_at) AS sort_added_at
|
||||||
${source.representativeExtraSelect}
|
${source.representativeExtraSelect}
|
||||||
FROM base_rows base
|
FROM base_rows base
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function expectedBucketKey(
|
|||||||
return String(Math.floor(book.publishedYear / step) * step);
|
return String(Math.floor(book.publishedYear / step) * step);
|
||||||
}
|
}
|
||||||
if (field === 'author') return letterOf(book.authorSortName);
|
if (field === 'author') return letterOf(book.authorSortName);
|
||||||
if (field === 'series') return letterOf(collapsed ? (book.seriesName ?? book.title) : book.seriesName);
|
if (field === 'series') return letterOf(book.seriesName);
|
||||||
if (field === 'publisher') return letterOf(book.publisher);
|
if (field === 'publisher') return letterOf(book.publisher);
|
||||||
if (collapsed) return letterOf(book.seriesName ?? book.title);
|
if (collapsed) return letterOf(book.seriesName ?? book.title);
|
||||||
return letterOf(book.title);
|
return letterOf(book.title);
|
||||||
@@ -412,6 +412,25 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
it(`lists collapsed series stacks before standalone books by title when sorting by series ${dir}`, async () => {
|
||||||
|
const [seriesListing, titleListing] = await Promise.all([
|
||||||
|
fetchListing([{ field: 'series', dir }], true),
|
||||||
|
fetchListing([{ field: 'title', dir }], true),
|
||||||
|
]);
|
||||||
|
const rows = seriesListing.items.map((item) => seededById.get((item as BookCard).id)!);
|
||||||
|
const firstStandalone = rows.findIndex((row) => row.seriesName === null);
|
||||||
|
|
||||||
|
expect(rows.slice(0, firstStandalone).map((row) => row.seriesName)).toEqual(
|
||||||
|
dir === 'asc' ? ['Dune Saga', 'zeta chronicles'] : ['zeta chronicles', 'Dune Saga'],
|
||||||
|
);
|
||||||
|
expect(rows.slice(firstStandalone).every((row) => row.seriesName === null)).toBe(true);
|
||||||
|
expect(rows.slice(firstStandalone).map((row) => row.id)).toEqual(
|
||||||
|
titleListing.items.map((item) => (item as BookCard).id).filter((id) => seededById.get(id)!.seriesName === null),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('preserves every populated year when sparse years fit the bounded capacity', async () => {
|
it('preserves every populated year when sparse years fit the bounded capacity', async () => {
|
||||||
const response = await fetchBuckets([{ field: 'publishedYear', dir: 'asc' }], false);
|
const response = await fetchBuckets([{ field: 'publishedYear', dir: 'asc' }], false);
|
||||||
const expectedYears = [...new Set([...seededById.values()].flatMap((book) => (book.publishedYear === null ? [] : [String(book.publishedYear)])))];
|
const expectedYears = [...new Set([...seededById.values()].flatMap((book) => (book.publishedYear === null ? [] : [String(book.publishedYear)])))];
|
||||||
|
|||||||
Reference in New Issue
Block a user