mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user