fix(dashboard): exclude finished books from continue reading

The dashboard query now joins user book statuses to exclude books marked as 'read', 'skimmed', or 'abandoned' from the continue reading list.

Closes #528
This commit is contained in:
neon
2026-07-04 04:49:01 +00:00
parent 8cf88fb547
commit 3db72ced64
2 changed files with 9 additions and 2 deletions
@@ -64,18 +64,22 @@ describe('DashboardRepository', () => {
expect(listChain.limit).toHaveBeenCalledWith(2);
});
it('maps continue-reading rows to id list without joining audiobook progress', async () => {
it('maps continue-reading rows to id list and excludes terminal read statuses', async () => {
const listChain = makeLimitChain([{ id: 40 }, { id: 9 }]);
const db = { select: vi.fn().mockReturnValue(listChain) };
const repo = new DashboardRepository(db as never);
const result = await repo.findContinueReadingBookIds([8], 55, 10);
const whereArg = listChain.where.mock.calls[0]?.[0];
const whereValues = collectValues(whereArg);
expect(result).toEqual([40, 9]);
expect(listChain.leftJoin).toHaveBeenCalledTimes(2);
expect(listChain.leftJoin).toHaveBeenCalledTimes(3);
expect(listChain.leftJoin.mock.calls[0]?.[0]).toBe(bookFiles);
expect(listChain.leftJoin.mock.calls[1]?.[0]).toBe(readingProgress);
expect(listChain.leftJoin.mock.calls[2]?.[0]).toBe(userBookStatus);
expect(listChain.leftJoin.mock.calls.some((call) => call[0] === audiobookProgress)).toBe(false);
expect(whereValues).toEqual(expect.arrayContaining(['read', 'skimmed', 'abandoned']));
expect(listChain.innerJoin).not.toHaveBeenCalled();
expect(listChain.orderBy).toHaveBeenCalledTimes(1);
expect(listChain.limit).toHaveBeenCalledWith(10);
@@ -11,6 +11,7 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
type Db = NodePgDatabase<typeof schema>;
type UpNextInSeriesRow = { id: number };
const AUDIO_FORMATS = BOOK_FORMATS.filter(isAudioFormat);
const CONTINUE_READING_EXCLUDED_READ_STATUSES = ['read', 'skimmed', 'abandoned'] as const satisfies readonly ReadStatus[];
const DISCOVERY_EXCLUDED_READ_STATUSES = ['reading', 'rereading', 'on_hold', 'read', 'skimmed', 'abandoned'] as const satisfies readonly ReadStatus[];
@Injectable()
@@ -44,12 +45,14 @@ export class DashboardRepository {
.from(books)
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, userId)))
.where(
and(
inArray(books.libraryId, accessibleLibraryIds),
eq(books.status, 'present'),
or(isNull(bookFiles.format), notInArray(bookFiles.format, AUDIO_FORMATS)),
sql`${readingProgress.percentage} > 0 and ${readingProgress.percentage} < 100`,
or(isNull(userBookStatus.bookId), notInArray(userBookStatus.status, [...CONTINUE_READING_EXCLUDED_READ_STATUSES])),
...cfClauses,
),
)