fix(series): count duplicate formats as one volume

Preserve raw book counts for compatibility while reporting logical ownership and reading progress for duplicate series positions.

Closes #1871
This commit is contained in:
neon
2026-10-27 03:55:23 +00:00
parent 0752319647
commit 0b89e5a4a1
9 changed files with 210 additions and 37 deletions
@@ -3,6 +3,7 @@ import { computed, defineComponent, nextTick, reactive, ref, type PropType } fro
import { flushPromises, mount } from '@vue/test-utils'
import type { BookCard, SeriesBooksPage, SeriesDetail } from '@bookorbit/types'
import SeriesDetailView from './SeriesDetailView.vue'
import SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media'
@@ -319,7 +320,7 @@ describe('SeriesDetailView', () => {
expect(wrapper.find('[data-testid="series-media-group-books"]').exists()).toBe(true)
})
it('shows the ownership bar only once a provider has supplied a series total', async () => {
it('falls back to book counts when a server does not return volume counts', async () => {
mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: 7 }))
const wrapper = mountView()
@@ -328,9 +329,37 @@ describe('SeriesDetailView', () => {
const bar = wrapper.get('[role="progressbar"]')
expect(bar.attributes('aria-valuenow')).toBe('4')
expect(bar.attributes('aria-valuemax')).toBe('7')
expect(wrapper.text()).toContain('4 volumes')
const completion = wrapper.getComponent(SeriesCompletionBar)
expect(completion.props('readCount')).toBe(0)
expect(completion.props('totalCount')).toBe(4)
expect(wrapper.text()).toContain('4 of 7')
})
it('uses logical volume counts for the header, reading progress, and ownership', async () => {
mocks.seriesInfo = ref(
makeSeriesInfo({
bookCount: 4,
readCount: 2,
ownedVolumeCount: 2,
readVolumeCount: 1,
expectedBookCount: 7,
}),
)
const wrapper = mountView()
await nextTick()
expect(wrapper.text()).toContain('2 volumes')
const completion = wrapper.getComponent(SeriesCompletionBar)
expect(completion.props('readCount')).toBe(1)
expect(completion.props('totalCount')).toBe(2)
const ownership = wrapper.get('[role="progressbar"]')
expect(ownership.attributes('aria-valuenow')).toBe('2')
expect(ownership.attributes('aria-valuemax')).toBe('7')
expect(wrapper.text()).toContain('2 of 7')
})
it('hides the ownership bar when no provider total is known', async () => {
mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: null }))
@@ -110,6 +110,8 @@ const canEditMetadata = computed(() => hasPermissionInAnyLibrary('library_edit_m
const safeLeadDescription = useSafeHtml(() => leadBook.value?.description)
const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5))
const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length))
const ownedVolumeCount = computed(() => seriesInfo.value?.ownedVolumeCount ?? seriesInfo.value?.bookCount ?? 0)
const readVolumeCount = computed(() => seriesInfo.value?.readVolumeCount ?? seriesInfo.value?.readCount ?? 0)
const addToCollectionOpen = ref(false)
const addToCollectionBookId = ref<number | null>(null)
const leadFallbackStyle = computed(() => {
@@ -538,7 +540,7 @@ defineOptions({ name: 'SeriesDetailView' })
<div class="min-w-0">
<h1 class="text-xl font-bold text-foreground">{{ seriesInfo.name }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>{{ t('series.detail.bookCount', { count: seriesInfo.bookCount }) }}</span>
<span>{{ t('series.card.volumeCount', { count: ownedVolumeCount }) }}</span>
<span v-if="visibleSeriesAuthors.length > 0">
{{ t('series.detail.byAuthors', { authors: visibleSeriesAuthors.join(', ') }) }}
<span v-if="hiddenSeriesAuthorsCount > 0"> {{ t('series.detail.moreAuthors', { count: hiddenSeriesAuthorsCount }) }}</span>
@@ -558,10 +560,10 @@ defineOptions({ name: 'SeriesDetailView' })
</div>
<div class="mt-3 grid max-w-md gap-3 sm:grid-cols-2">
<SeriesCompletionBar :read-count="seriesInfo.readCount" :total-count="seriesInfo.bookCount" />
<SeriesCompletionBar :read-count="readVolumeCount" :total-count="ownedVolumeCount" />
<SeriesOwnershipBar
v-if="seriesInfo.expectedBookCount !== null"
:owned-count="seriesInfo.bookCount"
:owned-count="ownedVolumeCount"
:expected-count="seriesInfo.expectedBookCount"
/>
</div>