feat(dashboard): add up next in series to continue reading

Closes #162
This commit is contained in:
neon
2026-06-01 16:48:21 +00:00
parent 6d1cac52b3
commit 2bdc88e2cc
12 changed files with 334 additions and 9 deletions
@@ -46,7 +46,7 @@ describe('useBookViewContext', () => {
const nav = useBookNavigation()
const books = ref<BookCard[]>([makeBook(1), makeBook(2)])
const total = ref(4)
const loadMore = vi.fn(async () => {
const loadMore = vi.fn<() => Promise<void>>(async () => {
books.value = [makeBook(1), makeBook(2), makeBook(3), makeBook(4)]
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, useAttrs } from 'vue'
import { Aperture, BookMarked, ChevronLeft, ChevronRight, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
import { Aperture, BookMarked, ChevronLeft, ChevronRight, ListOrdered, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
import type { BookCard, ScrollerType } from '@bookorbit/types'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
@@ -33,6 +33,7 @@ function scrollBy(delta: number) {
const typeIcon = computed(() => {
if (props.type === 'continue-reading') return BookMarked
if (props.type === 'up-next-in-series') return ListOrdered
if (props.type === 'recently-added') return Sparkles
if (props.type === 'smart-scope') return Aperture
return Shuffle
@@ -130,6 +131,7 @@ function handleBookAction(book: BookCard, action: BookActionType) {
</div>
<p class="text-sm text-muted-foreground">
<template v-if="type === 'continue-reading'">No books in progress yet. Start reading one to see it here.</template>
<template v-else-if="type === 'up-next-in-series'">No next-in-series picks yet. Finish a volume to surface the next one.</template>
<template v-else-if="type === 'recently-added'">No books in your library yet.</template>
<template v-else-if="type === 'smart-scope'">No books match this smartScope.</template>
<template v-else>No books found.</template>
@@ -46,7 +46,7 @@ const {
moveDown: widgetMoveDown,
} = useDraggableList(widgetDraft)
// ── Add / Remove ─────────────────────────────────────────────
const ALL_TYPES: ScrollerType[] = ['continue-reading', 'recently-added', 'random', 'smart-scope']
const ALL_TYPES: ScrollerType[] = ['continue-reading', 'up-next-in-series', 'recently-added', 'random', 'smart-scope']
function addScroller() {
if (draft.value.length >= MAX_SCROLLERS) return
@@ -52,8 +52,8 @@ describe('useDashboardConfig', () => {
addScroller('smart-scope')
expect(scrollers.value).toHaveLength(4)
expect(DEFAULT_SCROLLERS).toHaveLength(3)
expect(scrollers.value).toHaveLength(5)
expect(DEFAULT_SCROLLERS).toHaveLength(4)
})
it('prunes shelves that reference deleted smart scopes', async () => {
@@ -126,6 +126,33 @@ describe('useDashboardConfig', () => {
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('uses the default label for up-next-in-series when a custom label is empty', async () => {
const { useDashboardConfig } = await import('../useDashboardConfig')
const { scrollers, saveScrollers } = useDashboardConfig()
saveScrollers([
{
id: '12',
type: 'up-next-in-series',
label: ' ',
enabled: true,
order: 1,
limit: 20,
},
])
expect(scrollers.value).toEqual([
{
id: '12',
type: 'up-next-in-series',
label: 'Up Next in Series',
enabled: true,
order: 1,
limit: 20,
},
])
})
it('does not rewrite storage when smart scope prune keeps the same scrollers', async () => {
localStorage.setItem(
STORAGE_KEY,
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent } from 'vue'
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<Response>>(),
}))
import { api } from '@/lib/api'
import { useDashboardScroller } from '../useDashboardScroller'
const mockApi = vi.mocked(api)
function mockResponse(data: unknown, ok = true): Response {
return {
ok,
json: async () => data,
} as Response
}
function mountComposable(type: 'continue-reading' | 'up-next-in-series' | 'smart-scope', limit = 20, smartScopeId?: number) {
let result!: ReturnType<typeof useDashboardScroller>
mount(
defineComponent({
setup() {
result = useDashboardScroller(type, limit, smartScopeId)
return () => null
},
}),
)
return result
}
describe('useDashboardScroller', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads books for up-next-in-series on mount', async () => {
mockApi.mockResolvedValue(mockResponse([{ id: 7 }, { id: 2 }]))
const state = mountComposable('up-next-in-series', 12)
expect(state.loading.value).toBe(true)
await flushPromises()
expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/up-next-in-series?limit=12')
expect(state.books.value).toEqual([{ id: 7 }, { id: 2 }])
expect(state.error.value).toBe(false)
expect(state.loading.value).toBe(false)
})
it('includes smartScopeId only for smart-scope requests', async () => {
mockApi.mockResolvedValue(mockResponse([]))
mountComposable('smart-scope', 30, 99)
await flushPromises()
expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/smart-scope?limit=30&smartScopeId=99')
})
it('sets error=true when API response is not ok', async () => {
mockApi.mockResolvedValue(mockResponse([], false))
const state = mountComposable('continue-reading', 5)
await flushPromises()
expect(state.books.value).toEqual([])
expect(state.error.value).toBe(true)
expect(state.loading.value).toBe(false)
})
it('refresh retries after an error and updates books', async () => {
mockApi.mockResolvedValueOnce(mockResponse([], false)).mockResolvedValueOnce(mockResponse([{ id: 42 }], true))
const state = mountComposable('continue-reading', 5)
await flushPromises()
expect(state.error.value).toBe(true)
await state.refresh()
await flushPromises()
expect(state.error.value).toBe(false)
expect(state.books.value).toEqual([{ id: 42 }])
})
})
@@ -7,12 +7,14 @@ const MAX_SCROLLERS = 6
export const DEFAULT_SCROLLERS: ScrollerConfig[] = [
{ id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: false, order: 1, limit: 20 },
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 2, limit: 20 },
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 3, limit: 20 },
{ id: '4', type: 'up-next-in-series', label: 'Up Next in Series', enabled: false, order: 2, limit: 20 },
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 3, limit: 20 },
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 4, limit: 20 },
]
export const SCROLLER_LABELS: Record<ScrollerType, string> = {
'continue-reading': 'Continue Reading',
'up-next-in-series': 'Up Next in Series',
'recently-added': 'Recently Added',
random: 'Discover Something New',
'smart-scope': 'Smart Scope',
+1
View File
@@ -1,6 +1,7 @@
export const SCROLLER_TYPE = {
RECENTLY_ADDED: "recently-added",
CONTINUE_READING: "continue-reading",
UP_NEXT_IN_SERIES: "up-next-in-series",
RANDOM: "random",
SMART_SCOPE: "smart-scope",
} as const;
@@ -50,6 +50,20 @@ describe('DashboardController', () => {
vi.clearAllMocks();
});
describe('scroller endpoint', () => {
it('getScroller delegates to dashboardService with the provided scroller type and params', async () => {
const { controller, dashboardService } = makeController();
const user = makeUser({ id: 7 });
const mockResult = [{ id: 101 }];
dashboardService.getScroller.mockResolvedValue(mockResult);
const result = await controller.getScroller('up-next-in-series', 33, 0, user);
expect(dashboardService.getScroller).toHaveBeenCalledWith('up-next-in-series', user, 33, 0);
expect(result).toEqual(mockResult);
});
});
describe('widget endpoints', () => {
it('getReadingGoal delegates to widgetService', async () => {
const { controller, widgetService } = makeController();
@@ -26,13 +26,15 @@ describe('DashboardRepository', () => {
});
it('returns empty results without querying when no libraries are accessible', async () => {
const db = { select: vi.fn() };
const db = { select: vi.fn(), execute: vi.fn() };
const repo = new DashboardRepository(db as never);
await expect(repo.findRecentlyAddedBookIds([], 20)).resolves.toEqual([]);
await expect(repo.findContinueReadingBookIds([], 1, 20)).resolves.toEqual([]);
await expect(repo.findUpNextInSeriesBookIds([], 1, 20)).resolves.toEqual([]);
await expect(repo.findRandomBookIds([], 1, 20)).resolves.toEqual([]);
expect(db.select).not.toHaveBeenCalled();
expect(db.execute).not.toHaveBeenCalled();
});
it('maps recently added rows to id list', async () => {
@@ -96,4 +98,43 @@ describe('DashboardRepository', () => {
expect(result).toEqual([]);
expect(db.select).not.toHaveBeenCalled();
});
it('maps up-next-in-series rows to id list', async () => {
const db = {
select: vi.fn(),
execute: vi.fn().mockResolvedValue({ rows: [{ id: 17 }, { id: 4 }] }),
};
const repo = new DashboardRepository(db as never);
const result = await repo.findUpNextInSeriesBookIds([9], 55, 10);
expect(result).toEqual([17, 4]);
expect(db.execute).toHaveBeenCalledTimes(1);
});
it('returns empty up-next-in-series ids and does not query when limit is zero', async () => {
const db = {
select: vi.fn(),
execute: vi.fn(),
};
const repo = new DashboardRepository(db as never);
const result = await repo.findUpNextInSeriesBookIds([9], 55, 0);
expect(result).toEqual([]);
expect(db.execute).not.toHaveBeenCalled();
});
it('returns empty up-next-in-series ids when query returns no rows', async () => {
const db = {
select: vi.fn(),
execute: vi.fn().mockResolvedValue({ rows: [] }),
};
const repo = new DashboardRepository(db as never);
const result = await repo.findUpNextInSeriesBookIds([2], 101, 20);
expect(result).toEqual([]);
expect(db.execute).toHaveBeenCalledTimes(1);
});
});
@@ -5,10 +5,11 @@ import { ContentFilterRules } from '@bookorbit/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { audiobookProgress, bookFiles, books, readingProgress } from '../../db/schema';
import { audiobookProgress, bookFiles, bookMetadata, books, readingProgress, userBookStatus } from '../../db/schema';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
type Db = NodePgDatabase<typeof schema>;
type UpNextInSeriesRow = { id: number };
@Injectable()
export class DashboardRepository {
@@ -70,6 +71,117 @@ export class DashboardRepository {
return rows.map((row) => row.id);
}
async findUpNextInSeriesBookIds(
accessibleLibraryIds: number[],
userId: number,
limit: number,
contentFilters?: ContentFilterRules,
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
if (limit <= 0) return [];
const mergedProgress = sql<number>`
coalesce(
case
when ${readingProgress.updatedAt} is null then ${audiobookProgress.percentage}
when ${audiobookProgress.updatedAt} is null then ${readingProgress.percentage}
when ${readingProgress.updatedAt} >= ${audiobookProgress.updatedAt} then ${readingProgress.percentage}
else ${audiobookProgress.percentage}
end,
${readingProgress.percentage},
${audiobookProgress.percentage},
0
)
`;
const mergedUpdatedAt = sql<Date | null>`
case
when ${readingProgress.updatedAt} is null then ${audiobookProgress.updatedAt}
when ${audiobookProgress.updatedAt} is null then ${readingProgress.updatedAt}
when ${readingProgress.updatedAt} >= ${audiobookProgress.updatedAt} then ${readingProgress.updatedAt}
else ${audiobookProgress.updatedAt}
end
`;
const completionPredicate = sql<boolean>`${userBookStatus.status} in ('read', 'skimmed') or ${mergedProgress} >= 100`;
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const libraryIdList = sql.join(
accessibleLibraryIds.map((libraryId) => sql`${libraryId}`),
sql`, `,
);
const filterSql = cfClauses.length > 0 ? sql`and ${sql.join(cfClauses, sql` and `)}` : sql``;
const rows = await this.db.execute<UpNextInSeriesRow>(sql`
with scoped_series_books as (
select
${books.id} as id,
${books.libraryId} as library_id,
lower(btrim(${bookMetadata.seriesName})) as normalized_series_name,
${bookMetadata.seriesIndex} as series_index,
${books.addedAt} as added_at,
${mergedProgress} as current_progress,
case
when ${completionPredicate} then true
else false
end as is_completed,
case
when ${completionPredicate}
then greatest(
coalesce(${userBookStatus.updatedAt}, to_timestamp(0)),
coalesce(${mergedUpdatedAt}, to_timestamp(0))
)
else null
end as completion_updated_at
from ${books}
inner join ${bookMetadata} on ${bookMetadata.bookId} = ${books.id}
left join ${bookFiles} on ${bookFiles.id} = ${books.primaryFileId}
left join ${readingProgress} on ${readingProgress.bookFileId} = ${bookFiles.id} and ${readingProgress.userId} = ${userId}
left join ${audiobookProgress} on ${audiobookProgress.bookId} = ${books.id} and ${audiobookProgress.userId} = ${userId}
left join ${userBookStatus} on ${userBookStatus.bookId} = ${books.id} and ${userBookStatus.userId} = ${userId}
where ${books.libraryId} in (${libraryIdList})
and ${books.status} = 'present'
and ${bookMetadata.seriesName} is not null
and btrim(${bookMetadata.seriesName}) != ''
and ${bookMetadata.seriesIndex} is not null
${filterSql}
),
ordered_series as (
select
ssb.id,
ssb.library_id,
ssb.normalized_series_name,
ssb.series_index,
ssb.added_at,
ssb.current_progress,
ssb.is_completed,
ssb.completion_updated_at,
lag(ssb.is_completed) over (
partition by ssb.library_id, ssb.normalized_series_name
order by ssb.series_index asc, ssb.added_at asc, ssb.id asc
) as previous_is_completed,
lag(ssb.completion_updated_at) over (
partition by ssb.library_id, ssb.normalized_series_name
order by ssb.series_index asc, ssb.added_at asc, ssb.id asc
) as previous_completion_updated_at
from scoped_series_books ssb
),
next_candidates as (
select distinct on (os.library_id, os.normalized_series_name)
os.id,
os.previous_completion_updated_at
from ordered_series os
where os.previous_is_completed = true
and os.is_completed = false
and os.current_progress = 0
order by os.library_id, os.normalized_series_name, os.series_index asc, os.added_at asc, os.id asc
)
select nc.id
from next_candidates nc
order by nc.previous_completion_updated_at desc nulls last, nc.id desc
limit ${limit}
`);
return rows.rows.map((row) => row.id);
}
async findRandomBookIds(accessibleLibraryIds: number[], userId: number, limit: number, contentFilters?: ContentFilterRules): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
if (limit <= 0) return [];
@@ -29,6 +29,7 @@ function makeService() {
const dashboardRepo = {
findRecentlyAddedBookIds: vi.fn(),
findContinueReadingBookIds: vi.fn(),
findUpNextInSeriesBookIds: vi.fn(),
findRandomBookIds: vi.fn(),
};
const bookReadService = {
@@ -149,6 +150,43 @@ describe('DashboardService', () => {
expect(result.map((card) => card.id)).toEqual([4]);
});
it('routes up-next-in-series requests to repository and preserves response order', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
const user = makeUser({ id: 11 });
libraryService.findAccessibleLibraryIds.mockResolvedValue([707]);
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([19, 8]);
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([8, 19]));
const result = await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, user, 25);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([707], 11, 25, EMPTY_CONTENT_FILTER_RULES);
expect(result.map((card) => card.id)).toEqual([19, 8]);
});
it('passes undefined content filters for up-next-in-series when user is superuser', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
const superuser = makeUser({ id: 17, isSuperuser: true, contentFilters: EMPTY_CONTENT_FILTER_RULES });
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([55]);
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([55]));
await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, superuser, 20);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([1], 17, 20, undefined);
});
it('clamps up-next-in-series limit to minimum of 1', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
const user = makeUser({ id: 18 });
libraryService.findAccessibleLibraryIds.mockResolvedValue([91]);
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([3]);
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([3]));
await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, user, 0);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([91], 18, 1, EMPTY_CONTENT_FILTER_RULES);
});
it('routes random requests to repository and skips card fetch when no ids are returned', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([901]);
@@ -54,6 +54,9 @@ export class DashboardService {
case ScrollerType.CONTINUE_READING:
bookIds = await this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
break;
case ScrollerType.UP_NEXT_IN_SERIES:
bookIds = await this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
break;
case ScrollerType.RANDOM:
bookIds = await this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
break;