From 4cb01493800d46f3227d71faec5338574d718967 Mon Sep 17 00:00:00 2001
From: neon <18559658+neonsolstice@users.noreply.github.com>
Date: Mon, 18 May 2026 13:48:38 -0600
Subject: [PATCH] feat(settings): add series number badge to book cover card
overlay (#37)
Series position badge (#N) rendered in the top-right corner of cover
cards, sharing a flex-column container with the existing lock-status
badge. Shown only when seriesIndex is non-null. Whole numbers strip the
decimal (3.0 -> #3); fractional values display as-is (1.5 -> #1.5).
Hovering the badge shows a tooltip with the full series name and position.
Enabled by default in Appearance -> Card Overlays.
Closes #32
---
client/src/composables/useDisplaySettings.ts | 6 +-
.../book/components/BookCoverCard.test.ts | 59 ++++++++++++++++
.../book/components/BookCoverCard.vue | 42 +++++++++--
.../__tests__/BookCoverCard.spec.ts | 69 +++++++++++++++++++
.../features/settings/AppearanceSettings.vue | 1 +
commitlint.config.cjs | 2 +-
6 files changed, 169 insertions(+), 10 deletions(-)
diff --git a/client/src/composables/useDisplaySettings.ts b/client/src/composables/useDisplaySettings.ts
index 0441014d4..5c4e8177f 100644
--- a/client/src/composables/useDisplaySettings.ts
+++ b/client/src/composables/useDisplaySettings.ts
@@ -1,7 +1,7 @@
import { ref, watch } from 'vue'
import { storage } from '@/services/storage'
-export type CardOverlayKey = 'progress-bar' | 'format' | 'rating' | 'read-status' | 'lock-status'
+export type CardOverlayKey = 'progress-bar' | 'format' | 'rating' | 'read-status' | 'lock-status' | 'series-position'
export type AuthorCoverShape = 'square' | 'circle'
export type CoverSizeScope = 'per-view' | 'synced'
export type TableDensity = 'compact' | 'comfortable' | 'roomy'
@@ -9,8 +9,8 @@ export type TableDensity = 'compact' | 'comfortable' | 'roomy'
const DEFAULT_PORTRAIT_COVER_SIZE = 130
const DEFAULT_SQUARE_COVER_SIZE = 150
const DEFAULT_GRID_GAP = 28
-const CARD_OVERLAY_KEYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'lock-status']
-const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status']
+const CARD_OVERLAY_KEYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'lock-status', 'series-position']
+const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
function normalizeCardOverlays(value: unknown): CardOverlayKey[] {
if (!Array.isArray(value)) return [...DEFAULT_CARD_OVERLAYS]
diff --git a/client/src/features/book/components/BookCoverCard.test.ts b/client/src/features/book/components/BookCoverCard.test.ts
index c1fa2492c..c05a8759e 100644
--- a/client/src/features/book/components/BookCoverCard.test.ts
+++ b/client/src/features/book/components/BookCoverCard.test.ts
@@ -459,4 +459,63 @@ describe('BookCoverCard', () => {
expect(wrapper.find('[data-testid="send-dialog"]').exists()).toBe(false)
})
})
+
+ // ── series position overlay ───────────────────────────────────────────────
+
+ describe('series position overlay', () => {
+ it('hides badge when series-position is not in cardOverlays', () => {
+ mockCardOverlays.value = []
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: 'Dune' }) })
+ expect(wrapper.text()).not.toContain('#3')
+ })
+
+ it('shows #3 badge when seriesIndex is 3 and overlay is enabled', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: 'Dune' }) })
+ expect(wrapper.text()).toContain('#3')
+ })
+
+ it('shows #1.5 badge when seriesIndex is 1.5', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 1.5, seriesName: 'Dune' }) })
+ expect(wrapper.text()).toContain('#1.5')
+ })
+
+ it('strips trailing .0 for whole-number floats (3.0 shows as #3)', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 3.0, seriesName: 'Dune' }) })
+ expect(wrapper.text()).toContain('#3')
+ expect(wrapper.text()).not.toContain('#3.0')
+ })
+
+ it('hides badge when seriesIndex is null even if overlay is enabled', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: null, seriesName: 'Dune' }) })
+ expect(wrapper.text()).not.toContain('#')
+ })
+
+ it('hides badge in selection mode', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 2, seriesName: 'Dune' }), selectionMode: true })
+ expect(wrapper.text()).not.toContain('#2')
+ })
+
+ it('hides badge for missing books', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 2, seriesName: 'Dune', status: 'missing' }) })
+ expect(wrapper.text()).not.toContain('#2')
+ })
+
+ it('tooltip content includes series name and number', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: 'Sprawl' }) })
+ expect(wrapper.text()).toContain('Sprawl #3')
+ })
+
+ it('tooltip content shows only number when seriesName is null', () => {
+ mockCardOverlays.value = ['series-position']
+ const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: null }) })
+ expect(wrapper.text()).toContain('#3')
+ })
+ })
})
diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue
index b06c39e09..a20ef4df4 100644
--- a/client/src/features/book/components/BookCoverCard.vue
+++ b/client/src/features/book/components/BookCoverCard.vue
@@ -45,6 +45,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
@@ -121,6 +122,20 @@ const showRatingOverlay = computed(() => cardOverlays.value.includes('rating') &
const showLockStatusPill = computed(() => cardOverlays.value.includes('lock-status') && !props.selectionMode && !isMissing.value)
const metadataLocked = computed(() => props.book.hasMetadataLocks)
+const showSeriesPositionBadge = computed(
+ () => cardOverlays.value.includes('series-position') && props.book.seriesIndex != null && !props.selectionMode && !isMissing.value,
+)
+const seriesPositionLabel = computed(() => {
+ const index = props.book.seriesIndex
+ if (index == null) return ''
+ const display = index % 1 === 0 ? String(Math.trunc(index)) : String(index)
+ return `#${display}`
+})
+const seriesPositionTooltip = computed(() => {
+ const label = seriesPositionLabel.value
+ return props.book.seriesName ? `${props.book.seriesName} ${label}` : label
+})
+
const ratingColor = computed(() => {
const r = Math.round(props.book.rating ?? 0)
if (r <= 1) return '#dc2626'
@@ -283,12 +298,27 @@ async function handleSetStatus(status: ReadStatus) {
{{ seriesPositionTooltip }}
+