fix(books): honour the metadata score sort when series are collapsed

The collapsed order by had no metadataScore case, so the sort was dropped
and the list fell back to title order. Order by metadata_score there and
select it in the collapsed jump-bucket query that shares the order by.

Fixes #1638
This commit is contained in:
neon
2026-10-02 00:10:28 +00:00
parent 068a62b7ab
commit f8c8ecf99a
4 changed files with 31 additions and 0 deletions
@@ -41,6 +41,7 @@ import {
CUSTOM_FIELD_TYPE_OPERATORS,
EMPTY_CONTENT_FILTER_RULES,
FIELD_OPERATORS,
SORT_FIELDS,
customRuleField,
type CustomMetadataFieldType,
type RuleField,
@@ -1987,6 +1988,15 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'updatedAt', dir: 'asc' }], 1)).toBe('updated_at ASC NULLS LAST, r.id ASC');
});
it('generates metadataScore sort against the representative row', () => {
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'metadataScore', dir: 'asc' }], 1)).toBe('metadata_score ASC NULLS LAST, r.id ASC');
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'metadataScore', dir: 'desc' }], 1)).toBe('metadata_score DESC NULLS LAST, r.id ASC');
});
it.each(SORT_FIELDS.filter((field) => field !== 'relevance'))('honours the %s browse sort instead of falling back to title', (field) => {
expect(BookQueryBuilder.buildCollapseOrderBy([{ field, dir: 'desc' }], 1)).not.toBe('sort_title ASC NULLS LAST, r.id ASC');
});
it('generates fileSize sort using the primary file', () => {
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'fileSize', dir: 'desc' }], 1)).toBe(
'(SELECT bf.size_bytes FROM book_files bf WHERE bf.id = r.primary_file_id) DESC NULLS LAST, r.id ASC',
@@ -1166,6 +1166,9 @@ export class BookQueryBuilder {
case 'pageCount':
parts.push(`page_count ${D} NULLS LAST`);
break;
case 'metadataScore':
parts.push(`metadata_score ${D} NULLS LAST`);
break;
case 'updatedAt':
parts.push(`updated_at ${D} NULLS LAST`);
break;
@@ -1275,6 +1275,7 @@ export class BookRepository {
book_metadata.published_year,
book_metadata.publisher,
book_metadata.page_count,
book_metadata.metadata_score,
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
${source.baseExtraSelect}
FROM books
@@ -1301,6 +1302,7 @@ export class BookRepository {
base.published_year,
base.publisher,
base.page_count,
base.metadata_score,
ubr.rating,
base.primary_author_sort_name AS author_sort_name,
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
@@ -396,6 +396,22 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
}
}
it('applies a secondary metadataScore tier to collapsed buckets without breaking the anchors', async () => {
const sort: SortSpec[] = [
{ field: 'title', dir: 'asc' },
{ field: 'metadataScore', dir: 'desc' },
];
const [bucketsResponse, listing] = await Promise.all([fetchBuckets(sort, true), fetchListing(sort, true)]);
expect(bucketsResponse.total).toBe(listing.total);
expect(bucketsResponse.buckets.length).toBeGreaterThan(1);
for (const bucket of bucketsResponse.buckets) {
const seeded = seededById.get((listing.items[bucket.index] as BookCard).id);
expect(seeded).toBeDefined();
expect(expectedBucketKey(seeded!, 'title', true, bucketsResponse.granularity)).toBe(bucket.key);
}
});
it('preserves every populated year when sparse years fit the bounded capacity', async () => {
const response = await fetchBuckets([{ field: 'publishedYear', dir: 'asc' }], false);
const expectedYears = [...new Set([...seededById.values()].flatMap((book) => (book.publishedYear === null ? [] : [String(book.publishedYear)])))];