fix(books): show read status on the book detail discovery shelf

The series, author and similar endpoints never selected a read status, so
every cover in the detail shelf rendered bare. Attach the requesting user's
status in one bulk lookup per lane, and draw the badge in BookCarousel using
the same icons and placement as the library grid, plus an sr-only label.
BookRepository has no user in scope, so it now returns UnscopedBookRecommendation
and the type system forces the status on where the user exists.

Also adds the untracked scanner overlap repro for issue 1094.

Closes #1163
This commit is contained in:
neon
2026-08-27 21:56:17 +00:00
parent ce5b000d7a
commit ddb296cade
8 changed files with 402 additions and 33 deletions
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { STATUS_COLORS, STATUS_ICONS } from '@/features/book/composables/useBookStatus'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import type { CoverAspectRatio } from '@bookorbit/types'
import type { CoverAspectRatio, ReadStatus, UserBookStatus } from '@bookorbit/types'
export interface CarouselBook {
id: number
@@ -16,6 +18,7 @@ export interface CarouselBook {
seriesIndex?: string | null
hasCover: boolean
authors: string[]
readStatus?: UserBookStatus | null
isAudiobook?: boolean
isComic?: boolean
}
@@ -46,6 +49,7 @@ const cardWidthClass = computed(() => {
})
const router = useRouter()
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const scrollEl = ref<HTMLElement | null>(null)
const coverResetVersion = ref(0)
@@ -72,6 +76,16 @@ function isComic(book: CarouselBook): boolean {
return book.isComic ?? false
}
function readStatus(book: CarouselBook): ReadStatus | null {
const status = book.readStatus?.status
return status != null && status !== 'unread' ? status : null
}
function readStatusLabel(status: ReadStatus): string {
const key = status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())
return t(`book.readStatus.${key}`)
}
function cardAspectRatio(book: CarouselBook): string {
return book.coverAspectRatio
}
@@ -156,6 +170,13 @@ defineExpose({ scroll })
:spine="!isAudiobook(book)"
:is-comic="isComic(book)"
/>
<span
v-if="readStatus(book)"
class="absolute top-1.5 left-1.5 z-10 flex items-center justify-center rounded-full bg-black/60 p-1 pointer-events-none"
>
<component :is="STATUS_ICONS[readStatus(book)!]" :size="12" :class="STATUS_COLORS[readStatus(book)!]" />
<span class="sr-only">{{ readStatusLabel(readStatus(book)!) }}</span>
</span>
<span
v-if="showSeriesIndex && book.seriesIndex != null"
class="absolute bottom-1.5 left-1.5 bg-black/60 text-white text-[9px] font-semibold leading-none px-1.5 py-1 rounded-full pointer-events-none"
@@ -0,0 +1,52 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import BookCarousel, { type CarouselBook } from '../BookCarousel.vue'
import type { ReadStatus } from '@bookorbit/types'
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg' }),
}))
function makeBook(id: number, status: ReadStatus | null): CarouselBook {
return {
id,
title: `Book ${id}`,
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: true,
authors: [],
readStatus: status ? { status, source: 'manual', startedAt: null, finishedAt: null, updatedAt: '2026-01-01T00:00:00.000Z' } : null,
}
}
function mountCarousel(books: CarouselBook[]) {
return mount(BookCarousel, { props: { books, loading: false, showHeader: false } })
}
describe('BookCarousel read status badge', () => {
it('labels a card with the read status of its book', () => {
const wrapper = mountCarousel([makeBook(1, 'reading')])
expect(wrapper.get('[data-book-id="1"]').text()).toContain('Reading')
})
it('draws no badge for an unread or untracked book', () => {
const wrapper = mountCarousel([makeBook(1, 'unread'), makeBook(2, null)])
expect(wrapper.get('[data-book-id="1"]').find('.sr-only').exists()).toBe(false)
expect(wrapper.get('[data-book-id="2"]').find('.sr-only').exists()).toBe(false)
})
it('badges only the books that carry a status', () => {
const wrapper = mountCarousel([makeBook(1, 'read'), makeBook(2, null), makeBook(3, 'abandoned')])
expect(wrapper.get('[data-book-id="1"]').text()).toContain('Read')
expect(wrapper.get('[data-book-id="2"]').find('.sr-only').exists()).toBe(false)
expect(wrapper.get('[data-book-id="3"]').text()).toContain('Abandoned')
})
})