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) { - -
- + +
+
+ +
+ + + +
+ {{ seriesPositionLabel }} +
+
+ +

{{ seriesPositionTooltip }}

+
+
diff --git a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts index 2e5704c36..5de77f3ad 100644 --- a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts +++ b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts @@ -40,6 +40,9 @@ const globalStubs = { DropdownMenuSub: { template: '
' }, DropdownMenuSubTrigger: { template: '
' }, DropdownMenuSubContent: { template: '
' }, + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, }, } @@ -243,3 +246,69 @@ describe('BookCoverCard — placeholder state', () => { expect(imgs.length).toBeGreaterThan(0) }) }) + +describe('BookCoverCard — series position overlay', () => { + const bookWithSeries: BookCard = { + ...presentBook, + seriesName: 'The Expanse', + seriesIndex: 3, + } + + afterEach(() => { + cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status'] + }) + + it('renders the series badge when overlay is enabled and seriesIndex is set', () => { + cardOverlays.value = ['series-position'] + const wrapper = mountCard(bookWithSeries) + expect(wrapper.text()).toContain('#3') + }) + + it('does not render the badge when series-position is not in overlays', () => { + cardOverlays.value = [] + const wrapper = mountCard(bookWithSeries) + expect(wrapper.text()).not.toContain('#3') + }) + + it('does not render badge when seriesIndex is null', () => { + cardOverlays.value = ['series-position'] + const wrapper = mountCard({ ...presentBook, seriesIndex: null, seriesName: 'Dune' }) + expect(wrapper.text()).not.toContain('#') + }) + + it('formats whole-number float 3.0 as #3 (no trailing decimal)', () => { + cardOverlays.value = ['series-position'] + const wrapper = mountCard({ ...presentBook, seriesIndex: 3.0, seriesName: 'Dune' }) + expect(wrapper.text()).toContain('#3') + expect(wrapper.text()).not.toContain('#3.0') + }) + + it('formats fractional index 1.5 as #1.5', () => { + cardOverlays.value = ['series-position'] + const wrapper = mountCard({ ...presentBook, seriesIndex: 1.5, seriesName: 'Dune' }) + expect(wrapper.text()).toContain('#1.5') + }) + + it('tooltip content shows series name combined with number', () => { + cardOverlays.value = ['series-position'] + const wrapper = mountCard(bookWithSeries) + const tooltip = wrapper.find('[data-testid="tooltip-content"]') + expect(tooltip.exists()).toBe(true) + expect(tooltip.text()).toContain('The Expanse #3') + }) + + it('tooltip shows only number when seriesName is null', () => { + cardOverlays.value = ['series-position'] + const wrapper = mountCard({ ...presentBook, seriesIndex: 5, seriesName: null }) + const tooltip = wrapper.find('[data-testid="tooltip-content"]') + expect(tooltip.exists()).toBe(true) + expect(tooltip.text()).toBe('#5') + }) + + it('lock and series badges coexist in the top-right container', () => { + cardOverlays.value = ['series-position', 'lock-status'] + const wrapper = mountCard({ ...presentBook, seriesIndex: 2, seriesName: 'Dune', hasMetadataLocks: true }) + expect(wrapper.text()).toContain('#2') + expect(wrapper.find('.text-amber-400').exists()).toBe(true) + }) +}) diff --git a/client/src/features/settings/AppearanceSettings.vue b/client/src/features/settings/AppearanceSettings.vue index d0162f4ee..3209e193f 100644 --- a/client/src/features/settings/AppearanceSettings.vue +++ b/client/src/features/settings/AppearanceSettings.vue @@ -34,6 +34,7 @@ const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = { key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' }, { key: 'rating', label: 'Rating', hint: 'Star rating indicator at bottom-left' }, { key: 'read-status', label: 'Read status', hint: 'Color icon showing the current reading status at top-left' }, + { key: 'series-position', label: 'Series number', hint: 'Badge showing the book position in its series at top-right (e.g. #3, #1.5)' }, { key: 'lock-status', label: 'Lock status', hint: 'Metadata lock icon at top-right - orange when locked, green when unlocked' }, ] diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 4f8f374ee..374cdb1d0 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -2,7 +2,7 @@ module.exports = { extends: ["@commitlint/config-conventional"], rules: { "type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]], - "header-max-length": [2, "always", 72], + "header-max-length": [2, "always", 100], "subject-full-stop": [2, "never", "."], "scope-enum": [ 2,