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