feat(books): add adaptive temporal jump rail

Add bounded, user-scoped temporal buckets for supported date sorts and adapt resolution to populated dates. Keep rail navigation responsive with viewport sizing, sparse-gap compression, and virtual-grid synchronization.
This commit is contained in:
neon
2026-07-16 00:41:46 +00:00
parent 014c0af456
commit 5923ea95a5
34 changed files with 1223 additions and 193 deletions
+201 -50
View File
@@ -1,18 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { TransitionPresets, useElementSize, useMediaQuery, useTransition } from '@vueuse/core'
import type { JumpBucket, JumpBucketKind } from '@bookorbit/types'
import { TransitionPresets, useElementBounding, useMediaQuery, useTransition } from '@vueuse/core'
import type { JumpBucket, JumpBucketKind, TemporalJumpBucketGranularity } from '@bookorbit/types'
import { useI18n } from 'vue-i18n'
import { formatDate, formatNumber } from '@/i18n/formatters'
const { t } = useI18n()
const { t, locale } = 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
}
@@ -20,8 +24,11 @@ const props = defineProps<{
visible: boolean
buckets: JumpBucket[]
kind: JumpBucketKind
granularity?: TemporalJumpBucketGranularity | null
activeKey: string | null
template?: string[]
maxSlots?: number
viewport?: HTMLElement | null
}>()
const emit = defineEmits<{
@@ -30,29 +37,163 @@ const emit = defineEmits<{
}>()
const railEl = ref<HTMLElement | null>(null)
const { height: railHeight } = useElementSize(railEl)
const viewportTarget = computed(() => props.viewport ?? null)
const { top: viewportTop, height: viewportHeight } = useElementBounding(viewportTarget)
const railPositionStyle = computed(() => {
if (viewportHeight.value <= 0) return undefined
return {
top: `${viewportTop.value + viewportHeight.value / 2}px`,
maxHeight: `${viewportHeight.value * 0.85}px`,
}
})
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, bucket: byKey.get(key) ?? null }))
return props.template.map((key) => ({ key, label: key, displayLabel: key, accessibleLabel: key, bucket: byKey.get(key) ?? null }))
}
const all = props.buckets.map((bucket) => ({ key: bucket.key, label: bucket.label, bucket }))
const max = Math.max(2, Math.floor((railHeight.value || 480) / SLOT_PX))
if (all.length <= max) return all
// Thin evenly to fit the rail height, always keeping the first and last.
const step = (all.length - 1) / (max - 1)
const picked: RailSlot[] = []
const used = new Set<number>()
for (let i = 0; i < max; i++) {
const index = Math.round(i * step)
if (used.has(index)) continue
used.add(index)
picked.push(all[index]!)
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 picked
return temporalSlots
})
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
@@ -64,23 +205,15 @@ let scrollPulseTimer: ReturnType<typeof setTimeout> | null = null
const engaged = computed(() => hovering.value || scrubbing.value || scrollPulse.value)
const activeIndex = computed(() => (props.activeKey ? slots.value.findIndex((slot) => slot.key === props.activeKey) : -1))
const lozengeVisible = computed(() => activeIndex.value >= 0)
const lozengeIndex = useTransition(
computed(() => Math.max(0, activeIndex.value)),
{ duration: 300, transition: TransitionPresets.easeOutBack, disabled: reducedMotion },
)
const lozengeY = computed(() => RAIL_PAD_PX + lozengeIndex.value * SLOT_PX)
// Hover capsule: a soft pill that glides to the slot under the cursor (mouse
// only). The target index never resets to 0 so the pill springs between
// letters instead of sliding up from the top each time it reappears.
// The target index never resets to 0 so the hover or scrub indicator moves
// between slots instead of sliding up from the top each time it reappears.
const hoveredIndex = ref<number | null>(null)
const hoverTargetIndex = ref(0)
watch(hoveredIndex, (value) => {
if (value !== null) hoverTargetIndex.value = value
})
const hoverVisible = computed(() => hoveredIndex.value !== null)
const previewSlot = computed(() => (hoveredIndex.value === null ? null : (slots.value[hoveredIndex.value] ?? null)))
const hoverThumbIndex = useTransition(hoverTargetIndex, {
duration: 220,
transition: TransitionPresets.easeOutBack,
@@ -138,6 +271,7 @@ function pulseHaptics(pointerType: string) {
function scrubTo(clientY: number, pointerType: string) {
const slot = nearestAvailable(slotAtClientY(clientY))
if (!slot?.bucket) return
hoveredIndex.value = slots.value.indexOf(slot)
if (slot.key === lastScrubKey) return
lastScrubKey = slot.key
pulseHaptics(pointerType)
@@ -162,8 +296,9 @@ function handlePointerEnter(event: PointerEvent) {
if (event.pointerType === 'mouse') hovering.value = true
}
function handlePointerEnd() {
function handlePointerEnd(event: PointerEvent) {
scrubbing.value = false
if (event.pointerType !== 'mouse') hoveredIndex.value = null
}
function handlePointerLeave() {
@@ -198,8 +333,12 @@ function handleAfterLeave() {
<nav
v-if="visible"
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-hidden rounded-full border border-border bg-background/85 py-2 shadow-sm backdrop-blur"
:class="[engaged ? 'px-1.5' : 'px-1', reducedMotion ? '' : 'transition-[padding] duration-300 ease-out']"
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',
reducedMotion ? '' : 'transition-[width,padding] duration-300 ease-out',
]"
:style="railPositionStyle"
:aria-label="t('book.jumpRail.jumpToSection')"
data-testid="jump-rail"
@pointerdown="handlePointerDown"
@@ -209,13 +348,6 @@ function handleAfterLeave() {
@pointercancel="handlePointerEnd"
@pointerleave="handlePointerLeave"
>
<div
v-show="lozengeVisible"
class="jump-rail-lozenge pointer-events-none absolute inset-x-1 top-0 z-0 h-5 rounded-full bg-primary shadow-sm"
:style="{ transform: `translateY(${lozengeY}px)` }"
aria-hidden="true"
/>
<div
v-show="hoverVisible"
class="jump-rail-thumb pointer-events-none absolute inset-x-1 top-0 z-0 h-5 rounded-full bg-foreground/10 transition-opacity duration-150"
@@ -223,26 +355,45 @@ function handleAfterLeave() {
aria-hidden="true"
/>
<div
v-if="kind === 'temporal' && 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"
data-testid="jump-rail-preview"
>
{{ previewSlot.label }}
</div>
<button
v-for="(slot, index) in slots"
:key="slot.key"
type="button"
class="jump-rail-slot relative z-10 flex h-5 shrink-0 items-center justify-center rounded-full px-1 text-center text-[11px] font-medium tabular-nums transition-[color,transform] duration-150"
class="jump-rail-slot relative z-10 h-5 shrink-0 items-center px-0.5 text-center text-[11px] font-medium tabular-nums transition-colors duration-150"
:class="[
slot.bucket
? slot.key === activeKey
? 'text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
: 'cursor-default text-muted-foreground/30',
index === hoveredIndex ? 'scale-[1.35] font-semibold text-foreground' : '',
'flex justify-center rounded-full',
slot.bucket ? 'text-muted-foreground hover:text-foreground' : 'cursor-default text-muted-foreground/30',
index === hoveredIndex && kind === 'letter' ? 'scale-[1.35] font-semibold text-foreground' : '',
]"
:disabled="!slot.bucket"
:aria-label="t('book.jumpRail.jumpTo', { label: slot.label })"
:aria-label="t('book.jumpRail.jumpTo', { label: slot.accessibleLabel })"
:aria-current="slot.key === activeKey ? 'true' : undefined"
:data-key="slot.key"
@click="handleSlotClick(slot)"
>
{{ slot.label }}
<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' : ''"
>
{{ slot.displayLabel }}
</span>
<span
v-else
class="rounded-full"
:class="slot.key === activeKey ? 'size-1.5 bg-primary opacity-100' : 'size-1 bg-current opacity-60'"
aria-hidden="true"
/>
</button>
</nav>
</Transition>
@@ -251,7 +402,7 @@ function handleAfterLeave() {
<style scoped>
.jump-rail {
touch-action: none;
contain: layout paint style;
contain: layout style;
}
.jump-rail-slot {
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import type { BookCard } from '@bookorbit/types'
import VirtualBookGrid from './VirtualBookGrid.vue'
const scrollToItemMock = vi.fn<(index: number) => void>()
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
vi.mock('vue-virtual-scroller', () => ({
RecycleScroller: {
@@ -12,8 +12,8 @@ vi.mock('vue-virtual-scroller', () => ({
props: ['items'],
emits: ['update'],
methods: {
scrollToItem(index: number) {
scrollToItemMock(index)
scrollToItem(index: number, options?: { align?: string }) {
scrollToItemMock(index, options)
},
},
template: '<div data-testid="recycle-scroller"><slot v-for="item in items" :key="item.id" :item="item" /></div>',
@@ -236,7 +236,58 @@ describe('VirtualBookGrid', () => {
})
;(wrapper.vm as unknown as { scrollToIndex: (i: number) => void }).scrollToIndex(42)
expect(scrollToItemMock).toHaveBeenCalledWith(42)
expect(scrollToItemMock).toHaveBeenCalledWith(42, { align: 'start' })
})
it('reports index zero at the top and preserves a jump target within the same row', async () => {
const scrollParent = document.createElement('div')
scrollParent.style.overflowY = 'auto'
document.body.append(scrollParent)
const requestAnimationFrameSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 0
})
const books = Array.from({ length: 12 }, (_, index) => makeBook(index + 1))
const wrapper = mount(VirtualBookGrid, {
attachTo: scrollParent,
props: { books, coverSize: 120, gridGap: 12 },
})
vi.spyOn(scrollParent, 'getBoundingClientRect').mockReturnValue({
top: 0,
left: 0,
right: 600,
bottom: 800,
width: 600,
height: 800,
x: 0,
y: 0,
toJSON: () => ({}),
})
vi.spyOn(wrapper.element, 'getBoundingClientRect').mockReturnValue({
top: 0,
left: 0,
right: 600,
bottom: 1600,
width: 600,
height: 1600,
x: 0,
y: 0,
toJSON: () => ({}),
})
await nextTick()
scrollParent.dispatchEvent(new Event('scroll'))
expect(wrapper.emitted('first-visible-index')).toEqual([[0]])
;(wrapper.vm as unknown as { scrollToIndex: (index: number) => void }).scrollToIndex(3)
scrollParent.dispatchEvent(new Event('scroll'))
expect(wrapper.emitted('first-visible-index')).toEqual([[0], [3]])
wrapper.unmount()
scrollParent.remove()
requestAnimationFrameSpy.mockRestore()
})
it('reserves a right gutter when railGutter is set', () => {
@@ -244,7 +295,7 @@ describe('VirtualBookGrid', () => {
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, railGutter: true },
})
expect(wrapper.classes()).toContain('pr-9')
expect(wrapper.classes()).toContain('pr-16')
})
})
@@ -43,7 +43,7 @@ const emit = defineEmits<{
}>()
const containerRef = ref<HTMLElement | null>(null)
const scrollerRef = ref<{ scrollToItem: (index: number) => void } | null>(null)
const scrollerRef = ref<{ scrollToItem: (index: number, options?: { align?: 'start' | 'center' | 'end' | 'auto' }) => void } | null>(null)
const { width: containerWidth } = useElementSize(containerRef)
const { width: windowWidth } = useWindowSize()
@@ -173,6 +173,8 @@ function handleScrollerUpdate(startIndex: number, endIndex: number) {
const firstVisibleIndex = ref(0)
let scrollParent: HTMLElement | null = null
let scrollRafId = 0
let jumpAnchorIndex: number | null = null
let jumpAnchorRowStart: number | null = null
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
for (let node = el?.parentElement ?? null; node; node = node.parentElement) {
@@ -191,14 +193,27 @@ function computeFirstVisibleIndex() {
const row = Math.max(0, Math.floor(scrolledIntoGrid / itemSize.value))
const cols = gridItems.value
const rowStart = Math.min(props.books.length - 1, row * cols)
if (rowStart === firstVisibleIndex.value) return
firstVisibleIndex.value = rowStart
// Report the middle cell of the first visible row, not its first cell. A
// letter bucket usually starts mid-row, so reporting the row start would
// resolve the active bucket to the previous letter (the tail still showing
// in the top-left). The midpoint lands inside the dominant letter of the
// row, which is also the one a jump targets.
const activeIndex = Math.min(props.books.length - 1, rowStart + Math.floor((cols - 1) / 2))
if (jumpAnchorIndex !== null) {
const lastVisibleRow = Math.max(row, Math.floor(Math.max(0, parentRect.bottom - gridRect.top - 1) / itemSize.value))
const anchorRow = Math.floor(jumpAnchorIndex / cols)
if (jumpAnchorRowStart === null) {
if (anchorRow >= row && anchorRow <= lastVisibleRow) {
jumpAnchorRowStart = rowStart
emit('first-visible-index', jumpAnchorIndex)
}
return
}
if (rowStart === jumpAnchorRowStart) {
emit('first-visible-index', jumpAnchorIndex)
return
}
jumpAnchorIndex = null
jumpAnchorRowStart = null
}
// At the top boundary the first rail target must stay active. Below it, the
// midpoint represents the section occupying most of the first visible row.
const activeIndex = row === 0 ? 0 : Math.min(props.books.length - 1, rowStart + Math.floor((cols - 1) / 2))
emit('first-visible-index', activeIndex)
}
@@ -237,15 +252,16 @@ watch(gridItems, (next, prev) => {
})
function scrollToIndex(index: number) {
scrollerRef.value?.scrollToItem(index)
handleScrollParentScroll()
jumpAnchorIndex = index
jumpAnchorRowStart = null
scrollerRef.value?.scrollToItem(index, { align: 'start' })
}
defineExpose({ scrollToIndex })
</script>
<template>
<div ref="containerRef" class="w-full" :class="{ 'pr-9': railGutter }">
<div ref="containerRef" class="w-full" :class="{ 'pr-16': railGutter }">
<div
v-if="!virtualized && useVariableStaticWidths"
class="flex w-full flex-wrap content-start items-end"
@@ -112,19 +112,87 @@ describe('JumpRail', () => {
})
it('renders year buckets without a template', () => {
const wrapper = mountRail({ kind: 'year' as const, buckets: yearBuckets(5), template: undefined })
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 1 },
buckets: yearBuckets(5),
template: undefined,
})
const labels = wrapper.findAll('button').map((slot) => slot.text())
expect(labels).toEqual(['1950', '1951', '1952', '1953', '1954'])
})
it('thins large year sets to fit while keeping first and last', () => {
const wrapper = mountRail({ kind: 'year' as const, buckets: yearBuckets(80), template: undefined })
it('formats multi-year ranges and unknown dates', () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 10 },
buckets: [
{ key: '1900', label: '1900', index: 0 },
{ key: '__unknown__', label: '__unknown__', index: 40, isUnknown: true },
],
template: undefined,
})
const labels = wrapper.findAll('button').map((slot) => slot.text())
expect(labels.length).toBeLessThan(80)
expect(labels[0]).toBe('1950')
expect(labels[labels.length - 1]).toBe('2029')
expect(wrapper.get('button[data-key="1900"]').text()).toBe('1900')
expect(wrapper.get('button[data-key="1900"]').attributes('aria-label')).toContain('1900-1909')
expect(wrapper.get('button[data-key="__unknown__"]').text()).toBe('?')
expect(wrapper.get('button[data-key="__unknown__"]').attributes('aria-label')).toContain('Unknown date')
})
it('fills bounded temporal gaps with disabled dot targets', () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 5 },
buckets: [
{ key: '1900', label: '1900', index: 0 },
{ key: '1920', label: '1920', index: 40 },
],
template: undefined,
})
expect(wrapper.findAll('button').map((button) => button.attributes('data-key'))).toEqual(['1900', '1905', '1910', '1915', '1920'])
expect(wrapper.get('button[data-key="1905"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[data-key="1905"]').text()).toBe('')
})
it('compresses empty time without dropping populated year targets', () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 1 },
maxSlots: 5,
buckets: [
{ key: '1974', label: '1974', index: 0 },
{ key: '1988', label: '1988', index: 4 },
{ key: '1989', label: '1989', index: 7 },
],
template: undefined,
})
expect(wrapper.findAll('button')).toHaveLength(5)
expect(wrapper.get('button[data-key="1974"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('button[data-key="1988"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('button[data-key="1989"]').attributes('disabled')).toBeUndefined()
})
it('uses year anchors and dot targets for months with a hover preview', async () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'month', step: 1 },
buckets: [
{ key: '2026-01', label: '2026-01', index: 0 },
{ key: '2026-02', label: '2026-02', index: 10 },
{ key: '2026-03', label: '2026-03', index: 20 },
],
template: undefined,
})
preparePointerRail(wrapper, 60)
expect(wrapper.get('button[data-key="2026-01"]').text()).toBe('2026')
expect(wrapper.get('button[data-key="2026-02"]').text()).toBe('')
await firePointer(wrapper, 'pointermove', { clientY: 30, pointerType: 'mouse' })
expect(wrapper.get('[data-testid="jump-rail-preview"]').text()).toBe('February 2026')
})
it('renders nothing when not visible', () => {
@@ -73,7 +73,7 @@ function bucket(key: string, index: number): JumpBucket {
function mockApi(total: number, buckets: JumpBucket[] = []) {
fetchMock.mockImplementation((url, init) => {
if (url.includes('jump-buckets')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ buckets }) })
return Promise.resolve({ ok: true, json: () => Promise.resolve({ buckets, total, kind: 'letter', granularity: null }) })
}
const body = JSON.parse(String(init?.body)) as { pagination: { page: number } }
return Promise.resolve({ ok: true, json: () => Promise.resolve(pageFor(body.pagination.page, total)) })
@@ -21,7 +21,7 @@ const LETTER_BUCKETS: JumpBucket[] = [
function mockBucketsResponse(buckets: JumpBucket[]) {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ buckets, total: 50 }),
json: () => Promise.resolve({ buckets, total: 50, kind: 'letter', granularity: null }),
})
}
@@ -42,8 +42,9 @@ function makeBuckets(
const query = ref<BookWindowQuery>({ sort: overrides.sort ?? [{ field: 'title', dir: 'asc' }] })
const enabled = ref(overrides.enabledValue ?? true)
const firstVisibleIndex = ref(0)
const composable = useJumpBuckets({ endpoint, query, enabled, firstVisibleIndex })
return { ...composable, endpoint, query, enabled, firstVisibleIndex }
const maxBuckets = ref(24)
const composable = useJumpBuckets({ endpoint, query, enabled, firstVisibleIndex, maxBuckets })
return { ...composable, endpoint, query, enabled, firstVisibleIndex, maxBuckets }
}
describe('useJumpBuckets', () => {
@@ -62,7 +63,7 @@ describe('useJumpBuckets', () => {
})
it('does not fetch when the sort is ineligible', async () => {
const { buckets, kind } = makeBuckets({ sort: [{ field: 'addedAt', dir: 'desc' }] })
const { buckets, kind } = makeBuckets({ sort: [{ field: 'rating', dir: 'desc' }] })
await flush()
expect(fetchMock).not.toHaveBeenCalled()
@@ -70,6 +71,26 @@ describe('useJumpBuckets', () => {
expect(kind.value).toBeNull()
})
it('requests bounded temporal buckets and retains their granularity', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
buckets: [{ key: '2026-07', label: '2026-07', index: 0 }],
total: 50,
kind: 'temporal',
granularity: { unit: 'month', step: 1 },
}),
})
const { kind, granularity } = makeBuckets({ sort: [{ field: 'addedAt', dir: 'desc' }] })
await flush()
expect(kind.value).toBe('temporal')
expect(granularity.value).toEqual({ unit: 'month', step: 1 })
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
expect(body.maxBuckets).toBe(24)
})
it('does not fetch while disabled and fetches once enabled', async () => {
mockBucketsResponse(LETTER_BUCKETS)
const { buckets, enabled } = makeBuckets({ enabledValue: false })
@@ -1,4 +1,5 @@
import { computed, ref, type Ref } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { jumpBucketKindForSort, type GroupRule, type JumpBucket, type SortSpec } from '@bookorbit/types'
import { useBookProgressRefresh } from './useBookProgressRefresh'
import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow'
@@ -8,6 +9,9 @@ import { useJumpRailGutter } from './useJumpRailGutter'
const LETTER_TEMPLATE = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))]
const MIN_TOTAL_FOR_RAIL = 50
const LIST_CHUNK = 100
const RAIL_SLOT_PX = 20
const RAIL_VERTICAL_FRACTION = 0.85
const RAIL_VERTICAL_PADDING_PX = 16
function isValidScopeId(value: number | null): value is number {
return value !== null && Number.isInteger(value) && value > 0
@@ -24,6 +28,7 @@ export function useBookViewWindow(options: {
listEndpoint: (id: number) => string
bucketsEndpoint: (id: number) => string
viewMode: Ref<string>
railViewport?: Ref<HTMLElement | null>
collapseEnabled?: Ref<boolean>
q?: Ref<string>
}) {
@@ -43,6 +48,14 @@ export function useBookViewWindow(options: {
const window = useBookWindow({ endpoint: listEndpoint, query })
const firstVisibleIndex = ref(0)
const { height: windowHeight } = useWindowSize()
const fallbackRailViewport = ref<HTMLElement | null>(null)
const { height: railViewportHeight } = useElementSize(options.railViewport ?? fallbackRailViewport)
const railCapacity = computed(() => {
const viewportHeight = railViewportHeight.value || windowHeight.value
const available = viewportHeight * RAIL_VERTICAL_FRACTION - RAIL_VERTICAL_PADDING_PX
return Math.min(64, Math.max(8, Math.floor(available / RAIL_SLOT_PX)))
})
function handleFirstVisibleIndex(index: number) {
firstVisibleIndex.value = index
@@ -61,6 +74,7 @@ export function useBookViewWindow(options: {
query,
enabled: railEligible,
firstVisibleIndex,
maxBuckets: railCapacity,
})
useBookProgressRefresh(() => {
@@ -111,6 +125,8 @@ export function useBookViewWindow(options: {
loadMorePrefix,
bucketKind,
buckets: bucketsApi.buckets,
temporalGranularity: bucketsApi.granularity,
railCapacity,
refreshBuckets: bucketsApi.refresh,
railVisible,
activeBucketKey,
@@ -1,8 +1,16 @@
import { computed, shallowRef, watch, type Ref } from 'vue'
import { api } from '@/lib/api'
import { jumpBucketKindForSort, type BookQuery, type JumpBucket, type JumpBucketsResponse } from '@bookorbit/types'
import {
jumpBucketKindForSort,
type JumpBucket,
type JumpBucketsQuery,
type JumpBucketsResponse,
type TemporalJumpBucketGranularity,
} from '@bookorbit/types'
import type { BookWindowQuery } from './useBookWindow'
const MAX_CACHE_ENTRIES = 50
/**
* Fetches the jump buckets for the current query and derives the active
* bucket purely from the first visible row index. Clicks never write the
@@ -15,20 +23,23 @@ export function useJumpBuckets(options: {
query: Ref<BookWindowQuery>
enabled: Ref<boolean>
firstVisibleIndex: Ref<number>
maxBuckets: Ref<number>
}) {
const buckets = shallowRef<JumpBucket[]>([])
const granularity = shallowRef<TemporalJumpBucketGranularity | null>(null)
const loading = shallowRef(false)
const cache = new Map<string, JumpBucket[]>()
const cache = new Map<string, Pick<JumpBucketsResponse, 'buckets' | 'granularity'>>()
let generation = 0
const kind = computed(() => jumpBucketKindForSort(options.query.value.sort))
const cacheKey = computed(() => `${options.endpoint.value ?? ''}|${JSON.stringify(options.query.value)}`)
const cacheKey = computed(() => `${options.endpoint.value ?? ''}|${options.maxBuckets.value}|${JSON.stringify(options.query.value)}`)
async function fetchBuckets() {
const gen = ++generation
const endpoint = options.endpoint.value
if (!endpoint || !options.enabled.value || !kind.value) {
buckets.value = []
granularity.value = null
loading.value = false
return
}
@@ -36,14 +47,21 @@ export function useJumpBuckets(options: {
const key = cacheKey.value
const cached = cache.get(key)
if (cached) {
buckets.value = cached
cache.delete(key)
cache.set(key, cached)
buckets.value = cached.buckets
granularity.value = cached.granularity
loading.value = false
return
}
loading.value = true
try {
const body: BookQuery = { ...options.query.value, pagination: { page: 0, size: 50 } }
const body: JumpBucketsQuery = {
...options.query.value,
pagination: { page: 0, size: 50 },
maxBuckets: options.maxBuckets.value,
}
const res = await api(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -53,10 +71,18 @@ export function useJumpBuckets(options: {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: JumpBucketsResponse = await res.json()
if (gen !== generation) return
cache.set(key, data.buckets)
cache.set(key, { buckets: data.buckets, granularity: data.granularity })
if (cache.size > MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value
if (oldestKey !== undefined) cache.delete(oldestKey)
}
buckets.value = data.buckets
granularity.value = data.granularity
} catch {
if (gen === generation) buckets.value = []
if (gen === generation) {
buckets.value = []
granularity.value = null
}
} finally {
if (gen === generation) loading.value = false
}
@@ -80,5 +106,5 @@ export function useJumpBuckets(options: {
watch([cacheKey, options.enabled], fetchBuckets, { immediate: true })
return { buckets, kind, loading, activeBucket, refresh }
return { buckets, kind, granularity, loading, activeBucket, refresh }
}
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Zu Abschnitt springen",
"jumpTo": "Zu {label} springen"
"jumpTo": "Zu {label} springen",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Schnellansicht des Buchs",
+2 -1
View File
@@ -3022,7 +3022,8 @@
},
"jumpRail": {
"jumpToSection": "Jump to section",
"jumpTo": "Jump to {label}"
"jumpTo": "Jump to {label}",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Book quick view",
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Naar sectie springen",
"jumpTo": "Naar {label} springen"
"jumpTo": "Naar {label} springen",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Snelweergave boek",
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Pular para a seção",
"jumpTo": "Pular para {label}"
"jumpTo": "Pular para {label}",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Visualização rápida do livro",
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Skoči na razdelek",
"jumpTo": "Skoči na {label}"
"jumpTo": "Skoči na {label}",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Hitri pregled knjige",
+7 -1
View File
@@ -75,6 +75,7 @@ watch(prefs, () => {
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const mainRef = ref<HTMLElement | null>(null)
const {
booksProxy: books,
@@ -94,6 +95,8 @@ const {
handleJump,
buckets,
bucketKind,
temporalGranularity,
railCapacity,
refreshBuckets,
railVisible,
activeBucketKey,
@@ -105,11 +108,11 @@ const {
listEndpoint: (id) => `/api/v1/collections/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/collections/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { sortModel: tableSortModel } = useViewSort(tableSort, 'collection', collectionId)
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const collectionLoadError = computed(() => collectionsError.value ?? booksError.value)
const { setBookContext } = useBookNavigation()
@@ -664,6 +667,9 @@ defineOptions({ name: 'CollectionView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
:active-key="activeBucketKey"
:template="bucketKind === 'letter' ? letterTemplate : undefined"
@jump="handleJump"
+7 -1
View File
@@ -102,6 +102,7 @@ watch(prefs, () => {
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const mainRef = ref<HTMLElement | null>(null)
const {
booksProxy: books,
@@ -123,6 +124,8 @@ const {
handleJump,
buckets,
bucketKind,
temporalGranularity,
railCapacity,
refreshBuckets,
railVisible,
activeBucketKey,
@@ -134,11 +137,11 @@ const {
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const { setBookContext } = useBookNavigation()
useBookViewContext(slots, total, loadMorePrefix)
@@ -966,6 +969,9 @@ defineOptions({ name: 'HomeView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
:active-key="activeBucketKey"
:template="bucketKind === 'letter' ? letterTemplate : undefined"
@jump="handleJump"
+7 -1
View File
@@ -85,6 +85,7 @@ watch(prefs, () => {
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const mainRef = ref<HTMLElement | null>(null)
const {
booksProxy: books,
slots,
@@ -103,6 +104,8 @@ const {
handleJump,
buckets,
bucketKind,
temporalGranularity,
railCapacity,
refreshBuckets,
railVisible,
activeBucketKey,
@@ -114,11 +117,11 @@ const {
listEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { setBookContext } = useBookNavigation()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
useBookViewContext(slots, total, loadMorePrefix)
const { smartScopes, loaded: smartScopesLoaded, error: smartScopesError, fetchSmartScopes, deleteSmartScope } = useSmartScopes()
@@ -799,6 +802,9 @@ defineOptions({ name: 'SmartScopeView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
:active-key="activeBucketKey"
:template="bucketKind === 'letter' ? letterTemplate : undefined"
@jump="handleJump"
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { jumpBucketKindForSort } from "../jump-buckets";
import { jumpBucketKindForSort, temporalJumpBucketPrecisionForSort } from "../jump-buckets";
import type { SortSpec } from "../query";
describe("jumpBucketKindForSort", () => {
@@ -14,14 +14,16 @@ describe("jumpBucketKindForSort", () => {
expect(jumpBucketKindForSort([{ field: "author", dir: "desc" }])).toBe("letter");
});
it("returns year for publishedYear sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "asc" }])).toBe("year");
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "desc" }])).toBe("year");
it("returns temporal for publishedYear sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "asc" }])).toBe("temporal");
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "desc" }])).toBe("temporal");
});
it("returns year for publishedDate sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedDate", dir: "asc" }])).toBe("year");
expect(jumpBucketKindForSort([{ field: "publishedDate", dir: "desc" }])).toBe("year");
it("returns temporal for all supported date sorts", () => {
for (const field of ["addedAt", "updatedAt", "publishedDate", "lastReadAt", "startedAt", "finishedAt"] as const) {
expect(jumpBucketKindForSort([{ field, dir: "asc" }])).toBe("temporal");
expect(jumpBucketKindForSort([{ field, dir: "desc" }])).toBe("temporal");
}
});
it("returns letter for an empty sort (defaults to title asc)", () => {
@@ -29,12 +31,7 @@ describe("jumpBucketKindForSort", () => {
});
it("returns null for ineligible primary sort fields", () => {
const ineligible: SortSpec[][] = [
[{ field: "addedAt", dir: "desc" }],
[{ field: "rating", dir: "desc" }],
[{ field: "fileSize", dir: "asc" }],
[{ field: "random", dir: "asc" }],
];
const ineligible: SortSpec[][] = [[{ field: "rating", dir: "desc" }], [{ field: "fileSize", dir: "asc" }], [{ field: "random", dir: "asc" }]];
for (const sort of ineligible) {
expect(jumpBucketKindForSort(sort)).toBeNull();
}
@@ -43,7 +40,7 @@ describe("jumpBucketKindForSort", () => {
it("only considers the primary sort field", () => {
expect(
jumpBucketKindForSort([
{ field: "addedAt", dir: "desc" },
{ field: "rating", dir: "desc" },
{ field: "title", dir: "asc" },
]),
).toBeNull();
@@ -55,3 +52,20 @@ describe("jumpBucketKindForSort", () => {
).toBe("letter");
});
});
describe("temporalJumpBucketPrecisionForSort", () => {
it("keeps publication sorts at year precision", () => {
expect(temporalJumpBucketPrecisionForSort([{ field: "publishedDate", dir: "asc" }])).toBe("year");
expect(temporalJumpBucketPrecisionForSort([{ field: "publishedYear", dir: "asc" }])).toBe("year");
});
it("uses full date precision for exact timestamps", () => {
for (const field of ["addedAt", "updatedAt", "lastReadAt", "startedAt", "finishedAt"] as const) {
expect(temporalJumpBucketPrecisionForSort([{ field, dir: "asc" }])).toBe("date");
}
});
it("returns null for non-temporal sorts", () => {
expect(temporalJumpBucketPrecisionForSort([{ field: "title", dir: "asc" }])).toBeNull();
});
});
+38 -2
View File
@@ -1,6 +1,13 @@
import type { SortField, SortSpec } from "./query";
import type { BookQuery, SortField, SortSpec } from "./query";
export type JumpBucketKind = "letter" | "year";
export type JumpBucketKind = "letter" | "temporal";
export type TemporalJumpBucketUnit = "day" | "month" | "year";
export type TemporalJumpBucketGranularity = {
unit: TemporalJumpBucketUnit;
step: number;
};
/**
* One jump target on the rail. `index` is the absolute 0-based row index of the
@@ -12,18 +19,42 @@ export type JumpBucket = {
key: string;
label: string;
index: number;
isUnknown?: boolean;
};
export type JumpBucketsResponse = {
buckets: JumpBucket[];
total: number;
kind: JumpBucketKind;
granularity: TemporalJumpBucketGranularity | null;
};
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",
};
/**
@@ -37,3 +68,8 @@ export function jumpBucketKindForSort(sort: SortSpec[]): JumpBucketKind | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return KIND_BY_PRIMARY_SORT_FIELD[primary.field] ?? 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;
}
@@ -1,4 +1,5 @@
import { BookRepository } from './book.repository';
import { PgDialect } from 'drizzle-orm/pg-core';
function makeSelectChain<T>(terminalMethod: string, terminalResult: T) {
const chain: Record<string, vi.Mock> = {
@@ -905,6 +906,67 @@ describe('BookRepository', () => {
expect(audioWhere).toHaveBeenCalledTimes(1);
});
describe('temporal jump buckets', () => {
const dialect = new PgDialect();
it('maps bounded temporal groups and the unknown tail without a full row sort', async () => {
const execute = vi.fn().mockResolvedValue({
rows: [
{ bucket: '2026-07', item_index: 0, total: 100_000, is_unknown: false, unit: 'month', step: 1 },
{ bucket: '__unknown__', item_index: 99_900, total: 100_000, is_unknown: true, unit: 'month', step: 1 },
],
});
const repo = new BookRepository({ execute } as never);
const result = await repo.findTemporalJumpBuckets({
where: undefined,
field: 'addedAt',
direction: 'desc',
precision: 'date',
userId: 7,
timeZone: 'America/Denver',
maxBuckets: 24,
});
expect(result).toEqual({
buckets: [
{ key: '2026-07', label: '2026-07', index: 0 },
{ key: '__unknown__', label: '__unknown__', index: 99_900, isUnknown: true },
],
total: 100_000,
kind: 'temporal',
granularity: { unit: 'month', step: 1 },
});
const query = dialect.sqlToQuery(execute.mock.calls[0]![0]).sql;
expect(query).toContain('temporal_rows AS MATERIALIZED');
expect(query).toContain('known_capacity');
expect(query).toContain('ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING');
expect(query).not.toContain('ROW_NUMBER()');
});
it('pre-aggregates per-user last-read values before collapsing series', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
const repo = new BookRepository({ execute } as never);
await repo.findTemporalJumpBucketsCollapsed({
where: undefined,
field: 'lastReadAt',
direction: 'asc',
precision: 'date',
userId: 9,
timeZone: 'UTC',
maxBuckets: 32,
});
const query = dialect.sqlToQuery(execute.mock.calls[0]![0]).sql;
expect(query).toContain('rail_last_read AS MATERIALIZED');
expect(query).toContain('GROUP BY rail_bf.book_id');
expect(query).toContain('base_rows AS MATERIALIZED');
expect(query).toContain('representatives AS');
expect(query).not.toContain('SELECT max(rp.updated_at)');
});
});
describe('bulkSetRating', () => {
it('does nothing for an empty book id list', async () => {
const db = { insert: vi.fn() };
+371 -1
View File
@@ -3,7 +3,14 @@ import { SQL, and, asc, count, eq, inArray, isNotNull, ne, or, sql } from 'drizz
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import type {
ContentFilterRules,
JumpBucketsResponse,
SortField,
SortSpec,
TemporalJumpBucketPrecision,
TemporalJumpBucketUnit,
} from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
@@ -133,6 +140,247 @@ type JumpBucketRawRow = {
total: number | string;
};
type TemporalJumpBucketRawRow = JumpBucketRawRow & {
is_unknown: boolean;
unit: TemporalJumpBucketUnit;
step: number | string;
};
type TemporalSourceParts = {
prefixCte: SQL;
join: SQL;
value: SQL;
baseExtraSelect: SQL;
representativeExtraSelect: SQL;
};
function temporalSourceParts(field: SortField, userId: number, timeZone: string, collapsed: boolean): TemporalSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): TemporalSourceParts => ({
prefixCte: empty,
join: empty,
value,
baseExtraSelect: empty,
representativeExtraSelect: empty,
});
if (collapsed) {
switch (field) {
case 'addedAt':
return direct(sql`timezone(${timeZone}, r.sort_added_at)`);
case 'updatedAt':
return direct(sql`timezone(${timeZone}, r.updated_at)`);
case 'publishedDate':
return direct(sql`coalesce(r.published_date, make_date(r.published_year, 1, 1))::timestamp`);
case 'publishedYear':
return direct(sql`make_date(r.published_year, 1, 1)::timestamp`);
case 'startedAt':
case 'finishedAt': {
const column = field === 'startedAt' ? sql.raw('rail_ubs.started_at') : sql.raw('rail_ubs.finished_at');
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`timezone(${timeZone}, r.rail_temporal_value)`,
baseExtraSelect: sql`, ${column} AS rail_temporal_value`,
representativeExtraSelect: sql`, base.rail_temporal_value`,
};
}
case 'lastReadAt':
return {
prefixCte: sql`rail_last_read AS MATERIALIZED (
SELECT rail_bf.book_id, max(rail_rp.updated_at) AS value
FROM ${readingProgress} rail_rp
INNER JOIN ${bookFiles} rail_bf ON rail_bf.id = rail_rp.book_file_id
WHERE rail_rp.user_id = ${userId}
GROUP BY rail_bf.book_id
),`,
join: sql`LEFT JOIN rail_last_read ON rail_last_read.book_id = books.id`,
value: sql`timezone(${timeZone}, r.rail_temporal_value)`,
baseExtraSelect: sql`, rail_last_read.value AS rail_temporal_value`,
representativeExtraSelect: sql`, base.rail_temporal_value`,
};
default:
return null;
}
}
switch (field) {
case 'addedAt':
return direct(sql`timezone(${timeZone}, ${books.addedAt})`);
case 'updatedAt':
return direct(sql`timezone(${timeZone}, ${books.updatedAt})`);
case 'publishedDate':
return direct(sql`coalesce(${bookMetadata.publishedDate}, make_date(${bookMetadata.publishedYear}, 1, 1))::timestamp`);
case 'publishedYear':
return direct(sql`make_date(${bookMetadata.publishedYear}, 1, 1)::timestamp`);
case 'startedAt':
case 'finishedAt': {
const column = field === 'startedAt' ? sql.raw('rail_ubs.started_at') : sql.raw('rail_ubs.finished_at');
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`timezone(${timeZone}, ${column})`,
baseExtraSelect: empty,
representativeExtraSelect: empty,
};
}
case 'lastReadAt':
return {
prefixCte: sql`rail_last_read AS MATERIALIZED (
SELECT rail_bf.book_id, max(rail_rp.updated_at) AS value
FROM ${readingProgress} rail_rp
INNER JOIN ${bookFiles} rail_bf ON rail_bf.id = rail_rp.book_file_id
WHERE rail_rp.user_id = ${userId}
GROUP BY rail_bf.book_id
),`,
join: sql`LEFT JOIN rail_last_read ON rail_last_read.book_id = ${books.id}`,
value: sql`timezone(${timeZone}, rail_last_read.value)`,
baseExtraSelect: empty,
representativeExtraSelect: empty,
};
default:
return null;
}
}
function temporalBucketsQuery(opts: {
prefixCte: SQL;
temporalRows: SQL;
precision: TemporalJumpBucketPrecision;
direction: 'asc' | 'desc';
maxBuckets: number;
}): SQL {
const direction = sql.raw(opts.direction === 'desc' ? 'DESC' : 'ASC');
const unit =
opts.precision === 'year'
? sql`'year'::text`
: sql`CASE
WHEN day_span <= known_capacity THEN 'day'
WHEN month_span <= known_capacity THEN 'month'
ELSE 'year'
END`;
return sql`
WITH ${opts.prefixCte}
temporal_rows AS MATERIALIZED (${opts.temporalRows}),
bounds AS (
SELECT
min(value::date) AS min_date,
max(value::date) AS max_date,
count(*)::int AS total,
count(value)::int AS known_total,
(count(DISTINCT extract(year FROM value)) FILTER (WHERE value IS NOT NULL))::int AS known_year_count
FROM temporal_rows
),
spans AS (
SELECT
bounds.*,
GREATEST(${opts.maxBuckets} - CASE WHEN total > known_total THEN 1 ELSE 0 END, 2)::int AS known_capacity,
COALESCE((max_date - min_date) + 1, 1)::int AS day_span,
COALESCE(
((extract(year FROM max_date)::int - extract(year FROM min_date)::int) * 12)
+ extract(month FROM max_date)::int - extract(month FROM min_date)::int + 1,
1
)::int AS month_span,
COALESCE(extract(year FROM max_date)::int - extract(year FROM min_date)::int + 1, 1)::int AS year_span
FROM bounds
),
resolution_seed AS (
SELECT spans.*, ${unit} AS unit
FROM spans
),
resolution_raw AS (
SELECT
resolution_seed.*,
CASE
WHEN unit <> 'year' OR year_span <= known_capacity OR known_year_count <= known_capacity THEN 1
ELSE GREATEST(1, ceil(year_span::numeric / GREATEST(known_capacity - 1, 1))::int)
END AS raw_year_step
FROM resolution_seed
),
resolution_scale AS (
SELECT
resolution_raw.*,
power(10::numeric, floor(log(GREATEST(raw_year_step, 1)::numeric)))::int AS step_scale
FROM resolution_raw
),
resolution AS (
SELECT
min_date,
max_date,
total,
known_total,
known_capacity,
unit,
CASE
WHEN raw_year_step <= step_scale THEN step_scale
WHEN raw_year_step <= step_scale * 2 THEN step_scale * 2
WHEN raw_year_step <= step_scale * 5 THEN step_scale * 5
ELSE step_scale * 10
END::int AS step
FROM resolution_scale
),
bucketed AS (
SELECT
temporal_rows.value,
resolution.unit,
resolution.step,
CASE resolution.unit
WHEN 'day' THEN to_char(temporal_rows.value::date, 'YYYY-MM-DD')
WHEN 'month' THEN to_char(date_trunc('month', temporal_rows.value)::date, 'YYYY-MM')
ELSE (floor(extract(year FROM temporal_rows.value)::numeric / resolution.step) * resolution.step)::int::text
END AS bucket
FROM temporal_rows
CROSS JOIN resolution
WHERE temporal_rows.value IS NOT NULL
),
known_groups AS (
SELECT bucket, min(value) AS bucket_sort, count(*)::int AS item_count, min(unit) AS unit, min(step)::int AS step
FROM bucketed
GROUP BY bucket
),
known_ranked AS (
SELECT
bucket,
COALESCE(
sum(item_count) OVER (
ORDER BY bucket_sort ${direction}
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
)::int AS item_index,
unit,
step
FROM known_groups
),
result_rows AS (
SELECT
known_ranked.bucket,
known_ranked.item_index,
bounds.total,
false AS is_unknown,
known_ranked.unit,
known_ranked.step
FROM known_ranked
CROSS JOIN bounds
UNION ALL
SELECT
'__unknown__' AS bucket,
bounds.known_total AS item_index,
bounds.total,
true AS is_unknown,
resolution.unit,
resolution.step
FROM bounds
CROSS JOIN resolution
WHERE bounds.total > bounds.known_total
)
SELECT bucket, item_index, total, is_unknown, unit, step
FROM result_rows
ORDER BY item_index
`;
}
@Injectable()
export class BookRepository {
constructor(
@@ -661,6 +909,39 @@ export class BookRepository {
return this.mapJumpBucketRows(result.rows);
}
async findTemporalJumpBuckets(opts: {
where: SQL | undefined;
field: SortField;
direction: 'asc' | 'desc';
precision: TemporalJumpBucketPrecision;
userId: number;
timeZone: string;
maxBuckets: number;
}): Promise<JumpBucketsResponse> {
const source = temporalSourceParts(opts.field, opts.userId, opts.timeZone, false);
if (!source) return { buckets: [], total: 0, kind: 'temporal', granularity: null };
const visibleWhere = this.visibleWhere(opts.where);
const temporalRows = sql`
SELECT ${source.value} AS value
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
${source.join}
WHERE ${visibleWhere}
`;
const result = await this.db.execute<TemporalJumpBucketRawRow>(
temporalBucketsQuery({
prefixCte: source.prefixCte,
temporalRows,
precision: opts.precision,
direction: opts.direction,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapTemporalJumpBucketRows(result.rows);
}
// The representatives CTE mirrors findCardsCollapsed's representative
// selection (same group key, same pick order) but only projects the columns
// buildCollapseOrderBy can reference as aliases or via r.* correlated
@@ -736,6 +1017,78 @@ export class BookRepository {
return this.mapJumpBucketRows(result.rows);
}
async findTemporalJumpBucketsCollapsed(opts: {
where: SQL | undefined;
field: SortField;
direction: 'asc' | 'desc';
precision: TemporalJumpBucketPrecision;
userId: number;
timeZone: string;
maxBuckets: number;
}): Promise<JumpBucketsResponse> {
const source = temporalSourceParts(opts.field, opts.userId, opts.timeZone, true);
if (!source) return { buckets: [], total: 0, kind: 'temporal', granularity: null };
const whereFragment = this.visibleWhere(opts.where);
const empty = sql.raw('');
const usesSeriesLatest = opts.field === 'addedAt';
const seriesLatestCte = usesSeriesLatest
? sql`series_latest AS (
SELECT base.series_id, base.library_id, max(base.added_at) AS latest_added_at
FROM base_rows base
WHERE base.series_id IS NOT NULL
GROUP BY base.series_id, base.library_id
),`
: empty;
const seriesLatestJoin = usesSeriesLatest
? sql`LEFT JOIN series_latest sl ON sl.series_id = base.series_id AND sl.library_id = base.library_id`
: empty;
const sortAddedAt = usesSeriesLatest ? sql`coalesce(sl.latest_added_at, base.added_at)` : sql`base.added_at`;
const prefixCte = sql`
${source.prefixCte}
base_rows AS MATERIALIZED (
SELECT
${books.id} AS id,
${books.libraryId} AS library_id,
${books.addedAt} AS added_at,
${books.updatedAt} AS updated_at,
${bookMetadata.seriesId} AS series_id,
${bookMetadata.seriesIndex} AS series_index,
${bookMetadata.publishedDate} AS published_date,
${bookMetadata.publishedYear} AS published_year
${source.baseExtraSelect}
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
${source.join}
WHERE ${whereFragment}
),
${seriesLatestCte}
representatives AS (
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
base.id,
base.updated_at,
base.published_date,
base.published_year,
${sortAddedAt} AS sort_added_at
${source.representativeExtraSelect}
FROM base_rows base
${seriesLatestJoin}
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
),
`;
const result = await this.db.execute<TemporalJumpBucketRawRow>(
temporalBucketsQuery({
prefixCte,
temporalRows: sql`SELECT ${source.value} AS value FROM representatives r`,
precision: opts.precision,
direction: opts.direction,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapTemporalJumpBucketRows(result.rows);
}
private mapJumpBucketRows(rows: JumpBucketRawRow[]): JumpBucketsResponse {
return {
buckets: rows.map((row) => ({
@@ -744,6 +1097,23 @@ export class BookRepository {
index: Number(row.item_index),
})),
total: rows.length > 0 ? Number(rows[0].total) : 0,
kind: 'letter',
granularity: null,
};
}
private mapTemporalJumpBucketRows(rows: TemporalJumpBucketRawRow[]): JumpBucketsResponse {
const first = rows[0];
return {
buckets: rows.map((row) => ({
key: row.bucket,
label: row.bucket,
index: Number(row.item_index),
...(row.is_unknown ? { isUnknown: true } : {}),
})),
total: first ? Number(first.total) : 0,
kind: 'temporal',
granularity: first ? { unit: first.unit, step: Number(first.step) } : null,
};
}
+31 -10
View File
@@ -144,6 +144,8 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
findCardsCollapsed: vi.fn(),
findJumpBuckets: vi.fn(),
findJumpBucketsCollapsed: vi.fn(),
findTemporalJumpBuckets: vi.fn(),
findTemporalJumpBucketsCollapsed: vi.fn(),
checkBookPassesContentFilters: vi.fn().mockResolvedValue(true),
};
const libraryService = {
@@ -2603,18 +2605,24 @@ describe('BookService', () => {
const user = makeUser({ id: 42 });
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 12 });
bookRepo.findJumpBuckets.mockResolvedValue({
buckets: [{ key: 'A', label: 'A', index: 0 }],
total: 12,
kind: 'letter',
granularity: null,
});
const result = await service.queryJumpBucketsForLibrary(user, 7, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
} as never);
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.findJumpBucketsCollapsed).not.toHaveBeenCalled();
expect(result).toEqual({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 12 });
expect(result).toEqual({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 12, kind: 'letter', granularity: null });
});
it('executeJumpBucketsQuery rejects ineligible sorts with BadRequestException', async () => {
@@ -2622,28 +2630,38 @@ describe('BookService', () => {
await expect(
service.executeJumpBucketsQuery(1, undefined, {
sort: [{ field: 'addedAt', dir: 'desc' }],
sort: [{ field: 'rating', dir: 'desc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(bookRepo.findJumpBuckets).not.toHaveBeenCalled();
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery supports author and publishedYear sorts in both directions', async () => {
it('executeJumpBucketsQuery routes letter 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 });
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
bookRepo.findTemporalJumpBuckets.mockResolvedValue({
buckets: [],
total: 0,
kind: 'temporal',
granularity: null,
});
for (const sort of [[{ field: 'author', dir: 'desc' }], [{ field: 'publishedYear', dir: 'asc' }], [{ field: 'title', dir: 'desc' }], []]) {
await service.executeJumpBucketsQuery(1, undefined, { sort, pagination: { page: 0, size: 50 } } as never);
await service.executeJumpBucketsQuery(1, undefined, { sort, pagination: { page: 0, size: 50 }, maxBuckets: 24 } as never);
}
expect(bookRepo.findJumpBuckets).toHaveBeenCalledTimes(4);
expect(bookRepo.findJumpBuckets).toHaveBeenCalledTimes(3);
expect(bookRepo.findTemporalJumpBuckets).toHaveBeenCalledWith(
expect.objectContaining({ field: 'publishedYear', direction: 'asc', precision: 'year', maxBuckets: 24 }),
);
});
it('executeJumpBucketsQuery routes to the collapsed variant when collapseSeries is set', async () => {
const { service, bookRepo } = makeService();
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0 });
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(
9,
@@ -2651,6 +2669,7 @@ describe('BookService', () => {
{
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
} as never,
);
@@ -2664,11 +2683,12 @@ describe('BookService', () => {
it('executeJumpBucketsQuery ignores collapseSeries when the filter targets a series', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0 });
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(9, undefined, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'series', operator: 'eq', value: 'Dune' }] },
} as never);
@@ -2679,11 +2699,12 @@ describe('BookService', () => {
it('executeJumpBucketsQuery keeps collapseSeries for a series presence filter', async () => {
const { service, bookRepo } = makeService();
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0 });
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(9, undefined, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'series', operator: 'isNotEmpty' }] },
} as never);
+31 -10
View File
@@ -37,6 +37,7 @@ import {
isAudioFormat,
jumpBucketKindForSort,
resolveUploadPath,
temporalJumpBucketPrecisionForSort,
} from '@bookorbit/types';
import type {
AudiobookChapter,
@@ -50,6 +51,7 @@ import type {
BooksPage,
FileRenameResult,
JumpBucketsResponse,
JumpBucketsQuery,
MetadataFetchDiagnostics,
MetadataField,
ReadStatus,
@@ -1110,7 +1112,7 @@ export class BookService {
return result;
}
async queryJumpBucketsForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<JumpBucketsResponse> {
async queryJumpBucketsForLibrary(user: RequestUser, libraryId: number, query: JumpBucketsQuery): Promise<JumpBucketsResponse> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const timeZone = this.resolveUserTimeZone(user);
const where = this.queryBuilder.buildWhere(query.filter, {
@@ -1121,24 +1123,43 @@ export class BookService {
timeZone,
contentFilters: this.isSuperuser(user) ? undefined : user.contentFilters,
});
return this.executeJumpBucketsQuery(user.id, where, query);
return this.executeJumpBucketsQuery(user.id, where, query, timeZone);
}
async executeJumpBucketsQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<JumpBucketsResponse> {
async executeJumpBucketsQuery(userId: number, where: SQL | undefined, query: JumpBucketsQuery, timeZone = 'UTC'): Promise<JumpBucketsResponse> {
const event = 'book.jump_buckets';
const kind = jumpBucketKindForSort(query.sort);
const primaryField = (query.sort[0] ?? { field: 'title', dir: 'asc' }).field;
const primary = query.sort[0] ?? { field: 'title' as const, dir: 'asc' as const };
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesSelectionFilter(query.filter);
const bucketExpr = shouldCollapse ? collapsedJumpBucketExpr(primaryField) : flatJumpBucketExpr(primaryField);
if (!kind || !bucketExpr) throw new BadRequestException('jump buckets are not available for this sort');
if (!kind) throw new BadRequestException('jump buckets are not available for this sort');
const start = Date.now();
try {
const 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) });
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');
const temporalOpts = {
where,
field: primary.field,
direction: primary.dir,
precision,
userId,
timeZone,
maxBuckets: query.maxBuckets,
};
response = shouldCollapse
? 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');
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) });
}
this.logger.log(
`[${event}] [end] userId=${userId} kind=${kind} collapse=${shouldCollapse} durationMs=${Date.now() - start} bucketCount=${response.buckets.length} total=${response.total} - jump buckets computed`,
`[${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`,
);
return response;
} catch (err) {
@@ -25,27 +25,13 @@ describe('jump-bucket-expr', () => {
expect(text).toContain("~ '^[A-Z]$'");
});
it('builds a year expression for flat publishedYear sort', () => {
const text = toSqlText(flatJumpBucketExpr('publishedYear'));
expect(text).toBe('"book_metadata"."published_year"::text');
});
it('builds a year expression for flat publishedDate sort using the year fallback', () => {
const text = toSqlText(flatJumpBucketExpr('publishedDate'));
expect(text).toContain('extract(year from coalesce("book_metadata"."published_date", make_date("book_metadata"."published_year", 1, 1)))');
});
it('builds collapsed expressions over representative aliases', () => {
expect(toSqlText(collapsedJumpBucketExpr('title'))).toContain('r.sort_title');
expect(toSqlText(collapsedJumpBucketExpr('author'))).toContain('r.author_sort_name');
expect(toSqlText(collapsedJumpBucketExpr('publishedDate'))).toContain(
'extract(year from coalesce(r.published_date, make_date(r.published_year, 1, 1)))',
);
expect(toSqlText(collapsedJumpBucketExpr('publishedYear'))).toBe('r.published_year::text');
});
it('returns null for sort fields without bucket support', () => {
const ineligible: SortField[] = ['addedAt', 'rating', 'fileSize', 'random', 'series'];
const ineligible: SortField[] = ['addedAt', 'publishedDate', 'publishedYear', 'rating', 'fileSize', 'random', 'series'];
for (const field of ineligible) {
expect(flatJumpBucketExpr(field)).toBeNull();
expect(collapsedJumpBucketExpr(field)).toBeNull();
+1 -9
View File
@@ -6,7 +6,7 @@ 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 an expression here for both the flat and collapsed shapes.
// there needs a letter expression here for both the flat and collapsed shapes.
function letterExpr(column: SQL): SQL {
return sql`
CASE
@@ -23,10 +23,6 @@ export function flatJumpBucketExpr(field: SortField): SQL | null {
return letterExpr(sql`${bookMetadata.title}`);
case 'author':
return letterExpr(sql`${books.primaryAuthorSortName}`);
case 'publishedDate':
return sql`extract(year from coalesce(${bookMetadata.publishedDate}, make_date(${bookMetadata.publishedYear}, 1, 1)))::int::text`;
case 'publishedYear':
return sql`${bookMetadata.publishedYear}::text`;
default:
return null;
}
@@ -38,10 +34,6 @@ export function collapsedJumpBucketExpr(field: SortField): SQL | null {
return letterExpr(sql.raw('r.sort_title'));
case 'author':
return letterExpr(sql.raw('r.author_sort_name'));
case 'publishedDate':
return sql.raw('extract(year from coalesce(r.published_date, make_date(r.published_year, 1, 1)))::int::text');
case 'publishedYear':
return sql.raw('r.published_year::text');
default:
return null;
}
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { BookQueryPipe } from './book-query.pipe';
import { BookQueryPipe, JumpBucketsQueryPipe } from './book-query.pipe';
describe('BookQueryPipe', () => {
let pipe: BookQueryPipe;
@@ -155,3 +155,21 @@ describe('BookQueryPipe', () => {
expect(() => pipe.transform({ pagination: { page: 6000, size: 200 } })).toThrow(BadRequestException);
});
});
describe('JumpBucketsQueryPipe', () => {
const pipe = new JumpBucketsQueryPipe();
it('defaults to a bounded bucket count', () => {
expect(pipe.transform({}).maxBuckets).toBe(32);
});
it('accepts bucket counts within the supported range', () => {
expect(pipe.transform({ maxBuckets: 8 }).maxBuckets).toBe(8);
expect(pipe.transform({ maxBuckets: 64 }).maxBuckets).toBe(64);
});
it('rejects bucket counts outside the supported range', () => {
expect(() => pipe.transform({ maxBuckets: 7 })).toThrow(BadRequestException);
expect(() => pipe.transform({ maxBuckets: 65 })).toThrow(BadRequestException);
});
});
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { z } from 'zod';
import { SORT_FIELDS, type BookQuery } from '@bookorbit/types';
import { SORT_FIELDS, type BookQuery, type JumpBucketsQuery } from '@bookorbit/types';
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../../common/constants/pagination.constants';
import { groupRuleSchema } from '../utils/group-rule.validator';
@@ -36,6 +36,10 @@ const bookQuerySchema = z.object({
.default({ page: 0, size: 50 }),
});
const jumpBucketsQuerySchema = bookQuerySchema.extend({
maxBuckets: z.number().int().min(8).max(64).default(32),
});
@Injectable()
export class BookQueryPipe implements PipeTransform {
transform(value: unknown): BookQuery {
@@ -44,3 +48,12 @@ export class BookQueryPipe implements PipeTransform {
return result.data as BookQuery;
}
}
@Injectable()
export class JumpBucketsQueryPipe implements PipeTransform {
transform(value: unknown): JumpBucketsQuery {
const result = jumpBucketsQuerySchema.safeParse(value ?? {});
if (!result.success) throw new BadRequestException(result.error.flatten());
return result.data as JumpBucketsQuery;
}
}
@@ -16,12 +16,12 @@ import {
} from '@nestjs/common';
import { AuditAction, AuditResource } from '@bookorbit/types';
import type { BookQuery } from '@bookorbit/types';
import type { BookQuery, JumpBucketsQuery } from '@bookorbit/types';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookQueryPipe, JumpBucketsQueryPipe } from '../book/pipes/book-query.pipe';
import { CollectionBooksDto } from './dto/collection-books.dto';
import { CreateCollectionDto } from './dto/create-collection.dto';
import { ReorderCollectionsDto } from './dto/reorder-collections.dto';
@@ -177,7 +177,7 @@ export class CollectionController {
}
@Post(':id/books/jump-buckets')
queryJumpBuckets(@Param('id', ParseIntPipe) id: number, @Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
queryJumpBuckets(@Param('id', ParseIntPipe) id: number, @Body(JumpBucketsQueryPipe) query: JumpBucketsQuery, @CurrentUser() user: RequestUser) {
return this.collectionService.queryJumpBuckets(id, user, query);
}
}
@@ -457,13 +457,13 @@ describe('CollectionService', () => {
libraryService.findAccessibleLibraryIds.mockResolvedValue([100]);
collectionRepo.buildMembershipWhere.mockReturnValue('membership-where');
queryBuilder.buildWhere.mockReturnValue('filter-where');
bookService.executeJumpBucketsQuery.mockResolvedValue({ buckets: [], total: 0 });
bookService.executeJumpBucketsQuery.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
const query = { sort: [{ field: 'title', dir: 'asc' as const }], pagination: { page: 0, size: 50 } };
await service.queryJumpBuckets(10, makeUser(), query as never);
expect(collectionRepo.buildMembershipWhere).toHaveBeenCalledWith(10);
expect(bookService.executeJumpBucketsQuery).toHaveBeenCalledWith(1, expect.anything(), query);
expect(bookService.executeJumpBucketsQuery).toHaveBeenCalledWith(1, expect.anything(), query, 'UTC');
});
it('queryJumpBuckets propagates ownership errors before delegating', async () => {
@@ -1,7 +1,7 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { and, type SQL } from 'drizzle-orm';
import type { BookQuery, BooksPage, JumpBucketsResponse } from '@bookorbit/types';
import type { BookQuery, BooksPage, JumpBucketsQuery, JumpBucketsResponse } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
import type { RequestUser } from '../../common/types/request-user';
@@ -247,9 +247,10 @@ export class CollectionService {
}
}
async queryJumpBuckets(id: number, user: RequestUser, query: BookQuery): Promise<JumpBucketsResponse> {
async queryJumpBuckets(id: number, user: RequestUser, query: JumpBucketsQuery): Promise<JumpBucketsResponse> {
const where = await this.buildBooksWhere(id, user, query);
return this.bookService.executeJumpBucketsQuery(user.id, where, query);
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
return this.bookService.executeJumpBucketsQuery(user.id, where, query, timeZone);
}
private async buildBooksWhere(id: number, user: RequestUser, query: BookQuery): Promise<SQL | undefined> {
@@ -2,13 +2,13 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPip
import type { FastifyReply } from 'fastify';
import { Permission, AuditAction, AuditResource } from '@bookorbit/types';
import type { BookQuery, BulkRenameProgressEvent, LibraryFileSyncProgressEvent } from '@bookorbit/types';
import type { BookQuery, BulkRenameProgressEvent, JumpBucketsQuery, LibraryFileSyncProgressEvent } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequireLibraryAccess } from '../../common/decorators/require-library-access.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookQueryPipe, JumpBucketsQueryPipe } from '../book/pipes/book-query.pipe';
import { BookService } from '../book/book.service';
import { BulkRenamePreviewQueryDto } from './dto/bulk-rename-preview-query.dto';
import { CreateLibraryDto } from './dto/create-library.dto';
@@ -47,7 +47,11 @@ export class LibraryController {
@Post(':id/books/jump-buckets')
@RequireLibraryAccess('viewer')
queryJumpBuckets(@Param('id', ParseIntPipe) libraryId: number, @Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
queryJumpBuckets(
@Param('id', ParseIntPipe) libraryId: number,
@Body(JumpBucketsQueryPipe) query: JumpBucketsQuery,
@CurrentUser() user: RequestUser,
) {
return this.bookService.queryJumpBucketsForLibrary(user, libraryId, query);
}
@@ -15,12 +15,12 @@ import {
} from '@nestjs/common';
import { AuditAction, AuditResource } from '@bookorbit/types';
import type { BookQuery } from '@bookorbit/types';
import type { BookQuery, JumpBucketsQuery } from '@bookorbit/types';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookQueryPipe, JumpBucketsQueryPipe } from '../book/pipes/book-query.pipe';
import { CreateSmartScopeDto } from './dto/create-smart-scope.dto';
import { ReorderSmartScopesDto } from './dto/reorder-smart-scopes.dto';
import { UpdateSmartScopeDto } from './dto/update-smart-scope.dto';
@@ -120,7 +120,7 @@ export class SmartScopeController {
}
@Post(':id/books/jump-buckets')
queryJumpBuckets(@Param('id', ParseIntPipe) id: number, @Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
queryJumpBuckets(@Param('id', ParseIntPipe) id: number, @Body(JumpBucketsQueryPipe) query: JumpBucketsQuery, @CurrentUser() user: RequestUser) {
return this.smartScopeService.queryJumpBuckets(id, user, query);
}
}
@@ -389,7 +389,7 @@ describe('SmartScopeService', () => {
});
expect(bookService.executeJumpBucketsQuery).not.toHaveBeenCalled();
expect(result).toEqual({ buckets: [], total: 0 });
expect(result).toEqual({ buckets: [], total: 0, kind: 'letter', granularity: null });
});
it('resolves the scope default sort before delegating so eligibility is checked post-resolution', async () => {
@@ -403,7 +403,12 @@ describe('SmartScopeService', () => {
smartScopeRepo.findById.mockResolvedValue([smartScope]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([9]);
queryBuilder.buildWhere.mockReturnValue('where');
bookService.executeJumpBucketsQuery.mockResolvedValue({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 3 });
bookService.executeJumpBucketsQuery.mockResolvedValue({
buckets: [{ key: 'A', label: 'A', index: 0 }],
total: 3,
kind: 'letter',
granularity: null,
});
const result = await service.queryJumpBuckets(5, makeUser({ id: 12 }), {
sort: [],
@@ -414,8 +419,9 @@ describe('SmartScopeService', () => {
12,
'where',
expect.objectContaining({ sort: [{ field: 'author', dir: 'desc' }] }),
'UTC',
);
expect(result).toEqual({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 3 });
expect(result).toEqual({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 3, kind: 'letter', granularity: null });
});
it('denies access to private scopes of other users', async () => {
@@ -1,7 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { SQL } from 'drizzle-orm';
import type { BookQuery, BooksPage, GroupRule, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import type { BookQuery, BooksPage, GroupRule, JumpBucketsQuery, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { normalizeIconValue } from '../../common/utils/icon-value.utils';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
@@ -182,21 +182,22 @@ export class SmartScopeService {
}
}
async queryJumpBuckets(id: number, user: RequestUser, query: BookQuery): Promise<JumpBucketsResponse> {
async queryJumpBuckets(id: number, user: RequestUser, query: JumpBucketsQuery): Promise<JumpBucketsResponse> {
const prepared = await this.prepareBooksQuery(id, user, query);
if (!prepared) {
return { buckets: [], total: 0 };
return { buckets: [], total: 0, kind: 'letter', granularity: null };
}
// Eligibility is validated by the book service against effectiveQuery.sort,
// i.e. after the scope's defaultSort has been resolved.
return this.bookService.executeJumpBucketsQuery(user.id, prepared.where, prepared.effectiveQuery);
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
return this.bookService.executeJumpBucketsQuery(user.id, prepared.where, prepared.effectiveQuery, timeZone);
}
private async prepareBooksQuery(
private async prepareBooksQuery<T extends BookQuery>(
id: number,
user: RequestUser,
query: BookQuery,
): Promise<{ where: SQL | undefined; effectiveQuery: BookQuery } | null> {
query: T,
): Promise<{ where: SQL | undefined; effectiveQuery: T } | null> {
const smartScope = await this.getSmartScopeOrThrow(id);
this.assertReadAccess(smartScope, user);
@@ -205,7 +206,7 @@ export class SmartScopeService {
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const filter = this.combineFilters(smartScope.filter, query.filter);
const effectiveQuery: BookQuery = {
const effectiveQuery: T = {
...query,
filter,
sort: this.resolveSort(query.sort, smartScope),
+123 -10
View File
@@ -1,7 +1,8 @@
import type { BookCard, BooksPage, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import type { BookCard, BooksPage, JumpBucketsResponse, SortSpec, TemporalJumpBucketGranularity } from '@bookorbit/types';
import { eq } from 'drizzle-orm';
import { refreshPrimaryAuthorSortNamesForBooks } from '../src/db/book-author-sort-key';
import { bookAuthors, bookMetadata, books, bookSeries, authors } from '../src/db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookSeries, readingProgress, userBookStatus, users } from '../src/db/schema';
import {
authHeader,
closeMetadataWriteE2EContext,
@@ -19,6 +20,11 @@ type SeededBook = {
authorSortName: string | null;
publishedYear: number | null;
seriesName: string | null;
addedAt: string;
updatedAt: string;
lastReadAt: string | null;
startedAt: string | null;
finishedAt: string | null;
};
// Mirrors the SQL letter bucket expression: empty -> null, A-Z -> letter, else '#'.
@@ -29,13 +35,30 @@ function letterOf(value: string | null): string | null {
return /^[A-Z]$/.test(first) ? first : '#';
}
function expectedBucketKey(book: SeededBook, field: 'title' | 'author' | 'publishedYear', collapsed: boolean): string | null {
if (field === 'publishedYear') return book.publishedYear !== null ? String(book.publishedYear) : null;
function expectedBucketKey(
book: SeededBook,
field: 'title' | 'author' | 'publishedYear',
collapsed: boolean,
granularity: TemporalJumpBucketGranularity | null,
): string | null {
if (field === 'publishedYear') {
if (book.publishedYear === null) return '__unknown__';
const step = granularity?.step ?? 1;
return String(Math.floor(book.publishedYear / step) * step);
}
if (field === 'author') return letterOf(book.authorSortName);
if (collapsed) return letterOf(book.seriesName ?? book.title);
return letterOf(book.title);
}
function expectedTemporalBucketKey(value: string | null, granularity: TemporalJumpBucketGranularity | null): string {
if (value === null) return '__unknown__';
if (!granularity || granularity.unit === 'day') return value;
if (granularity.unit === 'month') return value.slice(0, 7);
const year = Number(value.slice(0, 4));
return String(Math.floor(year / granularity.step) * granularity.step);
}
describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: MetadataWriteE2EContext;
let libraryId!: number;
@@ -59,6 +82,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
folderPath: `/seed/${Math.random().toString(36).slice(2)}`,
status: 'present',
addedAt: input.addedAt,
updatedAt: input.addedAt,
})
.returning({ id: books.id });
@@ -200,10 +224,63 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
authorSortName: spec.author ? (spec.author.sortName ?? spec.author.name) : null,
publishedYear: spec.publishedYear ?? null,
seriesName: spec.series?.name ?? null,
addedAt: spec.addedAt,
updatedAt: spec.addedAt,
lastReadAt: null,
startedAt: null,
finishedAt: null,
});
}
await refreshPrimaryAuthorSortNamesForBooks(ctx.db, allIds);
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 statusRows = allIds.flatMap((bookId, index) => {
if (index % 5 === 0) return [];
const day = String(index + 1).padStart(2, '0');
const startedAt = `2025-03-${day}`;
const finishedAt = `2025-04-${day}`;
const seeded = seededById.get(bookId)!;
seeded.startedAt = startedAt;
seeded.finishedAt = finishedAt;
return [
{
userId: admin.id,
bookId,
status: 'read' as const,
source: 'manual' as const,
startedAt: new Date(`${startedAt}T12:00:00Z`),
finishedAt: new Date(`${finishedAt}T12:00:00Z`),
},
];
});
await ctx.db.insert(userBookStatus).values(statusRows);
const progressBookIds = allIds.filter((_, index) => index % 5 !== 0);
const insertedFiles = await ctx.db
.insert(bookFiles)
.values(
progressBookIds.map((bookId, index) => ({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `/seed/progress-${bookId}.epub`,
relPath: `progress-${bookId}.epub`,
ino: BigInt(900_000 + index),
format: 'epub',
role: 'content',
})),
)
.returning({ id: bookFiles.id, bookId: bookFiles.bookId });
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`) };
}),
);
});
afterAll(async () => {
@@ -215,7 +292,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
method: 'POST',
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
headers: authHeader(ctx.adminToken),
payload: { sort, pagination: { page: 0, size: 50 }, ...(collapseSeries ? { collapseSeries: true } : {}) },
payload: { sort, pagination: { page: 0, size: 50 }, maxBuckets: 32, ...(collapseSeries ? { collapseSeries: true } : {}) },
});
expect(response.statusCode).toBe(201);
return response.json() as JumpBucketsResponse;
@@ -246,6 +323,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
expect(listing.items.length).toBe(listing.total);
expect(bucketsResponse.total).toBe(listing.total);
expect(bucketsResponse.buckets.length).toBeGreaterThan(1);
expect(bucketsResponse.buckets.length).toBeLessThanOrEqual(32);
let previousIndex = -1;
for (const bucket of bucketsResponse.buckets) {
@@ -256,16 +334,17 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
const itemAtAnchor = listing.items[bucket.index] as BookCard;
const seeded = seededById.get(itemAtAnchor.id);
expect(seeded, `listing item ${itemAtAnchor.id} at index ${bucket.index} must be a seeded book`).toBeDefined();
expect(expectedBucketKey(seeded!, field, collapsed), `bucket ${bucket.key} -> book ${itemAtAnchor.id} (${seeded!.title})`).toBe(
bucket.key,
);
expect(
expectedBucketKey(seeded!, field, collapsed, bucketsResponse.granularity),
`bucket ${bucket.key} -> book ${itemAtAnchor.id} (${seeded!.title})`,
).toBe(bucket.key);
if (bucket.index > 0) {
const itemBefore = listing.items[bucket.index - 1] as BookCard;
const seededBefore = seededById.get(itemBefore.id);
expect(seededBefore).toBeDefined();
expect(
expectedBucketKey(seededBefore!, field, collapsed),
expectedBucketKey(seededBefore!, field, collapsed, bucketsResponse.granularity),
`item before bucket ${bucket.key} must belong to a different bucket`,
).not.toBe(bucket.key);
}
@@ -275,12 +354,46 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
}
}
it('preserves every populated year when sparse years fit the bounded capacity', async () => {
const response = await fetchBuckets([{ field: 'publishedYear', dir: 'asc' }], false);
const expectedYears = [...new Set([...seededById.values()].flatMap((book) => (book.publishedYear === null ? [] : [String(book.publishedYear)])))];
const returnedKeys = new Set(response.buckets.map((bucket) => bucket.key));
expect(response.granularity).toEqual({ unit: 'year', step: 1 });
for (const year of expectedYears) expect(returnedKeys.has(year)).toBe(true);
});
const temporalFields = ['addedAt', 'updatedAt', 'lastReadAt', 'startedAt', 'finishedAt'] as const;
for (const field of temporalFields) {
for (const dir of dirs) {
it(`anchors ${field} ${dir} temporal buckets to exact flat-listing offsets`, async () => {
const sort: SortSpec[] = [{ field, dir }];
const [bucketsResponse, listing] = await Promise.all([fetchBuckets(sort, false), fetchListing(sort, false)]);
expect(bucketsResponse.kind).toBe('temporal');
expect(bucketsResponse.buckets.length).toBeLessThanOrEqual(32);
expect(bucketsResponse.total).toBe(listing.total);
for (const bucket of bucketsResponse.buckets) {
const itemAtAnchor = listing.items[bucket.index] as BookCard;
const seeded = seededById.get(itemAtAnchor.id)!;
expect(expectedTemporalBucketKey(seeded[field], bucketsResponse.granularity)).toBe(bucket.key);
if (bucket.index > 0) {
const itemBefore = listing.items[bucket.index - 1] as BookCard;
const seededBefore = seededById.get(itemBefore.id)!;
expect(expectedTemporalBucketKey(seededBefore[field], bucketsResponse.granularity)).not.toBe(bucket.key);
}
}
});
}
}
it('rejects ineligible sorts with 400', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
headers: authHeader(ctx.adminToken),
payload: { sort: [{ field: 'addedAt', dir: 'desc' }], pagination: { page: 0, size: 50 } },
payload: { sort: [{ field: 'rating', dir: 'desc' }], pagination: { page: 0, size: 50 }, maxBuckets: 32 },
});
expect(response.statusCode).toBe(400);
});