refactor(books): update book window to use slots

Modify book navigation composables to work with sparse subsets
by pairing loaded book IDs with their absolute position in the
full listing. This ensures books reached via the jump rail
maintain their true position even when windowed.

Closes #319
This commit is contained in:
neon
2026-06-13 04:19:25 +00:00
parent 4f4189281c
commit b48dac0e30
7 changed files with 240 additions and 38 deletions
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import { useBookNavigation } from '../useBookNavigation'
import type { BookPlaceholder, BookSlot } from '../useBookWindow'
function makeBook(id: number): BookCard {
return {
id,
status: 'active',
title: `Book ${id}`,
authors: [],
seriesName: null,
seriesIndex: null,
files: [],
publishedYear: null,
language: null,
genres: [],
tags: [],
rating: null,
readingProgress: null,
readStatus: null,
addedAt: '2024-01-01T00:00:00Z',
updatedAt: null,
metadataScore: null,
hasCover: false,
hasMetadataLocks: false,
lockedFields: [],
subtitle: null,
publisher: null,
pageCount: null,
isbn13: null,
narrators: [],
}
}
function placeholder(id: number): BookPlaceholder {
return { id, placeholder: true }
}
describe('useBookNavigation', () => {
afterEach(() => {
const nav = useBookNavigation()
nav.setBookContext([], 0)
nav.registerLoadMore(null)
})
describe('setBookContext (fully-loaded list)', () => {
it('uses the array position as the absolute index', () => {
const nav = useBookNavigation()
nav.setBookContext([10, 20, 30], 3)
expect(nav.currentIndex(10)).toBe(0)
expect(nav.currentIndex(30)).toBe(2)
expect(nav.total.value).toBe(3)
expect(nav.hasContext.value).toBe(true)
})
it('returns -1 for an id outside the context', () => {
const nav = useBookNavigation()
nav.setBookContext([10, 20], 2)
expect(nav.currentIndex(999)).toBe(-1)
})
})
describe('setBookSlotContext (windowed listing reached via the jump rail)', () => {
it('reports the true slot index for a book in a far block, not its loaded-subset position', () => {
const nav = useBookNavigation()
// Mirrors the bug: the first block (rows 0-2) loads on open, then the
// jump rail loads a far block (rows 5000-5001). Everything in between is
// still a placeholder, so the loaded subset is sparse.
const slots: BookSlot[] = [
makeBook(1),
makeBook(2),
makeBook(3),
...Array.from({ length: 4997 }, (_, i) => placeholder(-(i + 1))),
makeBook(5001),
makeBook(5002),
]
nav.setBookSlotContext(slots, 5083)
// The old compacted-list logic reported index 3 (the 4th loaded card);
// the fix reports the real slot index.
expect(nav.currentIndex(5001)).toBe(5000)
expect(nav.currentIndex(5002)).toBe(5001)
// Head books keep their low positions.
expect(nav.currentIndex(1)).toBe(0)
expect(nav.total.value).toBe(5083)
})
it('returns -1 for a book that is not loaded into any slot', () => {
const nav = useBookNavigation()
nav.setBookSlotContext([makeBook(1), placeholder(-1)], 2)
expect(nav.currentIndex(999)).toBe(-1)
})
})
describe('prev/next adjacency', () => {
it('navigates between absolute-adjacent loaded books', async () => {
const nav = useBookNavigation()
nav.setBookSlotContext([makeBook(1), makeBook(2), makeBook(3)], 3)
expect(nav.getPrevId(2)).toBe(1)
expect(await nav.getNextId(2)).toBe(3)
})
it('does not jump across a placeholder gap left by the jump rail', async () => {
const nav = useBookNavigation()
// Loaded: rows 0,1 and row 3; row 2 is a placeholder.
const slots: BookSlot[] = [makeBook(1), makeBook(2), placeholder(-1), makeBook(50)]
nav.setBookSlotContext(slots, 4)
// Book 2 (index 1) has no loaded neighbour at index 2, so next is null
// rather than the far book 50 (index 3).
expect(await nav.getNextId(2)).toBe(null)
// Book 50 (index 3) has a placeholder at index 2, so prev is null.
expect(nav.getPrevId(50)).toBe(null)
})
})
describe('getNextId load-more at the end of a contiguous prefix', () => {
it('loads the next chunk and returns the newly adjacent book', async () => {
const nav = useBookNavigation()
nav.setBookContext([1, 2], 4)
nav.registerLoadMore(async () => {
nav.setBookContext([1, 2, 3, 4], 4)
})
expect(await nav.getNextId(2)).toBe(3)
})
it('returns null when load-more adds nothing', async () => {
const nav = useBookNavigation()
nav.setBookContext([1, 2], 4)
nav.registerLoadMore(async () => {})
expect(await nav.getNextId(2)).toBe(null)
})
})
})
@@ -4,6 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import { useBookNavigation } from '../useBookNavigation'
import { useBookViewContext } from '../useBookViewContext'
import type { BookPlaceholder, BookSlot } from '../useBookWindow'
function placeholder(id: number): BookPlaceholder {
return { id, placeholder: true }
}
function makeBook(id: number): BookCard {
return {
@@ -66,4 +71,36 @@ describe('useBookViewContext', () => {
expect(loadMore).toHaveBeenCalledTimes(1)
expect(nextId).toBe(3)
})
it('reports the absolute slot index after a jump rail loads a far block', async () => {
const nav = useBookNavigation()
// First block (rows 0-1) plus a far block (rows 5000-5001) loaded; the
// rows in between are still placeholders, as after a jump to "Z".
const slots = ref<BookSlot[]>([
makeBook(1),
makeBook(2),
...Array.from({ length: 4998 }, (_, i) => placeholder(-(i + 1))),
makeBook(5001),
makeBook(5002),
])
const total = ref(5002)
const loadMore = vi.fn(async () => {})
const Harness = defineComponent({
setup() {
useBookViewContext(slots, total, loadMore)
return {}
},
template: '<div />',
})
const wrapper = mount(Harness)
expect(nav.currentIndex(5001)).toBe(5000)
expect(nav.currentIndex(5002)).toBe(5001)
expect(nav.currentIndex(1)).toBe(0)
expect(nav.total.value).toBe(5002)
wrapper.unmount()
})
})
@@ -1,12 +1,35 @@
import { computed, ref } from 'vue'
import { isBookPlaceholder, type BookSlot } from './useBookWindow'
const bookIds = ref<number[]>([])
// Each entry pairs a loaded book id with its absolute position in the full
// listing. For fully-loaded lists the index equals the array position; for a
// windowed listing it equals the book's slot index, so a book reached via the
// jump rail keeps its true position even though only a sparse subset is loaded.
type NavEntry = { id: number; index: number }
const entries = ref<NavEntry[]>([])
const total = ref(0)
let loadMoreCallback: (() => Promise<void>) | null = null
const positionById = computed(() => {
const map = new Map<number, number>()
entries.value.forEach((entry, pos) => map.set(entry.id, pos))
return map
})
export function useBookNavigation() {
const setBookContext = (ids: number[], totalCount: number) => {
bookIds.value = ids
entries.value = ids.map((id, index) => ({ id, index }))
total.value = totalCount
}
const setBookSlotContext = (slots: BookSlot[], totalCount: number) => {
const next: NavEntry[] = []
for (let i = 0; i < slots.length; i++) {
const slot = slots[i]!
if (!isBookPlaceholder(slot)) next.push({ id: slot.id, index: i })
}
entries.value = next
total.value = totalCount
}
@@ -15,43 +38,46 @@ export function useBookNavigation() {
}
const getNextId = async (currentId: number) => {
const idx = bookIds.value.indexOf(currentId)
if (idx !== -1) {
if (idx < bookIds.value.length - 1) {
return bookIds.value[idx + 1]
}
if (bookIds.value.length < total.value && loadMoreCallback) {
const beforeLength = bookIds.value.length
await loadMoreCallback()
if (bookIds.value.length === beforeLength) {
// No more books were loaded, avoid infinite loop
return null
}
// After loading more, check again
const newIdx = bookIds.value.indexOf(currentId)
if (newIdx !== -1 && newIdx < bookIds.value.length - 1) {
return bookIds.value[newIdx + 1]
}
const pos = positionById.value.get(currentId)
if (pos === undefined) return null
const current = entries.value[pos]!
const next = entries.value[pos + 1]
if (next && next.index === current.index + 1) return next.id
if (current.index + 1 < total.value && loadMoreCallback) {
const beforeLength = entries.value.length
await loadMoreCallback()
if (entries.value.length === beforeLength) {
// No more books were loaded, avoid infinite loop
return null
}
const newPos = positionById.value.get(currentId)
if (newPos === undefined) return null
const reloaded = entries.value[newPos]!
const after = entries.value[newPos + 1]
if (after && after.index === reloaded.index + 1) return after.id
}
return null
}
const getPrevId = (currentId: number) => {
const idx = bookIds.value.indexOf(currentId)
if (idx !== -1 && idx > 0) {
return bookIds.value[idx - 1]
}
return null
const pos = positionById.value.get(currentId)
if (pos === undefined || pos === 0) return null
const current = entries.value[pos]!
const prev = entries.value[pos - 1]!
return prev.index === current.index - 1 ? prev.id : null
}
const hasContext = computed(() => bookIds.value.length > 0)
const currentIndex = (currentId: number) => bookIds.value.indexOf(currentId)
const hasContext = computed(() => entries.value.length > 0)
const currentIndex = (currentId: number) => {
const pos = positionById.value.get(currentId)
return pos === undefined ? -1 : entries.value[pos]!.index
}
return {
bookIds: computed(() => bookIds.value),
bookIds: computed(() => entries.value.map((entry) => entry.id)),
total: computed(() => total.value),
setBookContext,
setBookSlotContext,
registerLoadMore,
getNextId,
getPrevId,
@@ -1,17 +1,14 @@
import { onMounted, watch, type Ref } from 'vue'
import type { BookCard } from '@bookorbit/types'
import { useBookNavigation } from './useBookNavigation'
import type { BookSlot } from './useBookWindow'
export function useBookViewContext(books: Ref<BookCard[]>, total: Ref<number>, loadMore: () => Promise<unknown> | unknown) {
const { setBookContext, registerLoadMore } = useBookNavigation()
export function useBookViewContext(slots: Ref<BookSlot[]>, total: Ref<number>, loadMore: () => Promise<unknown> | unknown) {
const { setBookSlotContext, registerLoadMore } = useBookNavigation()
const syncBookContext = () => {
setBookContext(
books.value.map((book) => book.id),
total.value,
)
setBookSlotContext(slots.value, total.value)
}
watch([books, total], () => syncBookContext(), { immediate: true })
watch([slots, total], () => syncBookContext(), { immediate: true })
onMounted(() => {
registerLoadMore(async () => {
+1 -1
View File
@@ -108,7 +108,7 @@ const {
const { sortModel: tableSortModel } = useViewSort(tableSort, 'collection', collectionId)
const collectionLoadError = computed(() => collectionsError.value ?? booksError.value)
const { setBookContext } = useBookNavigation()
useBookViewContext(books, total, loadMorePrefix)
useBookViewContext(slots, total, loadMorePrefix)
function handleSaveCurrentView(name: string) {
if (!tableRef.value) return
+1 -1
View File
@@ -136,7 +136,7 @@ const {
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { setBookContext } = useBookNavigation()
useBookViewContext(books, total, loadMorePrefix)
useBookViewContext(slots, total, loadMorePrefix)
const FILTER_STORAGE_PREFIX = 'bookorbit:filter:library:'
function getFilterKey(id: number) {
+1 -1
View File
@@ -96,7 +96,7 @@ const {
q: debouncedQuery,
})
const { setBookContext } = useBookNavigation()
useBookViewContext(books, total, loadMorePrefix)
useBookViewContext(slots, total, loadMorePrefix)
const { smartScopes, loaded: smartScopesLoaded, error: smartScopesError, fetchSmartScopes, deleteSmartScope } = useSmartScopes()
const smartScopeNotFound = ref(false)
const smartScopeLoadError = computed(() => smartScopesError.value ?? booksError.value)