mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add continue listening and want to read dashboard scrollers
Add dedicated dashboard scrollers for audiobooks and want-to-read lists to improve content discoverability. Previously, audiobook progress was merged with reading progress, making it difficult to find active audiobooks. Separate the queries to ensure audiobooks are handled exclusively by the new listening scroller and expose the user's want-to-read queue directly on the dashboard for easier access.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { BookCard, ScrollerType } from '@bookorbit/types'
|
||||
|
||||
type DashboardScrollerState = {
|
||||
books: Ref<BookCard[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<boolean>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
type UseDashboardScrollerMock = (type: ScrollerType, limit?: number, smartScopeId?: number) => DashboardScrollerState
|
||||
|
||||
const deleteMocks = vi.hoisted(() => ({
|
||||
promptDelete: vi.fn<(id: number) => void>(),
|
||||
cancelDelete: vi.fn<() => void>(),
|
||||
confirmDelete: vi.fn<() => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/components/BookCoverCard.vue', () => ({
|
||||
default: {
|
||||
name: 'BookCoverCard',
|
||||
props: ['book', 'coverAspectRatio'],
|
||||
emits: ['action'],
|
||||
template: `
|
||||
<div class="book-card" :data-aspect="coverAspectRatio">
|
||||
<span>{{ book.title }}</span>
|
||||
<button class="quick-action" @click="$emit('action', 'quick-view')" />
|
||||
<button class="collection-action" @click="$emit('action', 'add-to-collection')" />
|
||||
<button class="delete-action" @click="$emit('action', 'delete')" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/features/book/components/BookQuickView.vue', () => ({
|
||||
default: {
|
||||
name: 'BookQuickView',
|
||||
props: ['bookId', 'open'],
|
||||
emits: ['update:open'],
|
||||
template: `<div class="quick-view" :data-open="String(open)" :data-book-id="bookId == null ? '' : String(bookId)" />`,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/features/collection/components/AddToCollectionSheet.vue', () => ({
|
||||
default: {
|
||||
name: 'AddToCollectionSheet',
|
||||
props: ['open', 'bookIds'],
|
||||
emits: ['update:open'],
|
||||
template: '<div class="collection-sheet" :data-open="String(open)" :data-book-ids="bookIds.join(\',\')" />',
|
||||
},
|
||||
}))
|
||||
vi.mock('@/features/book/components/DeleteBookDialog.vue', () => ({
|
||||
default: {
|
||||
name: 'DeleteBookDialog',
|
||||
props: ['open', 'deleting'],
|
||||
emits: ['confirm', 'cancel'],
|
||||
template: '<div class="delete-dialog" :data-open="String(open)" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/composables/useDeleteBook', () => ({
|
||||
useDeleteBook: vi.fn<() => unknown>(() => ({
|
||||
pendingId: ref(null),
|
||||
deleting: ref(false),
|
||||
promptDelete: deleteMocks.promptDelete,
|
||||
cancelDelete: deleteMocks.cancelDelete,
|
||||
confirmDelete: deleteMocks.confirmDelete,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useDashboardScroller', () => ({
|
||||
useDashboardScroller: vi.fn<UseDashboardScrollerMock>(),
|
||||
}))
|
||||
|
||||
import DashboardScroller from './DashboardScroller.vue'
|
||||
import { useDashboardScroller } from '../composables/useDashboardScroller'
|
||||
|
||||
const mockUseDashboardScroller = vi.mocked(useDashboardScroller)
|
||||
|
||||
function makeBook(id: number, format: string): BookCard {
|
||||
return {
|
||||
id,
|
||||
status: 'present',
|
||||
title: `Book ${id}`,
|
||||
authors: [],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [{ id, format, role: 'content', sizeBytes: 123 }],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: null,
|
||||
metadataScore: null,
|
||||
hasCover: false,
|
||||
hasMetadataLocks: false,
|
||||
lockedFields: [],
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
pageCount: null,
|
||||
isbn13: null,
|
||||
narrators: [],
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
function mountScroller({
|
||||
type,
|
||||
books = [],
|
||||
loading = false,
|
||||
error = false,
|
||||
refresh = vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
}: {
|
||||
type: ScrollerType
|
||||
books?: BookCard[]
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
refresh?: () => Promise<void>
|
||||
}) {
|
||||
mockUseDashboardScroller.mockReturnValue({
|
||||
books: ref(books),
|
||||
loading: ref(loading),
|
||||
error: ref(error),
|
||||
refresh,
|
||||
})
|
||||
|
||||
return mount(DashboardScroller, {
|
||||
props: {
|
||||
type,
|
||||
title: 'Shelf',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('DashboardScroller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['continue-listening', 'No audiobooks in progress yet. Start listening to one to see it here.'],
|
||||
['want-to-read', 'No books marked want to read yet.'],
|
||||
] as const)('renders empty-state copy for %s', (type, copy) => {
|
||||
const wrapper = mountScroller({ type })
|
||||
|
||||
expect(wrapper.text()).toContain(copy)
|
||||
expect(mockUseDashboardScroller).toHaveBeenCalledWith(type, undefined, undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['continue-reading', 'No books in progress yet. Start reading one to see it here.'],
|
||||
['up-next-in-series', 'No next-in-series picks yet. Finish a volume to surface the next one.'],
|
||||
['recently-added', 'No books in your library yet.'],
|
||||
['smart-scope', 'No books match this smartScope.'],
|
||||
['random', 'No books found.'],
|
||||
] as const)('keeps existing empty-state copy for %s', (type, copy) => {
|
||||
expect(mountScroller({ type }).text()).toContain(copy)
|
||||
})
|
||||
|
||||
it('renders loading skeletons', () => {
|
||||
const wrapper = mountScroller({ type: 'continue-listening', loading: true })
|
||||
|
||||
expect(wrapper.findAll('.animate-pulse')).toHaveLength(8)
|
||||
expect(wrapper.text()).not.toContain('No audiobooks in progress yet')
|
||||
})
|
||||
|
||||
it('renders an error state and retries', async () => {
|
||||
const refresh = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const wrapper = mountScroller({ type: 'want-to-read', error: true, refresh })
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to load.')
|
||||
const retryButton = wrapper.findAll('button').find((button) => button.text().includes('Retry'))
|
||||
expect(retryButton).toBeDefined()
|
||||
await retryButton?.trigger('click')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders book cards with count and cover shape based on audio format', () => {
|
||||
const wrapper = mountScroller({ type: 'continue-listening', books: [makeBook(1, 'm4b'), makeBook(2, 'epub')] })
|
||||
|
||||
const cards = wrapper.findAll('.book-card')
|
||||
expect(wrapper.text()).toContain('2')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]?.attributes('data-aspect')).toBe('1/1')
|
||||
expect(cards[1]?.attributes('data-aspect')).toBe('2/3')
|
||||
})
|
||||
|
||||
it('opens related book actions from card events', async () => {
|
||||
const wrapper = mountScroller({ type: 'want-to-read', books: [makeBook(42, 'epub')] })
|
||||
|
||||
await wrapper.find('.quick-action').trigger('click')
|
||||
expect(wrapper.find('.quick-view').attributes('data-open')).toBe('true')
|
||||
expect(wrapper.find('.quick-view').attributes('data-book-id')).toBe('42')
|
||||
|
||||
await wrapper.find('.collection-action').trigger('click')
|
||||
expect(wrapper.find('.collection-sheet').attributes('data-open')).toBe('true')
|
||||
expect(wrapper.find('.collection-sheet').attributes('data-book-ids')).toBe('42')
|
||||
|
||||
await wrapper.find('.delete-action').trigger('click')
|
||||
expect(deleteMocks.promptDelete).toHaveBeenCalledWith(42)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useAttrs } from 'vue'
|
||||
import { Aperture, BookMarked, ChevronLeft, ChevronRight, ListOrdered, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
|
||||
import { Aperture, BookMarked, BookmarkPlus, ChevronLeft, ChevronRight, Headphones, ListOrdered, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
|
||||
|
||||
import { isAudioFormat, type BookCard, type CoverAspectRatio, type ScrollerType } from '@bookorbit/types'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
@@ -33,6 +33,8 @@ function scrollBy(delta: number) {
|
||||
|
||||
const typeIcon = computed(() => {
|
||||
if (props.type === 'continue-reading') return BookMarked
|
||||
if (props.type === 'continue-listening') return Headphones
|
||||
if (props.type === 'want-to-read') return BookmarkPlus
|
||||
if (props.type === 'up-next-in-series') return ListOrdered
|
||||
if (props.type === 'recently-added') return Sparkles
|
||||
if (props.type === 'smart-scope') return Aperture
|
||||
@@ -145,6 +147,8 @@ function coverAspectRatio(book: BookCard): CoverAspectRatio {
|
||||
</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 === 'continue-listening'">No audiobooks in progress yet. Start listening to one to see it here.</template>
|
||||
<template v-else-if="type === 'want-to-read'">No books marked want to read yet.</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>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import type { WidgetConfig } from '@bookorbit/types'
|
||||
|
||||
type UseSmartScopesMock = () => {
|
||||
smartScopes: Ref<unknown[]>
|
||||
fetchSmartScopes: () => void
|
||||
}
|
||||
|
||||
type UseDashboardWidgetsMock = () => {
|
||||
widgets: Ref<WidgetConfig[]>
|
||||
saveWidgets: (widgets: WidgetConfig[]) => Promise<void>
|
||||
WIDGET_LABELS: Record<string, string>
|
||||
DEFAULT_WIDGETS: WidgetConfig[]
|
||||
}
|
||||
|
||||
vi.mock('@/components/ui/sheet', () => {
|
||||
const passthrough = { template: '<div><slot /></div>' }
|
||||
return {
|
||||
Sheet: { props: ['open'], emits: ['update:open'], template: '<div><slot /></div>' },
|
||||
SheetContent: passthrough,
|
||||
SheetHeader: passthrough,
|
||||
SheetTitle: passthrough,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({
|
||||
useSmartScopes: vi.fn<UseSmartScopesMock>(() => ({
|
||||
smartScopes: ref<unknown[]>([]),
|
||||
fetchSmartScopes: vi.fn<() => void>(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useDashboardWidgets', () => ({
|
||||
useDashboardWidgets: vi.fn<UseDashboardWidgetsMock>(() => ({
|
||||
widgets: ref<WidgetConfig[]>([]),
|
||||
saveWidgets: vi.fn<(widgets: WidgetConfig[]) => Promise<void>>(),
|
||||
WIDGET_LABELS: {},
|
||||
DEFAULT_WIDGETS: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
import DashboardSettingsSheet from './DashboardSettingsSheet.vue'
|
||||
|
||||
describe('DashboardSettingsSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('includes continue-listening and want-to-read in the shelf selector', async () => {
|
||||
const wrapper = mount(DashboardSettingsSheet, {
|
||||
props: { open: false },
|
||||
})
|
||||
|
||||
await wrapper.setProps({ open: true })
|
||||
await nextTick()
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Shelves')
|
||||
?.trigger('click')
|
||||
|
||||
const optionLabels = wrapper
|
||||
.find('select')
|
||||
.findAll('option')
|
||||
.map((option) => option.text())
|
||||
|
||||
expect(optionLabels).toContain('Continue Listening')
|
||||
expect(optionLabels).toContain('Want to Read')
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,15 @@ const {
|
||||
moveDown: widgetMoveDown,
|
||||
} = useDraggableList(widgetDraft)
|
||||
// ── Add / Remove ─────────────────────────────────────────────
|
||||
const ALL_TYPES: ScrollerType[] = ['continue-reading', 'up-next-in-series', 'recently-added', 'random', 'smart-scope']
|
||||
const ALL_TYPES: ScrollerType[] = [
|
||||
'continue-reading',
|
||||
'continue-listening',
|
||||
'want-to-read',
|
||||
'up-next-in-series',
|
||||
'recently-added',
|
||||
'random',
|
||||
'smart-scope',
|
||||
]
|
||||
|
||||
function addScroller() {
|
||||
if (draft.value.length >= MAX_SCROLLERS) return
|
||||
|
||||
@@ -49,11 +49,19 @@ describe('useDashboardConfig', () => {
|
||||
|
||||
expect(scrollers.value).toEqual(DEFAULT_SCROLLERS)
|
||||
expect(scrollers.value).not.toBe(DEFAULT_SCROLLERS)
|
||||
expect(DEFAULT_SCROLLERS.map((scroller) => [scroller.type, scroller.label, scroller.enabled, scroller.order])).toEqual([
|
||||
['continue-reading', 'Continue Reading', false, 1],
|
||||
['continue-listening', 'Continue Listening', true, 2],
|
||||
['want-to-read', 'Want to Read', true, 3],
|
||||
['up-next-in-series', 'Up Next in Series', false, 4],
|
||||
['recently-added', 'Recently Added', true, 5],
|
||||
['random', 'Discover Something New', true, 6],
|
||||
])
|
||||
|
||||
addScroller('smart-scope')
|
||||
|
||||
expect(scrollers.value).toHaveLength(5)
|
||||
expect(DEFAULT_SCROLLERS).toHaveLength(4)
|
||||
expect(scrollers.value).toHaveLength(7)
|
||||
expect(DEFAULT_SCROLLERS).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('prunes shelves that reference deleted smart scopes', async () => {
|
||||
@@ -153,6 +161,49 @@ describe('useDashboardConfig', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves and normalizes continue-listening and want-to-read shelves', async () => {
|
||||
const { useDashboardConfig } = await import('../useDashboardConfig')
|
||||
const { scrollers, saveScrollers } = useDashboardConfig()
|
||||
|
||||
saveScrollers([
|
||||
{
|
||||
id: '21',
|
||||
type: 'continue-listening',
|
||||
label: '',
|
||||
enabled: 'true',
|
||||
order: 10,
|
||||
limit: '9',
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
type: 'want-to-read',
|
||||
label: 'Reading Queue',
|
||||
enabled: 'false',
|
||||
order: 11,
|
||||
limit: 12,
|
||||
},
|
||||
] as unknown as ScrollerConfig[])
|
||||
|
||||
expect(scrollers.value).toEqual([
|
||||
{
|
||||
id: '21',
|
||||
type: 'continue-listening',
|
||||
label: 'Continue Listening',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
limit: 9,
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
type: 'want-to-read',
|
||||
label: 'Reading Queue',
|
||||
enabled: false,
|
||||
order: 2,
|
||||
limit: 12,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not rewrite storage when smart scope prune keeps the same scrollers', async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import type { ScrollerType } from '@bookorbit/types'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<() => Promise<Response>>(),
|
||||
@@ -18,7 +19,7 @@ function mockResponse(data: unknown, ok = true): Response {
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mountComposable(type: 'continue-reading' | 'up-next-in-series' | 'smart-scope', limit = 20, smartScopeId?: number) {
|
||||
function mountComposable(type: ScrollerType, limit = 20, smartScopeId?: number) {
|
||||
let result!: ReturnType<typeof useDashboardScroller>
|
||||
mount(
|
||||
defineComponent({
|
||||
@@ -58,6 +59,18 @@ describe('useDashboardScroller', () => {
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/smart-scope?limit=30&smartScopeId=99')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['continue-listening', '/api/v1/dashboard/scrollers/continue-listening?limit=8'],
|
||||
['want-to-read', '/api/v1/dashboard/scrollers/want-to-read?limit=9'],
|
||||
] as const)('loads books for %s on mount', async (type, expectedPath) => {
|
||||
mockApi.mockResolvedValue(mockResponse([{ id: 1 }]))
|
||||
|
||||
mountComposable(type, type === 'continue-listening' ? 8 : 9)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith(expectedPath)
|
||||
})
|
||||
|
||||
it('sets error=true when API response is not ok', async () => {
|
||||
mockApi.mockResolvedValue(mockResponse([], false))
|
||||
const state = mountComposable('continue-reading', 5)
|
||||
|
||||
@@ -3,17 +3,21 @@ import { ref } from 'vue'
|
||||
import { SCROLLER_TYPES, type ScrollerConfig, type ScrollerType } from '@bookorbit/types'
|
||||
|
||||
const STORAGE_KEY = 'bookorbit:dashboard:config'
|
||||
const MAX_SCROLLERS = 6
|
||||
const MAX_SCROLLERS = 8
|
||||
|
||||
export const DEFAULT_SCROLLERS: ScrollerConfig[] = [
|
||||
{ id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: false, order: 1, 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 },
|
||||
{ id: '5', type: 'continue-listening', label: 'Continue Listening', enabled: true, order: 2, limit: 20 },
|
||||
{ id: '6', type: 'want-to-read', label: 'Want to Read', enabled: true, order: 3, limit: 20 },
|
||||
{ id: '4', type: 'up-next-in-series', label: 'Up Next in Series', enabled: false, order: 4, limit: 20 },
|
||||
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 5, limit: 20 },
|
||||
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 6, limit: 20 },
|
||||
]
|
||||
|
||||
export const SCROLLER_LABELS: Record<ScrollerType, string> = {
|
||||
'continue-reading': 'Continue Reading',
|
||||
'continue-listening': 'Continue Listening',
|
||||
'want-to-read': 'Want to Read',
|
||||
'up-next-in-series': 'Up Next in Series',
|
||||
'recently-added': 'Recently Added',
|
||||
random: 'Discover Something New',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const SCROLLER_TYPE = {
|
||||
RECENTLY_ADDED: "recently-added",
|
||||
CONTINUE_READING: "continue-reading",
|
||||
CONTINUE_LISTENING: "continue-listening",
|
||||
WANT_TO_READ: "want-to-read",
|
||||
UP_NEXT_IN_SERIES: "up-next-in-series",
|
||||
RANDOM: "random",
|
||||
SMART_SCOPE: "smart-scope",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DashboardRepository } from './dashboard.repository';
|
||||
import { audiobookProgress, bookFiles, readingProgress, userBookStatus } from '../../db/schema';
|
||||
|
||||
function makeLimitChain<T>(rows: T) {
|
||||
const chain: Record<string, vi.Mock> = {
|
||||
@@ -31,6 +32,8 @@ describe('DashboardRepository', () => {
|
||||
|
||||
await expect(repo.findRecentlyAddedBookIds([], 20)).resolves.toEqual([]);
|
||||
await expect(repo.findContinueReadingBookIds([], 1, 20)).resolves.toEqual([]);
|
||||
await expect(repo.findContinueListeningBookIds([], 1, 20)).resolves.toEqual([]);
|
||||
await expect(repo.findWantToReadBookIds([], 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();
|
||||
@@ -48,7 +51,7 @@ describe('DashboardRepository', () => {
|
||||
expect(listChain.limit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('maps continue-reading rows to id list', async () => {
|
||||
it('maps continue-reading rows to id list without joining audiobook progress', async () => {
|
||||
const listChain = makeLimitChain([{ id: 40 }, { id: 9 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(listChain) };
|
||||
const repo = new DashboardRepository(db as never);
|
||||
@@ -56,11 +59,46 @@ describe('DashboardRepository', () => {
|
||||
const result = await repo.findContinueReadingBookIds([8], 55, 10);
|
||||
|
||||
expect(result).toEqual([40, 9]);
|
||||
expect(listChain.leftJoin).toHaveBeenCalledTimes(3);
|
||||
expect(listChain.leftJoin).toHaveBeenCalledTimes(2);
|
||||
expect(listChain.leftJoin.mock.calls[0]?.[0]).toBe(bookFiles);
|
||||
expect(listChain.leftJoin.mock.calls[1]?.[0]).toBe(readingProgress);
|
||||
expect(listChain.leftJoin.mock.calls.some((call) => call[0] === audiobookProgress)).toBe(false);
|
||||
expect(listChain.innerJoin).not.toHaveBeenCalled();
|
||||
expect(listChain.orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(listChain.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('maps continue-listening rows to id list and joins audio progress files', async () => {
|
||||
const listChain = makeLimitChain([{ id: 41 }, { id: 10 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(listChain) };
|
||||
const repo = new DashboardRepository(db as never);
|
||||
|
||||
const result = await repo.findContinueListeningBookIds([8], 55, 10);
|
||||
|
||||
expect(result).toEqual([41, 10]);
|
||||
expect(listChain.innerJoin).toHaveBeenCalledTimes(2);
|
||||
expect(listChain.innerJoin.mock.calls[0]?.[0]).toBe(audiobookProgress);
|
||||
expect(listChain.innerJoin.mock.calls[1]?.[0]).toBe(bookFiles);
|
||||
expect(listChain.leftJoin).not.toHaveBeenCalled();
|
||||
expect(listChain.orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(listChain.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('maps want-to-read rows to id list and joins user status', async () => {
|
||||
const listChain = makeLimitChain([{ id: 77 }, { id: 12 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(listChain) };
|
||||
const repo = new DashboardRepository(db as never);
|
||||
|
||||
const result = await repo.findWantToReadBookIds([12], 55, 6);
|
||||
|
||||
expect(result).toEqual([77, 12]);
|
||||
expect(listChain.innerJoin).toHaveBeenCalledTimes(1);
|
||||
expect(listChain.innerJoin.mock.calls[0]?.[0]).toBe(userBookStatus);
|
||||
expect(listChain.leftJoin).not.toHaveBeenCalled();
|
||||
expect(listChain.orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(listChain.limit).toHaveBeenCalledWith(6);
|
||||
});
|
||||
|
||||
it('returns empty random ids when there are no candidates', async () => {
|
||||
const listChain = makeLimitChain([]);
|
||||
const db = { select: vi.fn().mockReturnValue(listChain) };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, isNull, notInArray, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { ContentFilterRules } from '@bookorbit/types';
|
||||
import { BOOK_FORMATS, isAudioFormat, type ContentFilterRules } from '@bookorbit/types';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
@@ -10,6 +10,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);
|
||||
|
||||
@Injectable()
|
||||
export class DashboardRepository {
|
||||
@@ -35,27 +36,6 @@ export class DashboardRepository {
|
||||
contentFilters?: ContentFilterRules,
|
||||
): Promise<number[]> {
|
||||
if (accessibleLibraryIds.length === 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 cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const rows = await this.db
|
||||
@@ -63,9 +43,69 @@ 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(audiobookProgress, and(eq(audiobookProgress.bookId, books.id), eq(audiobookProgress.userId, userId)))
|
||||
.where(and(inArray(books.libraryId, accessibleLibraryIds), sql`${mergedProgress} > 0 and ${mergedProgress} < 100`, ...cfClauses))
|
||||
.orderBy(desc(mergedUpdatedAt), desc(books.id))
|
||||
.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`,
|
||||
...cfClauses,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(readingProgress.updatedAt), desc(books.id))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findContinueListeningBookIds(
|
||||
accessibleLibraryIds: number[],
|
||||
userId: number,
|
||||
limit: number,
|
||||
contentFilters?: ContentFilterRules,
|
||||
): Promise<number[]> {
|
||||
if (accessibleLibraryIds.length === 0) return [];
|
||||
|
||||
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const rows = await this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.innerJoin(audiobookProgress, and(eq(audiobookProgress.bookId, books.id), eq(audiobookProgress.userId, userId)))
|
||||
.innerJoin(
|
||||
bookFiles,
|
||||
and(
|
||||
eq(bookFiles.id, audiobookProgress.currentFileId),
|
||||
eq(bookFiles.bookId, books.id),
|
||||
eq(bookFiles.role, 'content'),
|
||||
inArray(bookFiles.format, AUDIO_FORMATS),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(books.libraryId, accessibleLibraryIds),
|
||||
eq(books.status, 'present'),
|
||||
sql`${audiobookProgress.percentage} > 0 and ${audiobookProgress.percentage} < 100`,
|
||||
...cfClauses,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(audiobookProgress.updatedAt), desc(books.id))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findWantToReadBookIds(accessibleLibraryIds: number[], userId: number, limit: number, contentFilters?: ContentFilterRules): Promise<number[]> {
|
||||
if (accessibleLibraryIds.length === 0) return [];
|
||||
|
||||
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const rows = await this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.innerJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, userId)))
|
||||
.where(
|
||||
and(inArray(books.libraryId, accessibleLibraryIds), eq(books.status, 'present'), eq(userBookStatus.status, 'want_to_read'), ...cfClauses),
|
||||
)
|
||||
.orderBy(desc(userBookStatus.updatedAt), desc(books.id))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
|
||||
@@ -29,6 +29,8 @@ function makeService() {
|
||||
const dashboardRepo = {
|
||||
findRecentlyAddedBookIds: vi.fn(),
|
||||
findContinueReadingBookIds: vi.fn(),
|
||||
findContinueListeningBookIds: vi.fn(),
|
||||
findWantToReadBookIds: vi.fn(),
|
||||
findUpNextInSeriesBookIds: vi.fn(),
|
||||
findRandomBookIds: vi.fn(),
|
||||
};
|
||||
@@ -150,6 +152,33 @@ describe('DashboardService', () => {
|
||||
expect(result.map((card) => card.id)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('routes continue listening requests to repository with user scope and content filters', async () => {
|
||||
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
|
||||
const user = makeUser({ id: 14 });
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([302, 303]);
|
||||
dashboardRepo.findContinueListeningBookIds.mockResolvedValue([6]);
|
||||
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([6]));
|
||||
|
||||
const result = await service.getScroller(ScrollerType.CONTINUE_LISTENING, user, 500);
|
||||
|
||||
expect(dashboardRepo.findContinueListeningBookIds).toHaveBeenCalledWith([302, 303], 14, 50, EMPTY_CONTENT_FILTER_RULES);
|
||||
expect(bookReadService.findCardsByBookIds).toHaveBeenCalledWith([6], 14);
|
||||
expect(result.map((card) => card.id)).toEqual([6]);
|
||||
});
|
||||
|
||||
it('routes want-to-read requests to repository and preserves response order', async () => {
|
||||
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
|
||||
const user = makeUser({ id: 21 });
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([404]);
|
||||
dashboardRepo.findWantToReadBookIds.mockResolvedValue([31, 22]);
|
||||
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([22, 31]));
|
||||
|
||||
const result = await service.getScroller(ScrollerType.WANT_TO_READ, user, 7);
|
||||
|
||||
expect(dashboardRepo.findWantToReadBookIds).toHaveBeenCalledWith([404], 21, 7, EMPTY_CONTENT_FILTER_RULES);
|
||||
expect(result.map((card) => card.id)).toEqual([31, 22]);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -54,6 +54,12 @@ export class DashboardService {
|
||||
case ScrollerType.CONTINUE_READING:
|
||||
bookIds = await this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
case ScrollerType.CONTINUE_LISTENING:
|
||||
bookIds = await this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
case ScrollerType.WANT_TO_READ:
|
||||
bookIds = await this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
case ScrollerType.UP_NEXT_IN_SERIES:
|
||||
bookIds = await this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user