feat(books): expand and refine library jump navigation

Extend jump rails across alphabetical, temporal, and categorical sorts.

Keep large libraries responsive with bounded database buckets and exact offsets.

Refine active-state tracking, Unicode initials, localized labels, accessibility, adaptive sizing, and flat or collapsed-series navigation.
This commit is contained in:
neon
2026-07-16 04:14:09 +00:00
parent 5923ea95a5
commit 504c9ce0e7
29 changed files with 899 additions and 316 deletions
+18 -163
View File
@@ -1,29 +1,21 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { TransitionPresets, useElementBounding, useMediaQuery, useTransition } from '@vueuse/core'
import type { JumpBucket, JumpBucketKind, TemporalJumpBucketGranularity } from '@bookorbit/types'
import type { JumpBucket, JumpBucketKind, SortField, TemporalJumpBucketGranularity } from '@bookorbit/types'
import { useI18n } from 'vue-i18n'
import { formatDate, formatNumber } from '@/i18n/formatters'
import { useJumpRailSlots, type JumpRailSlot as RailSlot } from '../composables/useJumpRailSlots'
const { t, locale } = useI18n()
const { t } = useI18n()
const SLOT_PX = 20
const RAIL_PAD_PX = 8
const SCROLL_PULSE_MS = 900
const MAX_TEMPORAL_SLOTS = 64
type RailSlot = {
key: string
label: string
displayLabel: string
accessibleLabel: string
bucket: JumpBucket | null
}
const props = defineProps<{
visible: boolean
buckets: JumpBucket[]
kind: JumpBucketKind
field: SortField
granularity?: TemporalJumpBucketGranularity | null
activeKey: string | null
template?: string[]
@@ -47,153 +39,13 @@ const railPositionStyle = computed(() => {
}
})
function temporalLabels(bucket: JumpBucket): Pick<RailSlot, 'label' | 'displayLabel' | 'accessibleLabel'> {
if (bucket.isUnknown) {
return { label: t('book.jumpRail.unknownDate'), displayLabel: '?', accessibleLabel: t('book.jumpRail.unknownDate') }
}
const granularity = props.granularity
if (!granularity) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
if (granularity.unit === 'year') {
const start = Number(bucket.key)
if (!Number.isFinite(start)) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
const startLabel = formatNumber(start, { useGrouping: false })
if (granularity.step === 1) return { label: startLabel, displayLabel: startLabel, accessibleLabel: startLabel }
const endLabel = formatNumber(start + granularity.step - 1, { useGrouping: false })
const range = `${startLabel}-${endLabel}`
return { label: range, displayLabel: startLabel, accessibleLabel: range }
}
const dateKey = granularity.unit === 'month' ? `${bucket.key}-01` : bucket.key
const date = new Date(`${dateKey}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
if (granularity.unit === 'month') {
const fullLabel = formatDate(date, { month: 'long', year: 'numeric', timeZone: 'UTC' })
return {
label: fullLabel,
displayLabel: bucket.key.endsWith('-01') ? formatDate(date, { year: 'numeric', timeZone: 'UTC' }) : '',
accessibleLabel: fullLabel,
}
}
const fullLabel = formatDate(date, { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
return {
label: fullLabel,
displayLabel: bucket.key.endsWith('-01-01')
? formatDate(date, { year: 'numeric', timeZone: 'UTC' })
: bucket.key.endsWith('-01')
? formatDate(date, { month: 'short', timeZone: 'UTC' })
: '',
accessibleLabel: fullLabel,
}
}
function shiftDateKey(key: string, unit: 'day' | 'month', amount: number): string | null {
const date = new Date(`${unit === 'month' ? `${key}-01` : key}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return null
if (unit === 'month') date.setUTCMonth(date.getUTCMonth() + amount)
else date.setUTCDate(date.getUTCDate() + amount)
const shifted = date.toISOString().slice(0, 10)
return unit === 'month' ? shifted.slice(0, 7) : shifted
}
function temporalDistance(start: string, end: string, granularity: TemporalJumpBucketGranularity): number | null {
if (granularity.unit === 'year') {
const startYear = Number(start)
const endYear = Number(end)
if (!Number.isFinite(startYear) || !Number.isFinite(endYear)) return null
return Math.abs((endYear - startYear) / granularity.step)
}
const startDate = new Date(`${granularity.unit === 'month' ? `${start}-01` : start}T00:00:00Z`)
const endDate = new Date(`${granularity.unit === 'month' ? `${end}-01` : end}T00:00:00Z`)
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null
if (granularity.unit === 'month') {
return Math.abs((endDate.getUTCFullYear() - startDate.getUTCFullYear()) * 12 + endDate.getUTCMonth() - startDate.getUTCMonth())
}
return Math.abs((endDate.getTime() - startDate.getTime()) / 86_400_000)
}
function shiftTemporalKey(key: string, granularity: TemporalJumpBucketGranularity, amount: number): string | null {
if (granularity.unit === 'year') {
const year = Number(key)
return Number.isFinite(year) ? String(year + amount * granularity.step) : null
}
return shiftDateKey(key, granularity.unit, amount)
}
function temporalTimelineKeys(buckets: JumpBucket[], granularity: TemporalJumpBucketGranularity | null | undefined, maxSlots: number): string[] {
const known = buckets.filter((bucket) => !bucket.isUnknown)
if (!granularity || known.length < 2) return known.map((bucket) => bucket.key)
const capacity = Math.max(known.length, Math.min(MAX_TEMPORAL_SLOTS, Math.max(2, maxSlots)))
const gaps = known.slice(0, -1).map((bucket, index) => {
const next = known[index + 1]!
const distance = temporalDistance(bucket.key, next.key, granularity)
return { start: bucket.key, end: next.key, distance: distance ?? 1, markers: 0 }
})
let remaining = capacity - known.length
while (remaining > 0) {
let selected: (typeof gaps)[number] | null = null
let selectedScore = 0
for (const gap of gaps) {
const missing = Math.max(0, Math.floor(gap.distance) - 1)
if (gap.markers >= missing) continue
const score = missing / (gap.markers + 1)
if (score > selectedScore) {
selected = gap
selectedScore = score
}
}
if (!selected) break
selected.markers += 1
remaining -= 1
}
const keys: string[] = []
for (let index = 0; index < known.length; index++) {
keys.push(known[index]!.key)
const gap = gaps[index]
if (!gap || gap.markers === 0) continue
const startValue = granularity.unit === 'year' ? Number(gap.start) : gap.start
const endValue = granularity.unit === 'year' ? Number(gap.end) : gap.end
const direction = startValue <= endValue ? 1 : -1
for (let marker = 1; marker <= gap.markers; marker++) {
const offset = Math.round((gap.distance * marker) / (gap.markers + 1)) * direction
const key = shiftTemporalKey(gap.start, granularity, offset)
if (key && key !== gap.start && key !== gap.end) keys.push(key)
}
}
return keys
}
const slots = computed<RailSlot[]>(() => {
void locale.value
if (props.kind === 'letter' && props.template) {
const byKey = new Map(props.buckets.map((bucket) => [bucket.key, bucket]))
return props.template.map((key) => ({ key, label: key, displayLabel: key, accessibleLabel: key, bucket: byKey.get(key) ?? null }))
}
const byKey = new Map(props.buckets.map((bucket) => [bucket.key, bucket]))
const unknown = props.buckets.find((bucket) => bucket.isUnknown)
const maxKnownSlots = Math.max(2, (props.maxSlots ?? MAX_TEMPORAL_SLOTS) - (unknown ? 1 : 0))
const timelineKeys = temporalTimelineKeys(props.buckets, props.granularity, maxKnownSlots)
if (unknown) timelineKeys.push(unknown.key)
const temporalSlots: RailSlot[] = timelineKeys.map((key) => {
const bucket = byKey.get(key) ?? null
const labels = temporalLabels(bucket ?? { key, label: key, index: -1 })
return { key, ...labels, displayLabel: bucket ? labels.displayLabel : '', bucket }
})
const displayedYears = new Set(temporalSlots.filter((slot) => slot.displayLabel).map((slot) => slot.key.slice(0, 4)))
for (const index of [0, temporalSlots.length - 1]) {
const slot = temporalSlots[index]
if (!slot || slot.displayLabel || slot.bucket?.isUnknown) continue
const yearKey = slot.key.slice(0, 4)
if (displayedYears.has(yearKey)) continue
const year = Number(slot.key.slice(0, 4))
slot.displayLabel = Number.isFinite(year) ? formatNumber(year, { useGrouping: false }) : slot.label
displayedYears.add(yearKey)
}
return temporalSlots
const slots = useJumpRailSlots({
buckets: () => props.buckets,
kind: () => props.kind,
field: () => props.field,
granularity: () => props.granularity,
template: () => props.template,
maxSlots: () => props.maxSlots,
})
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
@@ -335,7 +187,7 @@ function handleAfterLeave() {
ref="railEl"
class="jump-rail fixed right-1.5 top-1/2 z-30 flex max-h-[85vh] -translate-y-1/2 select-none flex-col items-stretch overflow-visible rounded-2xl border border-primary/20 bg-background/80 py-2 shadow-sm backdrop-blur"
:class="[
kind === 'temporal' ? 'w-14 px-1' : engaged ? 'w-9 px-1.5' : 'w-8 px-1',
kind === 'category' ? 'w-20 px-1' : kind === 'temporal' ? 'w-12 px-0.5' : engaged ? 'w-8 px-1' : 'w-7 px-0.5',
reducedMotion ? '' : 'transition-[width,padding] duration-300 ease-out',
]"
:style="railPositionStyle"
@@ -356,7 +208,7 @@ function handleAfterLeave() {
/>
<div
v-if="kind === 'temporal' && previewSlot"
v-if="kind !== 'letter' && previewSlot"
class="pointer-events-none absolute right-full top-0 z-20 mr-2 flex h-7 -translate-y-1 items-center whitespace-nowrap rounded-md border border-border bg-popover px-2.5 text-xs font-medium tabular-nums text-popover-foreground shadow-md"
:style="{ transform: `translateY(${hoverThumbY - 4}px)` }"
aria-hidden="true"
@@ -383,8 +235,11 @@ function handleAfterLeave() {
>
<span
v-if="slot.displayLabel"
class="rounded-full px-1.5 leading-4"
:class="slot.key === activeKey ? 'bg-primary font-semibold text-primary-foreground shadow-sm' : ''"
class="rounded-full leading-4"
:class="[
kind === 'category' ? 'max-w-full truncate px-1.5' : 'px-1',
slot.key === activeKey ? 'bg-primary font-semibold text-primary-foreground shadow-sm' : '',
]"
>
{{ slot.displayLabel }}
</span>
@@ -290,12 +290,28 @@ describe('VirtualBookGrid', () => {
requestAnimationFrameSpy.mockRestore()
})
it('reserves a right gutter when railGutter is set', () => {
it('reserves a compact right gutter for the alphabet rail', () => {
const wrapper = mount(VirtualBookGrid, {
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, railGutter: true },
})
expect(wrapper.classes()).toContain('pr-16')
expect(wrapper.classes()).toContain('pr-10')
})
it('reserves a wider right gutter for the temporal rail', () => {
const wrapper = mount(VirtualBookGrid, {
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, railGutter: true, railGutterKind: 'temporal' },
})
expect(wrapper.classes()).toContain('pr-14')
})
it('reserves the widest right gutter for categorical labels', () => {
const wrapper = mount(VirtualBookGrid, {
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, railGutter: true, railGutterKind: 'category' },
})
expect(wrapper.classes()).toContain('pr-22')
})
})
@@ -2,7 +2,7 @@
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useElementSize, useWindowSize, watchDebounced } from '@vueuse/core'
import { RecycleScroller } from 'vue-virtual-scroller'
import { isAudioFormat, type BookCard, type CoverAspectRatio } from '@bookorbit/types'
import { isAudioFormat, type BookCard, type CoverAspectRatio, type JumpBucketKind } from '@bookorbit/types'
import BookCoverCard from './BookCoverCard.vue'
import BookCoverSkeleton from './BookCoverSkeleton.vue'
import CollapsedSeriesCard from './CollapsedSeriesCard.vue'
@@ -23,6 +23,7 @@ const props = withDefaults(
virtualized?: boolean
audioCoverScale?: number
railGutter?: boolean
railGutterKind?: JumpBucketKind | null
}>(),
{
selectionMode: false,
@@ -31,6 +32,7 @@ const props = withDefaults(
virtualized: true,
audioCoverScale: 1,
railGutter: false,
railGutterKind: null,
},
)
@@ -261,7 +263,11 @@ defineExpose({ scrollToIndex })
</script>
<template>
<div ref="containerRef" class="w-full" :class="{ 'pr-16': railGutter }">
<div
ref="containerRef"
class="w-full"
:class="railGutter ? (railGutterKind === 'category' ? 'pr-22' : railGutterKind === 'temporal' ? 'pr-14' : 'pr-10') : ''"
>
<div
v-if="!virtualized && useVariableStaticWidths"
class="flex w-full flex-wrap content-start items-end"
@@ -25,6 +25,7 @@ function mountRail(props: Partial<InstanceType<typeof JumpRail>['$props']> = {})
visible: true,
buckets: LETTER_BUCKETS,
kind: 'letter' as const,
field: 'title' as const,
activeKey: null,
template: LETTER_TEMPLATE,
...props,
@@ -80,6 +81,23 @@ describe('JumpRail', () => {
expect(available.map((slot) => slot.text())).toEqual(['#', 'A', 'M'])
})
it('does not truncate single-character letter labels into visible ellipses', () => {
const wrapper = mountRail()
const label = wrapper.get('button[data-key="A"] span')
expect(label.classes()).not.toContain('truncate')
})
it('uses compact widths appropriate to each rail kind', () => {
const letter = mountRail()
const temporal = mountRail({ kind: 'temporal', field: 'publishedYear', buckets: yearBuckets(3), template: undefined })
const category = mountRail({ kind: 'category', field: 'format', buckets: [{ key: 'epub', label: 'epub', index: 0 }], template: undefined })
expect(letter.get('[data-testid="jump-rail"]').classes()).toContain('w-7')
expect(temporal.get('[data-testid="jump-rail"]').classes()).toContain('w-12')
expect(category.get('[data-testid="jump-rail"]').classes()).toContain('w-20')
})
it('renders letters in template order, supporting descending templates', () => {
const wrapper = mountRail({ template: [...LETTER_TEMPLATE].reverse() })
@@ -88,6 +106,16 @@ describe('JumpRail', () => {
expect(labels[26]).toBe('#')
})
it('preserves Unicode initials returned by the server alongside the alphabet template', () => {
const wrapper = mountRail({
buckets: [...LETTER_BUCKETS, { key: 'Д', label: 'Д', index: 35 }],
})
const keys = wrapper.findAll('button').map((button) => button.attributes('data-key'))
expect(keys).toContain('Д')
expect(keys.indexOf('Д')).toBeGreaterThan(keys.indexOf('M'))
})
it('emits jump with the bucket when an available slot is clicked', async () => {
const wrapper = mountRail()
@@ -195,6 +223,53 @@ describe('JumpRail', () => {
expect(wrapper.get('[data-testid="jump-rail-preview"]').text()).toBe('February 2026')
})
it('renders and localizes categorical format and unknown targets', () => {
const wrapper = mountRail({
kind: 'category' as const,
field: 'format' as const,
buckets: [
{ key: 'epub', label: 'epub', index: 0 },
{ key: '__unknown__', label: '__unknown__', index: 20, isUnknown: true },
],
template: undefined,
})
expect(wrapper.get('button[data-key="epub"]').text()).toBe('EPUB')
expect(wrapper.get('button[data-key="epub"] span').classes()).toContain('truncate')
expect(wrapper.get('button[data-key="__unknown__"]').text()).toBe('?')
expect(wrapper.get('button[data-key="__unknown__"]').attributes('aria-label')).toContain('Unknown value')
})
it('renders localized read-status category labels', () => {
const wrapper = mountRail({
kind: 'category' as const,
field: 'readStatus' as const,
buckets: [
{ key: 'want_to_read', label: 'want_to_read', index: 0 },
{ key: 'reading', label: 'reading', index: 10 },
],
template: undefined,
})
expect(wrapper.get('button[data-key="want_to_read"]').text()).toBe('Want to Read')
expect(wrapper.get('button[data-key="reading"]').text()).toBe('Reading')
})
it('shows a hover preview and emits jumps for categorical targets', async () => {
const buckets = [
{ key: 'en', label: 'en', index: 0 },
{ key: 'de', label: 'de', index: 20 },
]
const wrapper = mountRail({ kind: 'category' as const, field: 'language' as const, buckets, template: undefined })
preparePointerRail(wrapper, 40)
await firePointer(wrapper, 'pointermove', { clientY: 30, pointerType: 'mouse' })
expect(wrapper.get('[data-testid="jump-rail-preview"]').text()).toBe('German')
await wrapper.get('button[data-key="de"]').trigger('click')
expect(wrapper.emitted('jump')).toEqual([[buckets[1]]])
})
it('renders nothing when not visible', () => {
const wrapper = mountRail({ visible: false })
@@ -304,12 +379,12 @@ describe('JumpRail', () => {
const wrapper = mountRail({ activeKey: null })
await wrapper.setProps({ activeKey: 'A' })
expect(wrapper.get('[data-testid="jump-rail"]').classes()).toContain('px-1.5')
expect(wrapper.get('[data-testid="jump-rail"]').classes()).toContain('px-1')
vi.advanceTimersByTime(900)
await wrapper.vm.$nextTick()
expect(wrapper.get('[data-testid="jump-rail"]').classes()).toContain('px-1')
expect(wrapper.get('[data-testid="jump-rail"]').classes()).toContain('px-0.5')
})
it('clears a pending pulse timer on unmount', async () => {
@@ -225,6 +225,20 @@ describe('useBookViewWindow', () => {
expect(win.bucketKind.value).not.toBeNull()
})
it('exposes categorical rail eligibility and the primary sort field', async () => {
mockApi(120, [bucket('en', 0), bucket('de', 60)])
const { win } = setup({ viewMode: 'grid' })
await flush()
win.sort.value = [{ field: 'language', dir: 'asc' }]
await flush()
await flush()
expect(win.bucketKind.value).toBe('category')
expect(win.primarySortField.value).toBe('language')
expect(win.railVisible.value).toBe(true)
})
it('keeps the jump rail hidden in table mode', async () => {
mockApi(250, [bucket('A', 0), bucket('B', 120)])
const { win } = setup({ viewMode: 'table' })
@@ -91,6 +91,15 @@ describe('useJumpBuckets', () => {
expect(body.maxBuckets).toBe(24)
})
it('fetches categorical buckets for supported discrete sorts', async () => {
mockBucketsResponse([{ key: 'epub', label: 'epub', index: 0 }])
const { kind, buckets } = makeBuckets({ sort: [{ field: 'format', dir: 'asc' }] })
await flush()
expect(kind.value).toBe('category')
expect(buckets.value[0]?.key).toBe('epub')
})
it('does not fetch while disabled and fetches once enabled', async () => {
mockBucketsResponse(LETTER_BUCKETS)
const { buckets, enabled } = makeBuckets({ enabledValue: false })
@@ -66,6 +66,7 @@ export function useBookViewWindow(options: {
}
const bucketKind = computed(() => jumpBucketKindForSort(sort.value))
const primarySortField = computed(() => sort.value[0]?.field ?? 'title')
const railModeActive = computed(() => options.viewMode.value === 'grid')
const railEligible = computed(() => bucketKind.value !== null && railModeActive.value && window.total.value >= MIN_TOTAL_FOR_RAIL)
@@ -124,6 +125,7 @@ export function useBookViewWindow(options: {
hasMorePrefix,
loadMorePrefix,
bucketKind,
primarySortField,
buckets: bucketsApi.buckets,
temporalGranularity: bucketsApi.granularity,
railCapacity,
@@ -0,0 +1,235 @@
import { computed, type ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { JumpBucket, JumpBucketKind, SortField, TemporalJumpBucketGranularity } from '@bookorbit/types'
import { formatDate, formatLanguageName, formatNumber } from '@/i18n/formatters'
const MAX_TEMPORAL_SLOTS = 64
export type JumpRailSlot = {
key: string
label: string
displayLabel: string
accessibleLabel: string
bucket: JumpBucket | null
}
type JumpRailSlotOptions = {
buckets: () => JumpBucket[]
kind: () => JumpBucketKind
field: () => SortField
granularity: () => TemporalJumpBucketGranularity | null | undefined
template: () => string[] | undefined
maxSlots: () => number | undefined
}
const READ_STATUS_MESSAGE_KEYS: Record<string, string> = {
unread: 'book.readStatus.unread',
want_to_read: 'book.readStatus.wantToRead',
reading: 'book.readStatus.reading',
on_hold: 'book.readStatus.onHold',
rereading: 'book.readStatus.rereading',
read: 'book.readStatus.read',
skimmed: 'book.readStatus.skimmed',
abandoned: 'book.readStatus.abandoned',
}
function mergeLetterTemplate(template: string[], buckets: JumpBucket[]): string[] {
const templateIndexes = new Map(template.map((key, index) => [key, index]))
const keys: string[] = []
let templateIndex = 0
for (const bucket of buckets) {
const index = templateIndexes.get(bucket.key)
if (index === undefined || index < templateIndex) {
if (!keys.includes(bucket.key)) keys.push(bucket.key)
continue
}
while (templateIndex <= index) keys.push(template[templateIndex++]!)
}
while (templateIndex < template.length) keys.push(template[templateIndex++]!)
return keys
}
function shiftDateKey(key: string, unit: 'day' | 'month', amount: number): string | null {
const date = new Date(`${unit === 'month' ? `${key}-01` : key}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return null
if (unit === 'month') date.setUTCMonth(date.getUTCMonth() + amount)
else date.setUTCDate(date.getUTCDate() + amount)
const shifted = date.toISOString().slice(0, 10)
return unit === 'month' ? shifted.slice(0, 7) : shifted
}
function temporalDistance(start: string, end: string, granularity: TemporalJumpBucketGranularity): number | null {
if (granularity.unit === 'year') {
const startYear = Number(start)
const endYear = Number(end)
if (!Number.isFinite(startYear) || !Number.isFinite(endYear)) return null
return Math.abs((endYear - startYear) / granularity.step)
}
const startDate = new Date(`${granularity.unit === 'month' ? `${start}-01` : start}T00:00:00Z`)
const endDate = new Date(`${granularity.unit === 'month' ? `${end}-01` : end}T00:00:00Z`)
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null
if (granularity.unit === 'month') {
return Math.abs((endDate.getUTCFullYear() - startDate.getUTCFullYear()) * 12 + endDate.getUTCMonth() - startDate.getUTCMonth())
}
return Math.abs((endDate.getTime() - startDate.getTime()) / 86_400_000)
}
function shiftTemporalKey(key: string, granularity: TemporalJumpBucketGranularity, amount: number): string | null {
if (granularity.unit === 'year') {
const year = Number(key)
return Number.isFinite(year) ? String(year + amount * granularity.step) : null
}
return shiftDateKey(key, granularity.unit, amount)
}
function temporalTimelineKeys(buckets: JumpBucket[], granularity: TemporalJumpBucketGranularity | null | undefined, maxSlots: number): string[] {
const known = buckets.filter((bucket) => !bucket.isUnknown)
if (!granularity || known.length < 2) return known.map((bucket) => bucket.key)
const capacity = Math.max(known.length, Math.min(MAX_TEMPORAL_SLOTS, Math.max(2, maxSlots)))
const gaps = known.slice(0, -1).map((bucket, index) => {
const next = known[index + 1]!
const distance = temporalDistance(bucket.key, next.key, granularity)
return { start: bucket.key, end: next.key, distance: distance ?? 1, markers: 0 }
})
let remaining = capacity - known.length
while (remaining > 0) {
let selected: (typeof gaps)[number] | null = null
let selectedScore = 0
for (const gap of gaps) {
const missing = Math.max(0, Math.floor(gap.distance) - 1)
if (gap.markers >= missing) continue
const score = missing / (gap.markers + 1)
if (score > selectedScore) {
selected = gap
selectedScore = score
}
}
if (!selected) break
selected.markers += 1
remaining -= 1
}
const keys: string[] = []
for (let index = 0; index < known.length; index++) {
keys.push(known[index]!.key)
const gap = gaps[index]
if (!gap || gap.markers === 0) continue
const startValue = granularity.unit === 'year' ? Number(gap.start) : gap.start
const endValue = granularity.unit === 'year' ? Number(gap.end) : gap.end
const direction = startValue <= endValue ? 1 : -1
for (let marker = 1; marker <= gap.markers; marker++) {
const offset = Math.round((gap.distance * marker) / (gap.markers + 1)) * direction
const key = shiftTemporalKey(gap.start, granularity, offset)
if (key && key !== gap.start && key !== gap.end) keys.push(key)
}
}
return keys
}
export function useJumpRailSlots(options: JumpRailSlotOptions): ComputedRef<JumpRailSlot[]> {
const { t, locale } = useI18n()
function temporalLabels(bucket: JumpBucket): Pick<JumpRailSlot, 'label' | 'displayLabel' | 'accessibleLabel'> {
if (bucket.isUnknown) {
const label = t('book.jumpRail.unknownDate')
return { label, displayLabel: '?', accessibleLabel: label }
}
const granularity = options.granularity()
if (!granularity) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
if (granularity.unit === 'year') {
const start = Number(bucket.key)
if (!Number.isFinite(start)) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
const startLabel = formatNumber(start, { useGrouping: false })
if (granularity.step === 1) return { label: startLabel, displayLabel: startLabel, accessibleLabel: startLabel }
const endLabel = formatNumber(start + granularity.step - 1, { useGrouping: false })
const range = `${startLabel}-${endLabel}`
return { label: range, displayLabel: startLabel, accessibleLabel: range }
}
const dateKey = granularity.unit === 'month' ? `${bucket.key}-01` : bucket.key
const date = new Date(`${dateKey}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
if (granularity.unit === 'month') {
const fullLabel = formatDate(date, { month: 'long', year: 'numeric', timeZone: 'UTC' })
return {
label: fullLabel,
displayLabel: bucket.key.endsWith('-01') ? formatDate(date, { year: 'numeric', timeZone: 'UTC' }) : '',
accessibleLabel: fullLabel,
}
}
const fullLabel = formatDate(date, { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
return {
label: fullLabel,
displayLabel: bucket.key.endsWith('-01-01')
? formatDate(date, { year: 'numeric', timeZone: 'UTC' })
: bucket.key.endsWith('-01')
? formatDate(date, { month: 'short', timeZone: 'UTC' })
: '',
accessibleLabel: fullLabel,
}
}
function categoryLabels(bucket: JumpBucket): Pick<JumpRailSlot, 'label' | 'displayLabel' | 'accessibleLabel'> {
if (bucket.isUnknown) {
const label = t('book.jumpRail.unknownValue')
return { label, displayLabel: '?', accessibleLabel: label }
}
let label = bucket.label
switch (options.field()) {
case 'format':
label = bucket.key.toUpperCase()
break
case 'language':
label = formatLanguageName(bucket.key)
break
case 'readStatus': {
const messageKey = READ_STATUS_MESSAGE_KEYS[bucket.key]
label = messageKey ? t(messageKey) : bucket.label
break
}
}
return { label, displayLabel: label, accessibleLabel: label }
}
return computed(() => {
void locale.value
const buckets = options.buckets()
const kind = options.kind()
const byKey = new Map(buckets.map((bucket) => [bucket.key, bucket]))
if (kind === 'letter') {
const template = options.template() ?? []
const keys = mergeLetterTemplate(template, buckets)
return keys.map((key) => ({ key, label: key, displayLabel: key, accessibleLabel: key, bucket: byKey.get(key) ?? null }))
}
if (kind === 'category') {
return buckets.map((bucket) => ({ key: bucket.key, ...categoryLabels(bucket), bucket }))
}
const unknown = buckets.find((bucket) => bucket.isUnknown)
const maxKnownSlots = Math.max(2, (options.maxSlots() ?? MAX_TEMPORAL_SLOTS) - (unknown ? 1 : 0))
const timelineKeys = temporalTimelineKeys(buckets, options.granularity(), maxKnownSlots)
if (unknown) timelineKeys.push(unknown.key)
const temporalSlots: JumpRailSlot[] = timelineKeys.map((key) => {
const bucket = byKey.get(key) ?? null
const labels = temporalLabels(bucket ?? { key, label: key, index: -1 })
return { key, ...labels, displayLabel: bucket ? labels.displayLabel : '', bucket }
})
const displayedYears = new Set(temporalSlots.filter((slot) => slot.displayLabel).map((slot) => slot.key.slice(0, 4)))
for (const index of [0, temporalSlots.length - 1]) {
const slot = temporalSlots[index]
if (!slot || slot.displayLabel || slot.bucket?.isUnknown) continue
const yearKey = slot.key.slice(0, 4)
if (displayedYears.has(yearKey)) continue
const year = Number(slot.key.slice(0, 4))
slot.displayLabel = Number.isFinite(year) ? formatNumber(year, { useGrouping: false }) : slot.label
displayedYears.add(yearKey)
}
return temporalSlots
})
}
+7 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setI18nLocale } from '@/i18n'
import { formatBytes } from '@/lib/formatting'
import { formatDate, formatNumber } from './formatters'
import { formatDate, formatLanguageName, formatNumber } from './formatters'
describe('locale formatters', () => {
afterEach(async () => {
@@ -26,6 +26,12 @@ describe('locale formatters', () => {
expect(formatDate(date, { month: 'long', timeZone: 'UTC' })).toBe('januari')
})
it('formats language codes and safely preserves custom language values', async () => {
await setI18nLocale('en')
expect(formatLanguageName('de')).toBe('German')
expect(formatLanguageName('Custom language')).toBe('Custom language')
})
it('formats file sizes with localized decimals', async () => {
await setI18nLocale('en')
expect(formatBytes(1536)).toBe('1.5 KB')
+15
View File
@@ -5,6 +5,7 @@ import { i18n } from '@/i18n'
const numberFormatters = new Map<string, Intl.NumberFormat>()
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>()
const languageNameFormatters = new Map<Locale, Intl.DisplayNames>()
function activeLocale(): Locale {
return (i18n.global.locale as Ref<Locale>).value
@@ -61,3 +62,17 @@ export function formatRelativeTime(
}
return formatter.format(value, unit)
}
export function formatLanguageName(value: string): string {
const locale = activeLocale()
let formatter = languageNameFormatters.get(locale)
if (!formatter) {
formatter = new Intl.DisplayNames([locale], { type: 'language', fallback: 'none' })
languageNameFormatters.set(locale, formatter)
}
try {
return formatter.of(value) ?? value
} catch {
return value
}
}
+2 -1
View File
@@ -3065,7 +3065,8 @@
"jumpRail": {
"jumpToSection": "Zu Abschnitt springen",
"jumpTo": "Zu {label} springen",
"unknownDate": "Unknown date"
"unknownDate": "Unknown date",
"unknownValue": "Unknown value"
},
"quickView": {
"title": "Schnellansicht des Buchs",
+2 -1
View File
@@ -3023,7 +3023,8 @@
"jumpRail": {
"jumpToSection": "Jump to section",
"jumpTo": "Jump to {label}",
"unknownDate": "Unknown date"
"unknownDate": "Unknown date",
"unknownValue": "Unknown value"
},
"quickView": {
"title": "Book quick view",
+2 -1
View File
@@ -3065,7 +3065,8 @@
"jumpRail": {
"jumpToSection": "Naar sectie springen",
"jumpTo": "Naar {label} springen",
"unknownDate": "Unknown date"
"unknownDate": "Unknown date",
"unknownValue": "Unknown value"
},
"quickView": {
"title": "Snelweergave boek",
+2 -1
View File
@@ -3065,7 +3065,8 @@
"jumpRail": {
"jumpToSection": "Pular para a seção",
"jumpTo": "Pular para {label}",
"unknownDate": "Unknown date"
"unknownDate": "Unknown date",
"unknownValue": "Unknown value"
},
"quickView": {
"title": "Visualização rápida do livro",
+2 -1
View File
@@ -3065,7 +3065,8 @@
"jumpRail": {
"jumpToSection": "Skoči na razdelek",
"jumpTo": "Skoči na {label}",
"unknownDate": "Unknown date"
"unknownDate": "Unknown date",
"unknownValue": "Unknown value"
},
"quickView": {
"title": "Hitri pregled knjige",
+3
View File
@@ -95,6 +95,7 @@ const {
handleJump,
buckets,
bucketKind,
primarySortField,
temporalGranularity,
railCapacity,
refreshBuckets,
@@ -614,6 +615,7 @@ defineOptions({ name: 'CollectionView' })
:selection-mode="selectionMode"
:is-selected="isSelected"
:rail-gutter="railGutterReserved"
:rail-gutter-kind="bucketKind"
@range="handleRange"
@first-visible-index="handleFirstVisibleIndex"
@action="handleBookAction"
@@ -667,6 +669,7 @@ defineOptions({ name: 'CollectionView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:field="primarySortField"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
+3
View File
@@ -124,6 +124,7 @@ const {
handleJump,
buckets,
bucketKind,
primarySortField,
temporalGranularity,
railCapacity,
refreshBuckets,
@@ -911,6 +912,7 @@ defineOptions({ name: 'HomeView' })
:is-selected="isSelected"
:new-book-ids="newBookIds"
:rail-gutter="railGutterReserved"
:rail-gutter-kind="bucketKind"
@range="handleRange"
@first-visible-index="handleFirstVisibleIndex"
@action="handleBookAction"
@@ -969,6 +971,7 @@ defineOptions({ name: 'HomeView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:field="primarySortField"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
+3
View File
@@ -104,6 +104,7 @@ const {
handleJump,
buckets,
bucketKind,
primarySortField,
temporalGranularity,
railCapacity,
refreshBuckets,
@@ -748,6 +749,7 @@ defineOptions({ name: 'SmartScopeView' })
:selection-mode="selectionMode"
:is-selected="isSelected"
:rail-gutter="railGutterReserved"
:rail-gutter-kind="bucketKind"
@range="handleRange"
@first-visible-index="handleFirstVisibleIndex"
@action="handleBookAction"
@@ -802,6 +804,7 @@ defineOptions({ name: 'SmartScopeView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:field="primarySortField"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { jumpBucketKindForSort, temporalJumpBucketPrecisionForSort } from "../jump-buckets";
import { jumpBucketKindForSort, jumpRailStrategyForSort, temporalJumpBucketPrecisionForSort } from "../jump-buckets";
import type { SortSpec } from "../query";
describe("jumpBucketKindForSort", () => {
@@ -14,6 +14,20 @@ describe("jumpBucketKindForSort", () => {
expect(jumpBucketKindForSort([{ field: "author", dir: "desc" }])).toBe("letter");
});
it("returns letter for series and publisher sorts", () => {
for (const field of ["series", "publisher"] as const) {
expect(jumpBucketKindForSort([{ field, dir: "asc" }])).toBe("letter");
expect(jumpBucketKindForSort([{ field, dir: "desc" }])).toBe("letter");
}
});
it("returns category for language, format, and read status sorts", () => {
for (const field of ["language", "format", "readStatus"] as const) {
expect(jumpBucketKindForSort([{ field, dir: "asc" }])).toBe("category");
expect(jumpBucketKindForSort([{ field, dir: "desc" }])).toBe("category");
}
});
it("returns temporal for publishedYear sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "asc" }])).toBe("temporal");
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "desc" }])).toBe("temporal");
@@ -53,6 +67,18 @@ describe("jumpBucketKindForSort", () => {
});
});
describe("jumpRailStrategyForSort", () => {
it("returns the temporal precision as part of the strategy", () => {
expect(jumpRailStrategyForSort([{ field: "publishedDate", dir: "asc" }])).toEqual({ kind: "temporal", precision: "year" });
expect(jumpRailStrategyForSort([{ field: "addedAt", dir: "asc" }])).toEqual({ kind: "temporal", precision: "date" });
});
it("returns strategy-only descriptors for discrete rails", () => {
expect(jumpRailStrategyForSort([{ field: "publisher", dir: "asc" }])).toEqual({ kind: "letter" });
expect(jumpRailStrategyForSort([{ field: "format", dir: "asc" }])).toEqual({ kind: "category" });
});
});
describe("temporalJumpBucketPrecisionForSort", () => {
it("keeps publication sorts at year precision", () => {
expect(temporalJumpBucketPrecisionForSort([{ field: "publishedDate", dir: "asc" }])).toBe("year");
+26 -25
View File
@@ -1,6 +1,6 @@
import type { BookQuery, SortField, SortSpec } from "./query";
export type JumpBucketKind = "letter" | "temporal";
export type JumpBucketKind = "letter" | "temporal" | "category";
export type TemporalJumpBucketUnit = "day" | "month" | "year";
@@ -33,30 +33,32 @@ export type JumpBucketsQuery = BookQuery & {
maxBuckets: number;
};
const KIND_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpBucketKind>> = {
title: "letter",
author: "letter",
addedAt: "temporal",
updatedAt: "temporal",
publishedDate: "temporal",
publishedYear: "temporal",
lastReadAt: "temporal",
startedAt: "temporal",
finishedAt: "temporal",
};
export type TemporalJumpBucketPrecision = "date" | "year";
const TEMPORAL_PRECISION_BY_SORT_FIELD: Partial<Record<SortField, TemporalJumpBucketPrecision>> = {
addedAt: "date",
updatedAt: "date",
publishedDate: "year",
publishedYear: "year",
lastReadAt: "date",
startedAt: "date",
finishedAt: "date",
export type JumpRailStrategy = { kind: "letter" } | { kind: "temporal"; precision: TemporalJumpBucketPrecision } | { kind: "category" };
const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpRailStrategy>> = {
title: { kind: "letter" },
author: { kind: "letter" },
series: { kind: "letter" },
publisher: { kind: "letter" },
addedAt: { kind: "temporal", precision: "date" },
updatedAt: { kind: "temporal", precision: "date" },
publishedDate: { kind: "temporal", precision: "year" },
publishedYear: { kind: "temporal", precision: "year" },
lastReadAt: { kind: "temporal", precision: "date" },
startedAt: { kind: "temporal", precision: "date" },
finishedAt: { kind: "temporal", precision: "date" },
language: { kind: "category" },
format: { kind: "category" },
readStatus: { kind: "category" },
};
export function jumpRailStrategyForSort(sort: SortSpec[]): JumpRailStrategy | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return STRATEGY_BY_PRIMARY_SORT_FIELD[primary.field] ?? null;
}
/**
* Single source of truth for rail eligibility, shared by the client (gate the
* rail and bucket fetches) and the server (validate + pick bucket expression).
@@ -65,11 +67,10 @@ const TEMPORAL_PRECISION_BY_SORT_FIELD: Partial<Record<SortField, TemporalJumpBu
* title ascending, mirroring the server's default.
*/
export function jumpBucketKindForSort(sort: SortSpec[]): JumpBucketKind | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return KIND_BY_PRIMARY_SORT_FIELD[primary.field] ?? null;
return jumpRailStrategyForSort(sort)?.kind ?? null;
}
export function temporalJumpBucketPrecisionForSort(sort: SortSpec[]): TemporalJumpBucketPrecision | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return TEMPORAL_PRECISION_BY_SORT_FIELD[primary.field] ?? null;
const strategy = jumpRailStrategyForSort(sort);
return strategy?.kind === "temporal" ? strategy.precision : null;
}
@@ -1606,9 +1606,15 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
it('generates readStatus sort using subquery', () => {
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'readStatus', dir: 'asc' }], 1);
expect(result).toContain('user_book_status');
expect(result).toContain('COALESCE');
expect(result).toContain("'unread'");
expect(result).toContain('ASC NULLS LAST');
});
it('generates language sort over the representative field', () => {
expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'language', dir: 'desc' }], 1)).toBe('r.language DESC NULLS LAST, r.id ASC');
});
it('generates format sort using subquery', () => {
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'format', dir: 'desc' }], 1);
expect(result).toContain('book_files');
@@ -962,6 +962,9 @@ export class BookQueryBuilder {
case 'publisher':
parts.push(`publisher ${D} NULLS LAST`);
break;
case 'language':
parts.push(`r.language ${D} NULLS LAST`);
break;
case 'pageCount':
parts.push(`page_count ${D} NULLS LAST`);
break;
@@ -998,7 +1001,9 @@ export class BookQueryBuilder {
break;
}
case 'readStatus':
parts.push(`(SELECT ubs.status FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${safeUserId}) ${D} NULLS LAST`);
parts.push(
`COALESCE((SELECT ubs.status FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${safeUserId}), 'unread') ${D} NULLS LAST`,
);
break;
case 'format':
parts.push(`(SELECT bf.format FROM book_files bf WHERE bf.id = r.primary_file_id) ${D} NULLS LAST`);
@@ -1,4 +1,5 @@
import { BookRepository } from './book.repository';
import { sql } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
function makeSelectChain<T>(terminalMethod: string, terminalResult: T) {
@@ -967,6 +968,93 @@ describe('BookRepository', () => {
});
});
describe('discrete jump buckets', () => {
const dialect = new PgDialect();
it('maps bounded categorical groups and unknown values', async () => {
const execute = vi.fn().mockResolvedValue({
rows: [
{ bucket: 'en', item_index: 0, total: 100_000, is_unknown: false },
{ bucket: '__unknown__', item_index: 90_000, total: 100_000, is_unknown: true },
],
});
const repo = new BookRepository({ execute } as never);
const result = await repo.findJumpBuckets({
where: undefined,
field: 'language',
kind: 'category',
userId: 7,
maxBuckets: 24,
orderBy: [sql`book_metadata.language ASC NULLS LAST`],
});
expect(result).toEqual({
buckets: [
{ key: 'en', label: 'en', index: 0 },
{ key: '__unknown__', label: '__unknown__', index: 90_000, isUnknown: true },
],
total: 100_000,
kind: 'category',
granularity: null,
});
const query = dialect.sqlToQuery(execute.mock.calls[0]![0]).sql;
expect(query).toContain('ordered AS MATERIALIZED');
expect(query).toContain('bucket_count');
expect(query).toContain('LIMIT');
});
it('joins primary formats and per-user statuses once instead of correlating each row', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
const repo = new BookRepository({ execute } as never);
await repo.findJumpBuckets({
where: undefined,
field: 'format',
kind: 'category',
userId: 9,
maxBuckets: 32,
orderBy: [sql`books.id ASC`],
});
const formatQuery = dialect.sqlToQuery(execute.mock.calls[0]![0]).sql;
expect(formatQuery).toContain('LEFT JOIN "book_files" rail_primary_file');
expect(formatQuery).not.toContain('SELECT bf.format');
await repo.findJumpBuckets({
where: undefined,
field: 'readStatus',
kind: 'category',
userId: 9,
maxBuckets: 32,
orderBy: [sql`books.id ASC`],
});
const statusQuery = dialect.sqlToQuery(execute.mock.calls[1]![0]).sql;
expect(statusQuery).toContain('LEFT JOIN "user_book_status" rail_ubs');
expect(statusQuery).toContain('rail_ubs.user_id =');
expect(statusQuery).not.toContain('SELECT ubs.status');
});
it('carries categorical values through the collapsed representative query', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
const repo = new BookRepository({ execute } as never);
await repo.findJumpBucketsCollapsed({
where: undefined,
field: 'readStatus',
kind: 'category',
sort: [{ field: 'readStatus', dir: 'asc' }],
userId: 9,
maxBuckets: 32,
});
const query = dialect.sqlToQuery(execute.mock.calls[0]![0]).sql;
expect(query).toContain('base_rows AS MATERIALIZED');
expect(query).toContain("coalesce(rail_ubs.status::text, 'unread') AS rail_discrete_value");
expect(query).toContain('base.rail_discrete_value');
expect(query).toContain('r.rail_discrete_value');
});
});
describe('bulkSetRating', () => {
it('does nothing for an empty book id list', async () => {
const db = { insert: vi.fn() };
+186 -27
View File
@@ -5,6 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type {
ContentFilterRules,
JumpBucketKind,
JumpBucketsResponse,
SortField,
SortSpec,
@@ -17,6 +18,7 @@ import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-se
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { letterJumpBucketExpr } from './jump-bucket-expr';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
@@ -146,6 +148,24 @@ type TemporalJumpBucketRawRow = JumpBucketRawRow & {
step: number | string;
};
type DiscreteJumpBucketRawRow = JumpBucketRawRow & {
is_unknown: boolean;
};
type FlatDiscreteSourceParts = {
prefixCte: SQL;
join: SQL;
value: SQL;
};
type CollapsedDiscreteSourceParts = {
prefixCte: SQL;
baseJoin: SQL;
baseExtraSelect: SQL;
representativeExtraSelect: SQL;
value: SQL;
};
type TemporalSourceParts = {
prefixCte: SQL;
join: SQL;
@@ -154,6 +174,116 @@ type TemporalSourceParts = {
representativeExtraSelect: SQL;
};
function flatDiscreteSourceParts(field: SortField, userId: number): FlatDiscreteSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): FlatDiscreteSourceParts => ({ prefixCte: empty, join: empty, value });
switch (field) {
case 'title':
return direct(sql`${bookMetadata.title}`);
case 'author':
return direct(sql`${books.primaryAuthorSortName}`);
case 'series':
return direct(sql`${bookMetadata.seriesName}`);
case 'publisher':
return direct(sql`${bookMetadata.publisher}`);
case 'language':
return direct(sql`${bookMetadata.language}`);
case 'format':
return {
prefixCte: empty,
join: sql`LEFT JOIN ${bookFiles} rail_primary_file ON rail_primary_file.id = ${books.primaryFileId}`,
value: sql.raw('rail_primary_file.format'),
};
case 'readStatus':
return {
prefixCte: empty,
join: sql`LEFT JOIN ${userBookStatus} rail_ubs ON rail_ubs.book_id = ${books.id} AND rail_ubs.user_id = ${userId}`,
value: sql`coalesce(rail_ubs.status::text, 'unread')`,
};
default:
return null;
}
}
function collapsedDiscreteSourceParts(field: SortField, userId: number): CollapsedDiscreteSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): CollapsedDiscreteSourceParts => ({
prefixCte: empty,
baseJoin: empty,
baseExtraSelect: empty,
representativeExtraSelect: empty,
value,
});
switch (field) {
case 'title':
case 'series':
return direct(sql.raw('r.sort_title'));
case 'author':
return direct(sql.raw('r.author_sort_name'));
case 'publisher':
return direct(sql.raw('r.publisher'));
case 'language':
return {
prefixCte: empty,
baseJoin: empty,
baseExtraSelect: sql`, ${bookMetadata.language} AS language`,
representativeExtraSelect: sql`, base.language`,
value: sql.raw('r.language'),
};
case 'format':
return {
prefixCte: empty,
baseJoin: sql`LEFT JOIN ${bookFiles} rail_primary_file ON rail_primary_file.id = ${books.primaryFileId}`,
baseExtraSelect: sql`, rail_primary_file.format AS rail_discrete_value`,
representativeExtraSelect: sql`, base.rail_discrete_value`,
value: sql.raw('r.rail_discrete_value'),
};
case 'readStatus':
return {
prefixCte: empty,
baseJoin: sql`LEFT JOIN ${userBookStatus} rail_ubs ON rail_ubs.book_id = ${books.id} AND rail_ubs.user_id = ${userId}`,
baseExtraSelect: sql`, coalesce(rail_ubs.status::text, 'unread') AS rail_discrete_value`,
representativeExtraSelect: sql`, base.rail_discrete_value`,
value: sql.raw('r.rail_discrete_value'),
};
default:
return null;
}
}
function discreteBucketsQuery(opts: { prefixCte: SQL; orderedRows: SQL; maxBuckets: number }): SQL {
return sql`
WITH ${opts.prefixCte}
ordered AS MATERIALIZED (${opts.orderedRows}),
grouped AS (
SELECT bucket, is_unknown, min(item_index)::int AS item_index, (SELECT count(*) FROM ordered)::int AS total
FROM ordered
WHERE bucket IS NOT NULL
GROUP BY bucket, is_unknown
),
ranked AS (
SELECT
grouped.*,
row_number() OVER (ORDER BY item_index)::int AS bucket_ordinal,
count(*) OVER ()::int AS bucket_count
FROM grouped
)
SELECT bucket, item_index, total, is_unknown
FROM ranked
WHERE bucket_count <= ${opts.maxBuckets}
OR bucket_ordinal = 1
OR bucket_ordinal = bucket_count
OR mod(
bucket_ordinal - 1,
GREATEST(ceil((bucket_count - 1)::numeric / GREATEST(${opts.maxBuckets} - 1, 1))::int, 1)
) = 0
ORDER BY item_index
LIMIT ${opts.maxBuckets}
`;
}
function temporalSourceParts(field: SortField, userId: number, timeZone: string, collapsed: boolean): TemporalSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): TemporalSourceParts => ({
@@ -888,25 +1018,37 @@ export class BookRepository {
return { rows: mappedRows, ...enrichment, total };
}
async findJumpBuckets(opts: { where: SQL | undefined; bucketExpr: SQL; orderBy: SQL[] }): Promise<JumpBucketsResponse> {
async findJumpBuckets(opts: {
where: SQL | undefined;
field: SortField;
kind: Exclude<JumpBucketKind, 'temporal'>;
userId: number;
maxBuckets: number;
orderBy: SQL[];
}): Promise<JumpBucketsResponse> {
const source = flatDiscreteSourceParts(opts.field, opts.userId);
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
const visibleWhere = this.visibleWhere(opts.where);
const result = await this.db.execute<JumpBucketRawRow>(sql`
WITH ordered AS (
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
const result = await this.db.execute<DiscreteJumpBucketRawRow>(
discreteBucketsQuery({
prefixCte: source.prefixCte,
orderedRows: sql`
SELECT
${opts.bucketExpr} AS bucket,
${bucketExpr} AS bucket,
${isUnknownExpr} AS is_unknown,
(ROW_NUMBER() OVER (ORDER BY ${sql.join(opts.orderBy, sql`, `)}) - 1) AS item_index
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
${source.join}
WHERE ${visibleWhere}
)
SELECT bucket, min(item_index)::int AS item_index, (SELECT count(*) FROM ordered)::int AS total
FROM ordered
WHERE bucket IS NOT NULL
GROUP BY bucket
ORDER BY min(item_index)
`);
`,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapJumpBucketRows(result.rows);
return this.mapDiscreteJumpBucketRows(result.rows, opts.kind);
}
async findTemporalJumpBuckets(opts: {
@@ -946,11 +1088,25 @@ export class BookRepository {
// selection (same group key, same pick order) but only projects the columns
// buildCollapseOrderBy can reference as aliases or via r.* correlated
// subqueries.
async findJumpBucketsCollapsed(opts: { where: SQL | undefined; bucketExpr: SQL; sort: SortSpec[]; userId: number }): Promise<JumpBucketsResponse> {
async findJumpBucketsCollapsed(opts: {
where: SQL | undefined;
field: SortField;
kind: Exclude<JumpBucketKind, 'temporal'>;
sort: SortSpec[];
userId: number;
maxBuckets: number;
}): Promise<JumpBucketsResponse> {
const source = collapsedDiscreteSourceParts(opts.field, opts.userId);
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
const whereFragment = this.visibleWhere(opts.where);
const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId);
const result = await this.db.execute<JumpBucketRawRow>(sql`
WITH base_rows AS (
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
const result = await this.db.execute<DiscreteJumpBucketRawRow>(
discreteBucketsQuery({
prefixCte: sql`
${source.prefixCte}
base_rows AS MATERIALIZED (
SELECT
books.id,
books.library_id,
@@ -967,8 +1123,10 @@ export class BookRepository {
book_metadata.publisher,
book_metadata.page_count,
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
${source.baseExtraSelect}
FROM books
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
${source.baseJoin}
WHERE ${whereFragment}
),
series_latest AS (
@@ -994,6 +1152,7 @@ export class BookRepository {
base.primary_author_sort_name AS author_sort_name,
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
COALESCE(sl.latest_added_at, base.added_at) AS sort_added_at
${source.representativeExtraSelect}
FROM base_rows base
LEFT JOIN user_book_ratings ubr ON ubr.book_id = base.id AND ubr.user_id = ${opts.userId}
LEFT JOIN series_latest sl
@@ -1001,20 +1160,19 @@ export class BookRepository {
AND sl.library_id = base.library_id
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
),
ordered AS (
`,
orderedRows: sql`
SELECT
${opts.bucketExpr} AS bucket,
${bucketExpr} AS bucket,
${isUnknownExpr} AS is_unknown,
(ROW_NUMBER() OVER (ORDER BY ${sql.raw(orderBy)}) - 1) AS item_index
FROM representatives r
)
SELECT bucket, min(item_index)::int AS item_index, (SELECT count(*) FROM ordered)::int AS total
FROM ordered
WHERE bucket IS NOT NULL
GROUP BY bucket
ORDER BY min(item_index)
`);
`,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapJumpBucketRows(result.rows);
return this.mapDiscreteJumpBucketRows(result.rows, opts.kind);
}
async findTemporalJumpBucketsCollapsed(opts: {
@@ -1089,15 +1247,16 @@ export class BookRepository {
return this.mapTemporalJumpBucketRows(result.rows);
}
private mapJumpBucketRows(rows: JumpBucketRawRow[]): JumpBucketsResponse {
private mapDiscreteJumpBucketRows(rows: DiscreteJumpBucketRawRow[], kind: Exclude<JumpBucketKind, 'temporal'>): JumpBucketsResponse {
return {
buckets: rows.map((row) => ({
key: row.bucket,
label: row.bucket,
index: Number(row.item_index),
...(row.is_unknown ? { isUnknown: true } : {}),
})),
total: rows.length > 0 ? Number(rows[0].total) : 0,
kind: 'letter',
kind,
granularity: null,
};
}
+21 -4
View File
@@ -2620,7 +2620,9 @@ describe('BookService', () => {
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith([{ field: 'title', dir: 'asc' }], 42);
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ where: 'WHERE', orderBy: ['ORDER'] }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(
expect.objectContaining({ where: 'WHERE', field: 'title', kind: 'letter', userId: 42, maxBuckets: 24, orderBy: ['ORDER'] }),
);
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
expect(result).toEqual({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 12, kind: 'letter', granularity: null });
});
@@ -2639,7 +2641,7 @@ describe('BookService', () => {
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery routes letter and temporal sorts to their bounded query paths', async () => {
it('executeJumpBucketsQuery routes letter, category, and temporal sorts to their bounded query paths', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
@@ -2650,10 +2652,25 @@ describe('BookService', () => {
granularity: null,
});
for (const sort of [[{ field: 'author', dir: 'desc' }], [{ field: 'publishedYear', dir: 'asc' }], [{ field: 'title', dir: 'desc' }], []]) {
for (const sort of [
[{ field: 'author', dir: 'desc' }],
[{ field: 'series', dir: 'asc' }],
[{ field: 'publisher', dir: 'desc' }],
[{ field: 'format', dir: 'asc' }],
[{ field: 'language', dir: 'desc' }],
[{ field: 'readStatus', dir: 'asc' }],
[{ field: 'publishedYear', dir: 'asc' }],
[{ field: 'title', dir: 'desc' }],
[],
]) {
await service.executeJumpBucketsQuery(1, undefined, { sort, pagination: { page: 0, size: 50 }, maxBuckets: 24 } as never);
}
expect(bookRepo.findJumpBuckets).toHaveBeenCalledTimes(3);
expect(bookRepo.findJumpBuckets).toHaveBeenCalledTimes(8);
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'series', kind: 'letter', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'publisher', kind: 'letter', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'format', kind: 'category', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'language', kind: 'category', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'readStatus', kind: 'category', userId: 1 }));
expect(bookRepo.findTemporalJumpBuckets).toHaveBeenCalledWith(
expect.objectContaining({ field: 'publishedYear', direction: 'asc', precision: 'year', maxBuckets: 24 }),
);
+15 -13
View File
@@ -35,9 +35,8 @@ import {
MetadataProviderKey,
Permission,
isAudioFormat,
jumpBucketKindForSort,
jumpRailStrategyForSort,
resolveUploadPath,
temporalJumpBucketPrecisionForSort,
} from '@bookorbit/types';
import type {
AudiobookChapter,
@@ -78,7 +77,6 @@ import { UserBookStatusService, type AutoReadingActivity } from '../user-book-st
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { collapsedJumpBucketExpr, flatJumpBucketExpr } from './jump-bucket-expr';
import { BookRepository } from './book.repository';
import { ComicMetadataRepository } from '../metadata/comic-metadata.repository';
import { CustomMetadataService } from '../custom-metadata/custom-metadata.service';
@@ -1128,22 +1126,21 @@ export class BookService {
async executeJumpBucketsQuery(userId: number, where: SQL | undefined, query: JumpBucketsQuery, timeZone = 'UTC'): Promise<JumpBucketsResponse> {
const event = 'book.jump_buckets';
const kind = jumpBucketKindForSort(query.sort);
const strategy = jumpRailStrategyForSort(query.sort);
const kind = strategy?.kind ?? null;
const primary = query.sort[0] ?? { field: 'title' as const, dir: 'asc' as const };
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(query.filter);
if (!kind) throw new BadRequestException('jump buckets are not available for this sort');
if (!strategy) throw new BadRequestException('jump buckets are not available for this sort');
const start = Date.now();
try {
let response: JumpBucketsResponse;
if (kind === 'temporal') {
const precision = temporalJumpBucketPrecisionForSort(query.sort);
if (!precision) throw new BadRequestException('temporal jump buckets are not available for this sort');
if (strategy.kind === 'temporal') {
const temporalOpts = {
where,
field: primary.field,
direction: primary.dir,
precision,
precision: strategy.precision,
userId,
timeZone,
maxBuckets: query.maxBuckets,
@@ -1152,11 +1149,16 @@ export class BookService {
? await this.bookRepo.findTemporalJumpBucketsCollapsed(temporalOpts)
: await this.bookRepo.findTemporalJumpBuckets(temporalOpts);
} else {
const bucketExpr = shouldCollapse ? collapsedJumpBucketExpr(primary.field) : flatJumpBucketExpr(primary.field);
if (!bucketExpr) throw new BadRequestException('jump buckets are not available for this sort');
const discreteOpts = {
where,
field: primary.field,
kind: strategy.kind,
userId,
maxBuckets: query.maxBuckets,
};
response = shouldCollapse
? await this.bookRepo.findJumpBucketsCollapsed({ where, bucketExpr, sort: query.sort, userId })
: await this.bookRepo.findJumpBuckets({ where, bucketExpr, orderBy: this.queryBuilder.buildOrderBy(query.sort, userId) });
? await this.bookRepo.findJumpBucketsCollapsed({ ...discreteOpts, sort: query.sort })
: await this.bookRepo.findJumpBuckets({ ...discreteOpts, orderBy: this.queryBuilder.buildOrderBy(query.sort, userId) });
}
this.logger.log(
`[${event}] [end] userId=${userId} kind=${kind} collapse=${shouldCollapse} maxBuckets=${query.maxBuckets} durationMs=${Date.now() - start} bucketCount=${response.buckets.length} total=${response.total} - jump buckets computed`,
@@ -1,40 +1,27 @@
import { PgDialect } from 'drizzle-orm/pg-core';
import { describe, expect, it } from 'vitest';
import type { SortField } from '@bookorbit/types';
import { collapsedJumpBucketExpr, flatJumpBucketExpr } from './jump-bucket-expr';
import { sql } from 'drizzle-orm';
import { letterJumpBucketExpr } from './jump-bucket-expr';
const dialect = new PgDialect();
function toSqlText(expr: ReturnType<typeof flatJumpBucketExpr>): string {
if (!expr) throw new Error('expected SQL expression');
function toSqlText(expr: ReturnType<typeof letterJumpBucketExpr>): string {
return dialect.sqlToQuery(expr).sql;
}
describe('jump-bucket-expr', () => {
it('builds a letter expression over the metadata title for flat title sort', () => {
const text = toSqlText(flatJumpBucketExpr('title'));
expect(text).toContain('"book_metadata"."title"');
it('builds an accent-insensitive letter expression over a supplied value', () => {
const text = toSqlText(letterJumpBucketExpr(sql.raw('rail_value')));
expect(text).toContain('rail_value');
expect(text).toContain('public.bookorbit_unaccent');
expect(text).toContain("~ '^[A-Z]$'");
expect(text).toContain("ELSE '#'");
});
it('builds a letter expression over the denormalized author sort key for flat author sort', () => {
const text = toSqlText(flatJumpBucketExpr('author'));
expect(text).toContain('"books"."primary_author_sort_name"');
expect(text).toContain("~ '^[A-Z]$'");
});
it('builds collapsed expressions over representative aliases', () => {
expect(toSqlText(collapsedJumpBucketExpr('title'))).toContain('r.sort_title');
expect(toSqlText(collapsedJumpBucketExpr('author'))).toContain('r.author_sort_name');
});
it('returns null for sort fields without bucket support', () => {
const ineligible: SortField[] = ['addedAt', 'publishedDate', 'publishedYear', 'rating', 'fileSize', 'random', 'series'];
for (const field of ineligible) {
expect(flatJumpBucketExpr(field)).toBeNull();
expect(collapsedJumpBucketExpr(field)).toBeNull();
}
it('preserves non-Latin alphabetic initials instead of folding them into the symbol bucket', () => {
const text = toSqlText(letterJumpBucketExpr(sql.raw('rail_value')));
expect(text).toContain("~ '^[[:alpha:]]$'");
expect(text).toContain('upper(substr(btrim(COALESCE(rail_value');
});
});
+4 -31
View File
@@ -1,40 +1,13 @@
import { SQL, sql } from 'drizzle-orm';
import type { SortField } from '@bookorbit/types';
import { bookMetadata, books } from '../../db/schema';
// Maps a row to its rail bucket: 'A'..'Z', '#' for non-alphabetic first chars,
// NULL (excluded from buckets) for empty values. Must stay in sync with
// jumpBucketKindForSort in @bookorbit/types: every field that yields a kind
// there needs a letter expression here for both the flat and collapsed shapes.
function letterExpr(column: SQL): SQL {
export function letterJumpBucketExpr(column: SQL): SQL {
return sql`
CASE
WHEN btrim(COALESCE(${column}, '')) = '' THEN NULL
WHEN upper(substr(btrim(COALESCE(${column}, '')), 1, 1)) ~ '^[A-Z]$'
WHEN upper(substr(btrim(public.bookorbit_unaccent(COALESCE(${column}, ''))), 1, 1)) ~ '^[A-Z]$'
THEN upper(substr(btrim(public.bookorbit_unaccent(COALESCE(${column}, ''))), 1, 1))
WHEN upper(substr(btrim(COALESCE(${column}, '')), 1, 1)) ~ '^[[:alpha:]]$'
THEN upper(substr(btrim(COALESCE(${column}, '')), 1, 1))
ELSE '#'
END`;
}
export function flatJumpBucketExpr(field: SortField): SQL | null {
switch (field) {
case 'title':
return letterExpr(sql`${bookMetadata.title}`);
case 'author':
return letterExpr(sql`${books.primaryAuthorSortName}`);
default:
return null;
}
}
export function collapsedJumpBucketExpr(field: SortField): SQL | null {
switch (field) {
case 'title':
return letterExpr(sql.raw('r.sort_title'));
case 'author':
return letterExpr(sql.raw('r.author_sort_name'));
default:
return null;
}
}
+87 -15
View File
@@ -18,6 +18,10 @@ type SeededBook = {
id: number;
title: string | null;
authorSortName: string | null;
publisher: string | null;
language: string | null;
format: string | null;
readStatus: string;
publishedYear: number | null;
seriesName: string | null;
addedAt: string;
@@ -32,12 +36,18 @@ function letterOf(value: string | null): string | null {
const trimmed = (value ?? '').trim();
if (trimmed === '') return null;
const first = trimmed[0]!.toUpperCase();
return /^[A-Z]$/.test(first) ? first : '#';
const unaccented =
first
.normalize('NFKD')
.replace(/\p{Mark}/gu, '')[0]
?.toUpperCase() ?? first;
if (/^[A-Z]$/.test(unaccented)) return unaccented;
return /^\p{Letter}$/u.test(first) ? first : '#';
}
function expectedBucketKey(
book: SeededBook,
field: 'title' | 'author' | 'publishedYear',
field: 'title' | 'author' | 'series' | 'publisher' | 'publishedYear',
collapsed: boolean,
granularity: TemporalJumpBucketGranularity | null,
): string | null {
@@ -47,10 +57,17 @@ function expectedBucketKey(
return String(Math.floor(book.publishedYear / step) * step);
}
if (field === 'author') return letterOf(book.authorSortName);
if (field === 'series') return letterOf(collapsed ? (book.seriesName ?? book.title) : book.seriesName);
if (field === 'publisher') return letterOf(book.publisher);
if (collapsed) return letterOf(book.seriesName ?? book.title);
return letterOf(book.title);
}
function expectedCategoryBucketKey(book: SeededBook, field: 'language' | 'format' | 'readStatus'): string {
if (field === 'readStatus') return book.readStatus;
return book[field] ?? '__unknown__';
}
function expectedTemporalBucketKey(value: string | null, granularity: TemporalJumpBucketGranularity | null): string {
if (value === null) return '__unknown__';
if (!granularity || granularity.unit === 'day') return value;
@@ -66,6 +83,8 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
async function seedBook(input: {
title: string | null;
publisher?: string | null;
language?: string | null;
publishedYear?: number | null;
seriesId?: number | null;
seriesName?: string | null;
@@ -89,6 +108,8 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
await ctx.db.insert(bookMetadata).values({
bookId: book!.id,
title: input.title,
publisher: input.publisher ?? null,
language: input.language ?? null,
publishedYear: input.publishedYear ?? null,
seriesId: input.seriesId ?? null,
seriesName: input.seriesName ?? null,
@@ -202,13 +223,17 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
author: { id: cheap!.id, sortName: '99 Cent Press', name: '99 Cent Press' },
addedAt: '2024-01-16',
},
{ title: 'quail hunting', publishedYear: 1991, addedAt: '2024-01-17' },
{ title: 'Дальний берег', publishedYear: 1991, addedAt: '2024-01-17' },
];
const allIds: number[] = [];
for (const spec of seedSpecs) {
for (const [index, spec] of seedSpecs.entries()) {
const publisher = index % 6 === 0 ? null : ['Orbit', 'Penguin', 'Éditions du Nord'][index % 3]!;
const language = index % 7 === 0 ? null : ['en', 'de', 'pt'][index % 3]!;
const id = await seedBook({
title: spec.title,
publisher,
language,
publishedYear: spec.publishedYear ?? null,
seriesId: spec.series?.id ?? null,
seriesName: spec.series?.name ?? null,
@@ -222,6 +247,10 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
id,
title: spec.title,
authorSortName: spec.author ? (spec.author.sortName ?? spec.author.name) : null,
publisher,
language,
format: null,
readStatus: 'unread',
publishedYear: spec.publishedYear ?? null,
seriesName: spec.series?.name ?? null,
addedAt: spec.addedAt,
@@ -237,6 +266,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
const [admin] = await ctx.db.select({ id: users.id }).from(users).where(eq(users.username, 'metadata-write-e2e-admin')).limit(1);
if (!admin) throw new Error('e2e admin user not found');
const seededStatuses = ['reading', 'read', 'want_to_read'] as const;
const statusRows = allIds.flatMap((bookId, index) => {
if (index % 5 === 0) return [];
const day = String(index + 1).padStart(2, '0');
@@ -245,11 +275,12 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
const seeded = seededById.get(bookId)!;
seeded.startedAt = startedAt;
seeded.finishedAt = finishedAt;
seeded.readStatus = seededStatuses[index % seededStatuses.length]!;
return [
{
userId: admin.id,
bookId,
status: 'read' as const,
status: seeded.readStatus as (typeof seededStatuses)[number],
source: 'manual' as const,
startedAt: new Date(`${startedAt}T12:00:00Z`),
finishedAt: new Date(`${finishedAt}T12:00:00Z`),
@@ -258,28 +289,38 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
});
await ctx.db.insert(userBookStatus).values(statusRows);
const progressBookIds = allIds.filter((_, index) => index % 5 !== 0);
const seededFormats = ['epub', 'pdf', 'mobi'] as const;
const insertedFiles = await ctx.db
.insert(bookFiles)
.values(
progressBookIds.map((bookId, index) => ({
allIds.map((bookId, index) => ({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `/seed/progress-${bookId}.epub`,
relPath: `progress-${bookId}.epub`,
ino: BigInt(900_000 + index),
format: 'epub',
format: seededFormats[index % seededFormats.length],
role: 'content',
})),
)
.returning({ id: bookFiles.id, bookId: bookFiles.bookId });
for (const [index, file] of insertedFiles.entries()) {
const seeded = seededById.get(file.bookId)!;
await ctx.db
.update(books)
.set({ primaryFileId: file.id, updatedAt: new Date(`${seeded.updatedAt}T00:00:00.000Z`) })
.where(eq(books.id, file.bookId));
seeded.format = seededFormats[index % seededFormats.length]!;
}
await ctx.db.insert(readingProgress).values(
insertedFiles.map((file, index) => {
const day = String(index + 1).padStart(2, '0');
const lastReadAt = `2025-05-${day}`;
seededById.get(file.bookId)!.lastReadAt = lastReadAt;
return { bookFileId: file.id, userId: admin.id, percentage: 50, updatedAt: new Date(`${lastReadAt}T12:00:00Z`) };
}),
insertedFiles
.filter((_, index) => index % 5 !== 0)
.map((file, index) => {
const day = String(index + 1).padStart(2, '0');
const lastReadAt = `2025-05-${day}`;
seededById.get(file.bookId)!.lastReadAt = lastReadAt;
return { bookFileId: file.id, userId: admin.id, percentage: 50, updatedAt: new Date(`${lastReadAt}T12:00:00Z`) };
}),
);
});
@@ -309,7 +350,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
return response.json() as BooksPage;
}
const fields = ['title', 'author', 'publishedYear'] as const;
const fields = ['title', 'author', 'series', 'publisher', 'publishedYear'] as const;
const dirs = ['asc', 'desc'] as const;
const collapseModes = [false, true] as const;
@@ -363,6 +404,37 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
for (const year of expectedYears) expect(returnedKeys.has(year)).toBe(true);
});
const categoryFields = ['language', 'format', 'readStatus'] as const;
for (const field of categoryFields) {
for (const dir of dirs) {
for (const collapsed of collapseModes) {
it(`anchors ${field} ${dir} collapse=${collapsed} category buckets to exact listing offsets`, async () => {
const sort: SortSpec[] = [{ field, dir }];
const [bucketsResponse, listing] = await Promise.all([fetchBuckets(sort, collapsed), fetchListing(sort, collapsed)]);
expect(bucketsResponse.kind).toBe('category');
expect(bucketsResponse.buckets.length).toBeGreaterThan(1);
expect(bucketsResponse.buckets.length).toBeLessThanOrEqual(32);
expect(bucketsResponse.total).toBe(listing.total);
let previousIndex = -1;
for (const bucket of bucketsResponse.buckets) {
expect(bucket.index).toBeGreaterThan(previousIndex);
previousIndex = bucket.index;
const itemAtAnchor = listing.items[bucket.index] as BookCard;
const seeded = seededById.get(itemAtAnchor.id)!;
expect(expectedCategoryBucketKey(seeded, field)).toBe(bucket.key);
if (bucket.index > 0) {
const itemBefore = listing.items[bucket.index - 1] as BookCard;
const seededBefore = seededById.get(itemBefore.id)!;
expect(expectedCategoryBucketKey(seededBefore, field)).not.toBe(bucket.key);
}
}
});
}
}
}
const temporalFields = ['addedAt', 'updatedAt', 'lastReadAt', 'startedAt', 'finishedAt'] as const;
for (const field of temporalFields) {
for (const dir of dirs) {