fix(dashboard): keep abandoned books out of up next in series

Offer a series' next volume only when its read status is unset, unread or want to read, on the
shelf and in the is up next filter. Abandoned, on-hold and in-progress volumes at 0% no longer
show, and an abandoned volume ends its series there instead of surfacing the one after it.

Fixes #1649
This commit is contained in:
neon
2026-10-20 15:42:32 +00:00
parent 8762c4e628
commit 98415b90cc
4 changed files with 39 additions and 1 deletions
@@ -1806,6 +1806,17 @@ describe('seriesStatusRuleToSql', () => {
expect(text).toContain("'skimmed'");
});
it('isUpNext offers only volumes with no read status, unread or want to read, gated after the window', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }) as never, USER_CTX) as any;
const text = collectSqlText(getRuleSql(where)).join(' ').replaceAll(/\s+/g, ' ').toLowerCase();
const gate = "and (o.read_status is null or o.read_status in ('unread', 'want_to_read'))";
expect(text).toContain('ubs.status as read_status');
expect(text.indexOf(gate)).toBeGreaterThan(text.indexOf('from ordered o'));
expect(text.split(gate)).toHaveLength(2);
});
it('requires an authenticated user', () => {
const { builder } = makeBuilder();
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }) as never, BASE_CTX)).toThrow(
@@ -877,7 +877,9 @@ export class BookQueryBuilder {
/**
* Set membership against the same window-function pipeline as the "Up Next in Series" shelf
* (DashboardRepository.findUpNextInSeriesBookIds): per (library, series), the next unstarted book
* whose immediately preceding entry is finished. Implemented as a subquery (not a per-row predicate)
* whose immediately preceding entry is finished and whose read status is unset, unread or want to
* read. The status gate applies to candidates only, so an abandoned volume ends the series rather
* than letting the volume after it through. Implemented as a subquery (not a per-row predicate)
* so results match the shelf exactly. Keep the merged-progress / completion definitions in sync with
* that repository. Library scoping is omitted here because the window partitions by library already;
* the surrounding query restricts to the user's accessible libraries.
@@ -904,6 +906,7 @@ export class BookQueryBuilder {
m.series_index as series_index,
b.added_at as added_at,
${mergedProgress} as current_progress,
ubs.status as read_status,
case when ${isCompleted} then true else false end as is_completed
from ${books} b
inner join ${bookMetadata} m on m.book_id = b.id
@@ -922,6 +925,7 @@ export class BookQueryBuilder {
s.added_at,
s.is_completed,
s.current_progress,
s.read_status,
lag(s.is_completed) over (
partition by s.library_id, s.series_id
order by ${seriesIndexSortKey(sql.raw('s.series_index'))} asc,
@@ -932,6 +936,7 @@ export class BookQueryBuilder {
select distinct on (o.library_id, o.series_id) o.id
from ordered o
where o.previous_is_completed = true and o.is_completed = false and o.current_progress = 0
and (o.read_status is null or o.read_status in ('unread', 'want_to_read'))
order by o.library_id, o.series_id, ${seriesIndexSortKey(sql.raw('o.series_index'))} asc,
o.series_index collate "C" asc, o.added_at asc, o.id asc
)`;
@@ -305,6 +305,23 @@ describe('DashboardRepository', () => {
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('offers only up-next volumes with no read status, unread or want to read, gated after the series window', async () => {
const db = {
select: vi.fn(),
execute: vi.fn().mockResolvedValue({ rows: [] }),
};
const repo = new DashboardRepository(db as never);
await repo.findUpNextInSeriesBookIds([9], 55, 10);
const query = compileSql(db.execute.mock.calls[0]?.[0]);
const gate = "and (os.read_status is null or os.read_status in ('unread', 'want_to_read'))";
expect(query).toContain('"user_book_status"."status" as read_status');
expect(query).toContain('ssb.read_status');
expect(query.indexOf(gate)).toBeGreaterThan(query.indexOf('from ordered_series os'));
expect(query.split(gate)).toHaveLength(2);
});
it('returns empty up-next-in-series ids and does not query when limit is zero', async () => {
const db = {
select: vi.fn(),
@@ -278,6 +278,8 @@ export class DashboardRepository {
);
const filterSql = cfClauses.length > 0 ? sql`and ${sql.join(cfClauses, sql` and `)}` : sql``;
// The read-status gate applies to candidates only. An abandoned volume stays in the window so
// the series stops there instead of skipping ahead to the volume after it.
const rows = await this.db.execute<UpNextInSeriesRow>(sql`
with scoped_series_books as (
select
@@ -287,6 +289,7 @@ export class DashboardRepository {
${bookMetadata.seriesIndex} as series_index,
${books.addedAt} as added_at,
${mergedProgress} as current_progress,
${userBookStatus.status} as read_status,
case
when ${completionPredicate} then true
else false
@@ -319,6 +322,7 @@ export class DashboardRepository {
ssb.series_index,
ssb.added_at,
ssb.current_progress,
ssb.read_status,
ssb.is_completed,
ssb.completion_updated_at,
lag(ssb.is_completed) over (
@@ -341,6 +345,7 @@ export class DashboardRepository {
where os.previous_is_completed = true
and os.is_completed = false
and os.current_progress = 0
and (os.read_status is null or os.read_status in ('unread', 'want_to_read'))
order by os.library_id, os.series_id, ${seriesIndexSortKey(sql.raw('os.series_index'))} asc,
os.series_index collate "C" asc, os.added_at asc, os.id asc
)