mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user