fix(books): reshuffle random sort per visit instead of per day

Seed the random sort field from a per-view randomSeed on BookQuery rather
than a day counter, so a reload or a scope change draws a new order while
every page of one listing stays coherent. Callers that omit the seed keep
the daily fallback. Adds a shuffle button to the library, smart scope, and
collection toolbars when the active sort is random.

Closes #1153
This commit is contained in:
neon
2026-08-26 15:25:46 +00:00
parent d587d8f2a8
commit 4c42e0c73f
17 changed files with 297 additions and 26 deletions
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Shuffle } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{
/** Icon only, for the dense desktop toolbar. */
compact?: boolean
/** Hide below the `sm` breakpoint, where the mobile control sheet carries its own copy. */
desktopOnly?: boolean
}>()
const emit = defineEmits<{ shuffle: [] }>()
const { t } = useI18n()
const visibility = computed(() => (props.desktopOnly ? 'hidden sm:flex' : 'flex'))
function handleShuffle() {
emit('shuffle')
}
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
:aria-label="t('views.bookView.shuffleAria')"
class="h-8 items-center justify-center gap-1.5 rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:text-foreground"
:class="[visibility, compact ? 'w-8' : 'px-2.5 text-sm']"
@click="handleShuffle"
>
<Shuffle :size="13" />
<span v-if="!compact">{{ t('views.bookView.shuffle') }}</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ t('views.bookView.shuffleHint') }}</TooltipContent>
</Tooltip>
</template>
@@ -1,4 +1,5 @@
import { nextTick, ref } from 'vue'
import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookCard, BooksPage, GroupRule, JumpBucket, SortSpec } from '@bookorbit/types'
import { COLLECTION_DEFAULT_SORT } from '../../lib/sort-defaults'
@@ -403,4 +404,100 @@ describe('useBookViewWindow', () => {
win.releaseRailGutter()
})
it('omits the shuffle seed until a sort tier is random', async () => {
mockApi(120)
const { win } = setup()
await flush()
expect(win.randomSortActive.value).toBe(false)
expect(listRequestBodies()[0]).not.toHaveProperty('randomSeed')
win.sort.value = [{ field: 'random', dir: 'asc' }]
await flush()
expect(win.randomSortActive.value).toBe(true)
const seed = listRequestBodies().at(-1)?.randomSeed
expect(typeof seed).toBe('number')
})
it('sends one seed for every page of a shuffled listing', async () => {
mockApi(250)
const { win } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
await flush()
await win.ensureRange(100, 199)
await flush()
const seeds = listRequestBodies().map((body) => body.randomSeed)
expect(seeds.length).toBeGreaterThan(1)
expect(new Set(seeds).size).toBe(1)
expect(typeof seeds[0]).toBe('number')
})
it('reshuffles into a new seed and refetches', async () => {
mockApi(120)
const { win } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
await flush()
const first = listRequestBodies().at(-1)?.randomSeed
win.reshuffle()
await flush()
const second = listRequestBodies().at(-1)?.randomSeed
expect(second).not.toBe(first)
expect(listRequestBodies().length).toBeGreaterThan(1)
})
it('reshuffles when the scope changes so two scopes never share an order', async () => {
mockApi(120)
const { win, scopeId } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
await flush()
const first = listRequestBodies().at(-1)?.randomSeed
scopeId.value = 2
await flush()
expect(listRequestBodies().at(-1)?.randomSeed).not.toBe(first)
expect(win.randomSortActive.value).toBe(true)
})
it('reshuffles when a kept-alive random listing is revisited', async () => {
mockApi(120)
const visible = ref(true)
const Source = defineComponent({
setup() {
useBookViewWindow({
scopeId: ref(1),
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
viewMode: ref('table'),
defaultSort: [{ field: 'random', dir: 'asc' }],
})
return {}
},
template: '<div />',
})
const Harness = defineComponent({
components: { Source },
setup() {
return { visible }
},
template: '<KeepAlive><Source v-if="visible" /></KeepAlive>',
})
const wrapper = mount(Harness)
await flush()
const first = listRequestBodies().at(-1)?.randomSeed
fetchMock.mockClear()
visible.value = false
await nextTick()
visible.value = true
await flush()
expect(listRequestBodies().at(-1)?.randomSeed).not.toBe(first)
wrapper.unmount()
})
})
@@ -1,6 +1,14 @@
import { computed, ref, type Ref } from 'vue'
import { computed, getCurrentInstance, onActivated, ref, watch, type Ref } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { jumpBucketKindForSort, type GroupRule, type JumpBucket, type SortSpec } from '@bookorbit/types'
import {
MAX_RANDOM_SORT_SEED,
createRandomSortSeed,
hasRandomSort,
jumpBucketKindForSort,
type GroupRule,
type JumpBucket,
type SortSpec,
} from '@bookorbit/types'
import { useBookProgressRefresh } from './useBookProgressRefresh'
import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow'
import { useJumpBuckets } from './useJumpBuckets'
@@ -39,8 +47,36 @@ export function useBookViewWindow(options: {
const filter = ref<GroupRule | undefined>(undefined)
const sort = ref<SortSpec[]>(copySort(options.defaultSort ?? DEFAULT_SORT))
// One shuffle per visit: the seed is minted here and travels with every page request, so
// paging stays coherent while a reload, a different scope, or the shuffle button re-rolls it.
const randomSeed = ref(createRandomSortSeed())
const randomSortActive = computed(() => hasRandomSort(sort.value))
function reshuffle() {
const next = createRandomSortSeed()
randomSeed.value = next === randomSeed.value ? (next + 1) % (MAX_RANDOM_SORT_SEED + 1) : next
}
// Registered before useBookWindow so a sort or scope change re-rolls the seed in the same flush
// the window reads the query, rather than costing a second round trip.
watch(randomSortActive, (active) => {
if (active) reshuffle()
})
watch(options.scopeId, () => {
reshuffle()
})
if (getCurrentInstance()) {
let hasActivated = false
onActivated(() => {
if (hasActivated && randomSortActive.value) reshuffle()
hasActivated = true
})
}
const query = computed<BookWindowQuery>(() => ({
sort: sort.value,
...(randomSortActive.value ? { randomSeed: randomSeed.value } : {}),
...(filter.value ? { filter: filter.value } : {}),
...(options.collapseEnabled?.value ? { collapseSeries: true } : {}),
...(options.q?.value.trim() ? { q: options.q.value.trim() } : {}),
@@ -130,6 +166,8 @@ export function useBookViewWindow(options: {
filter,
sort,
query,
randomSortActive,
reshuffle,
firstVisibleIndex,
handleFirstVisibleIndex,
handleRange,
+3
View File
@@ -7989,6 +7989,9 @@
"sort": "Sort",
"sortOn": "On",
"resetSort": "Reset sort to default",
"shuffle": "Shuffle",
"shuffleAria": "Shuffle the book order",
"shuffleHint": "Draw a new random order",
"filters": "Filters",
"clear": "Clear",
"clearAllFilters": "Clear all filters",
+5
View File
@@ -19,6 +19,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
@@ -105,6 +106,8 @@ const {
initialized: booksInitialized,
error: booksError,
sort: tableSort,
randomSortActive,
reshuffle,
reset: resetBooks,
contiguousPrefix,
hasMorePrefix,
@@ -544,6 +547,7 @@ defineOptions({ name: 'CollectionView' })
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
</Tooltip>
</div>
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
<Tooltip>
<TooltipTrigger as-child>
@@ -680,6 +684,7 @@ defineOptions({ name: 'CollectionView' })
>
<X :size="13" />
</button>
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+6
View File
@@ -25,6 +25,7 @@ import TableColumnPanel from '@/features/book/components/TableColumnPanel.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import ViewHeader from '@/components/ViewHeader.vue'
@@ -119,6 +120,8 @@ const {
error,
filter,
sort,
randomSortActive,
reshuffle,
reset: resetBooks,
updateBooks,
contiguousPrefix,
@@ -645,6 +648,7 @@ defineOptions({ name: 'HomeView' })
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
</Tooltip>
</div>
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
<div class="hidden sm:block w-px h-5 bg-border shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
@@ -804,6 +808,8 @@ defineOptions({ name: 'HomeView' })
<X :size="13" />
</button>
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
<button
@click="toggleFilterPanel"
class="flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-sm transition-colors"
+5
View File
@@ -51,6 +51,7 @@ import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
import { useViewSearch } from '@/features/book/composables/useViewSearch'
import FilterSummary from '@/features/book/components/FilterSummary.vue'
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { usePageTitle } from '@/composables/usePageTitle'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
@@ -101,6 +102,8 @@ const {
initialized: booksInitialized,
error: booksError,
sort: tableSort,
randomSortActive,
reshuffle,
reset: resetBooks,
contiguousPrefix,
hasMorePrefix,
@@ -573,6 +576,7 @@ defineOptions({ name: 'SmartScopeView' })
</TooltipTrigger>
<TooltipContent>{{ filterExpanded ? 'Hide filter summary' : 'Show filter summary' }}</TooltipContent>
</Tooltip>
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
<Tooltip>
<TooltipTrigger as-child>
<button
@@ -727,6 +731,7 @@ defineOptions({ name: 'SmartScopeView' })
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
<span>{{ filterExpanded ? 'Hide filter' : 'Show filter' }}</span>
</button>
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
<button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
@click="openMetadataExport"