diff --git a/client/src/features/series/composables/useSeriesList.test.ts b/client/src/features/series/composables/useSeriesList.test.ts index bb0e93b03..9e0d1ed51 100644 --- a/client/src/features/series/composables/useSeriesList.test.ts +++ b/client/src/features/series/composables/useSeriesList.test.ts @@ -24,6 +24,9 @@ function makeSeries(overrides: Partial = {}): Serie lastAddedAt: null, libraryNames: [], expectedBookCount: null, + ownedVolumeCount: 1, + readVolumeCount: 0, + readingVolumeCount: 0, volumes: [], volumesTruncated: false, gaps: [], diff --git a/client/src/features/series/lib/series-summary.test.ts b/client/src/features/series/lib/series-summary.test.ts index e49237c17..48d051043 100644 --- a/client/src/features/series/lib/series-summary.test.ts +++ b/client/src/features/series/lib/series-summary.test.ts @@ -19,6 +19,9 @@ function summary(overrides: Partial = {}): SeriesSummary { lastAddedAt: null, libraryNames: [], expectedBookCount: null, + ownedVolumeCount: 1, + readVolumeCount: 0, + readingVolumeCount: 0, volumes: [], volumesTruncated: false, gaps: [], @@ -31,51 +34,40 @@ function summary(overrides: Partial = {}): SeriesSummary { } describe('seriesRowFacts', () => { - it('counts volumes off the ladder, so two editions of one volume count once', () => { - const facts = seriesRowFacts( - summary({ - bookCount: 4, - readCount: 1, - volumes: [slot({ index: 1, bookId: 1, status: 'read' }), slot({ index: 2, bookId: 2, status: 'unread' })], - }), - ) + it('reports volumes rather than books, so two editions of one volume count once', () => { + const facts = seriesRowFacts(summary({ bookCount: 4, readCount: 2, ownedVolumeCount: 2, readVolumeCount: 1 })) expect(facts.ownedVolumes).toBe(2) expect(facts.readVolumes).toBe(1) expect(facts.percentRead).toBe(50) }) - it('does not count a missing rung as something the library owns', () => { + it('counts a series past the capped ladder instead of stopping at the cap', () => { + const volumes = Array.from({ length: 60 }, (_, i) => slot({ index: i + 1, bookId: i + 1, status: 'read' })) const facts = seriesRowFacts( - summary({ - bookCount: 2, - volumes: [ - slot({ index: 1, bookId: 1, status: 'read' }), - slot({ index: 2, bookId: null, title: null, status: 'missing' }), - slot({ index: 3, bookId: 3, status: 'unread' }), - ], - gaps: [2], - gapCount: 1, - }), + summary({ bookCount: 300, readCount: 60, ownedVolumeCount: 300, readVolumeCount: 60, volumes, volumesTruncated: true }), ) - expect(facts.ownedVolumes).toBe(2) - expect(facts.hasGaps).toBe(true) + expect(facts.ownedVolumes).toBe(300) + expect(facts.readVolumes).toBe(60) + expect(facts.percentRead).toBe(20) expect(facts.isComplete).toBe(false) }) - it('falls back to the book counts when there is no ladder to count', () => { - const facts = seriesRowFacts(summary({ bookCount: 9, readCount: 4, readingCount: 1, volumes: [], volumesTruncated: true })) + it('reports the volumes in progress', () => { + const facts = seriesRowFacts(summary({ ownedVolumeCount: 9, readVolumeCount: 4, readingVolumeCount: 1 })) - expect(facts.ownedVolumes).toBe(9) - expect(facts.readVolumes).toBe(4) expect(facts.readingVolumes).toBe(1) + expect(facts.isStarted).toBe(true) + }) + + it('flags gaps from the gap count', () => { + expect(seriesRowFacts(summary({ gaps: [2], gapCount: 1 })).hasGaps).toBe(true) }) it('is complete only when every owned volume is read', () => { - const read = [slot({ index: 1, bookId: 1, status: 'read' }), slot({ index: 2, bookId: 2, status: 'read' })] - expect(seriesRowFacts(summary({ volumes: read })).isComplete).toBe(true) - expect(seriesRowFacts(summary({ volumes: [], bookCount: 0, readCount: 0 })).isComplete).toBe(false) + expect(seriesRowFacts(summary({ ownedVolumeCount: 2, readVolumeCount: 2 })).isComplete).toBe(true) + expect(seriesRowFacts(summary({ ownedVolumeCount: 0, readVolumeCount: 0, bookCount: 0 })).isComplete).toBe(false) }) }) diff --git a/client/src/features/series/lib/series-summary.ts b/client/src/features/series/lib/series-summary.ts index db9844cbf..0b9cd7eaf 100644 --- a/client/src/features/series/lib/series-summary.ts +++ b/client/src/features/series/lib/series-summary.ts @@ -4,10 +4,10 @@ import type { SeriesSummary, SeriesVolumeSlot } from '@bookorbit/types' * What a row actually shows, derived once so the grid card and the list row can never * disagree about a series. * - * The volume counts come from the ladder rather than from `bookCount`, because a library holding - * the same volume twice - one ebook, one audiobook - owns two books and one volume, and "3 of 7" - * only reads correctly when it counts volumes. When there is no ladder to count, the book counts - * stand in. + * The volume counts are the server's logical-volume counts rather than `bookCount`, because a + * library holding the same volume twice - one ebook, one audiobook - owns two books and one + * volume, and "3 of 7" only reads correctly when it counts volumes. They are not counted from + * `volumes`, which is capped and would stop a long series at the cap. */ export type SeriesRowFacts = { ownedVolumes: number @@ -23,10 +23,9 @@ export type SeriesRowFacts = { } export function seriesRowFacts(series: SeriesSummary): SeriesRowFacts { - const slots = series.volumes - const owned = slots.length > 0 ? slots.filter((slot) => slot.bookId !== null).length : series.bookCount - const read = slots.length > 0 ? slots.filter((slot) => slot.status === 'read').length : series.readCount - const reading = slots.length > 0 ? slots.filter((slot) => slot.status === 'reading').length : series.readingCount + const owned = series.ownedVolumeCount + const read = series.readVolumeCount + const reading = series.readingVolumeCount return { ownedVolumes: owned, @@ -37,7 +36,7 @@ export function seriesRowFacts(series: SeriesSummary): SeriesRowFacts { isStarted: read > 0 || reading > 0, isSingleVolume: owned === 1, hasGaps: series.gapCount > 0, - isNumbered: slots.some((slot) => slot.index !== null), + isNumbered: series.volumes.some((slot) => slot.index !== null), } } diff --git a/client/src/features/series/views/SeriesView.spec.ts b/client/src/features/series/views/SeriesView.spec.ts index d6bc780a1..b438283d9 100644 --- a/client/src/features/series/views/SeriesView.spec.ts +++ b/client/src/features/series/views/SeriesView.spec.ts @@ -38,6 +38,9 @@ function makeSeries(overrides: Partial = {}): SeriesSummary { lastAddedAt: null, libraryNames: ['Novels'], expectedBookCount: null, + ownedVolumeCount: 2, + readVolumeCount: 1, + readingVolumeCount: 0, volumes: [ { index: 1, bookId: 10, title: 'One', status: 'read' }, { index: 2, bookId: null, title: null, status: 'missing' }, diff --git a/packages/types/src/series.ts b/packages/types/src/series.ts index 7fb9b3f5d..ed8055cc7 100644 --- a/packages/types/src/series.ts +++ b/packages/types/src/series.ts @@ -38,6 +38,16 @@ export type SeriesSummary = { libraryNames: string[]; /** Total a metadata provider reports for the series, or null when no provider has said. */ expectedBookCount: number | null; + /** + * Logical volumes owned across the whole series, collapsing book records that share a + * whole-numbered position. Unlike `volumes`, never capped; falls back to `bookCount` when the + * members could not all be read. + */ + ownedVolumeCount: number; + /** Owned logical volumes whose furthest status is read. */ + readVolumeCount: number; + /** Owned logical volumes whose furthest status is reading. */ + readingVolumeCount: number; volumes: SeriesVolumeSlot[]; volumesTruncated: boolean; /** Missing numbers, capped at {@link SERIES_GAP_PREVIEW_LIMIT}. */ diff --git a/server/src/modules/series/series.service.test.ts b/server/src/modules/series/series.service.test.ts index 0c80fba57..f7ca8b7e8 100644 --- a/server/src/modules/series/series.service.test.ts +++ b/server/src/modules/series/series.service.test.ts @@ -225,6 +225,36 @@ describe('SeriesService', () => { expect(item.volumesTruncated).toBe(true); expect(item.gapCount).toBe(0); expect(item.nextBookId).toBe(31); + expect(item).toMatchObject({ ownedVolumeCount: 900, readVolumeCount: 0, readingVolumeCount: 0 }); + }); + + it('reports the true volume counts for a series longer than the ladder cap', async () => { + seriesRepo.findPage.mockResolvedValue({ + items: [ + summaryRow({ + id: 10, + name: 'Hellblazer', + bookCount: 300, + readCount: 60, + members: Array.from({ length: 300 }, (_, i) => ({ + bookId: 100 + i, + seriesIndex: String(i + 1), + title: `Issue ${i + 1}`, + status: i < 60 ? 'read' : null, + })), + }), + ], + total: 1, + facets: { ...EMPTY_FACETS, all: 1, inProgress: 1 }, + page: 0, + size: 50, + }); + + const item = (await service.findAll(reqUser(), {})).items[0]!; + + expect(item.volumes).toHaveLength(60); + expect(item.volumesTruncated).toBe(true); + expect(item).toMatchObject({ ownedVolumeCount: 300, readVolumeCount: 60, readingVolumeCount: 0 }); }); it('scopes to specific library when libraryId provided', async () => { diff --git a/server/src/modules/series/series.service.ts b/server/src/modules/series/series.service.ts index 5ef8eb1fb..ddf046e4e 100644 --- a/server/src/modules/series/series.service.ts +++ b/server/src/modules/series/series.service.ts @@ -63,6 +63,7 @@ export class SeriesService { bookCount: row.bookCount, expectedBookCount: row.expectedBookCount, }); + const counts = ladder.counts ?? { ownedVolumeCount: row.bookCount, readVolumeCount: row.readCount, readingVolumeCount: row.readingCount }; return { id: row.id, @@ -75,6 +76,9 @@ export class SeriesService { lastAddedAt: row.lastAddedAt ?? null, libraryNames: row.libraryNames, expectedBookCount: normalizeSeriesTotalBooks(row.expectedBookCount) ?? null, + ownedVolumeCount: counts.ownedVolumeCount, + readVolumeCount: counts.readVolumeCount, + readingVolumeCount: counts.readingVolumeCount, volumes: ladder.volumes, volumesTruncated: ladder.truncated, gaps: ladder.gaps.slice(0, SERIES_GAP_PREVIEW_LIMIT), @@ -191,7 +195,8 @@ export class SeriesService { name: detail.name, bookCount: detail.bookCount, readCount: detail.readCount, - ...volumeCounts, + ownedVolumeCount: volumeCounts.ownedVolumeCount, + readVolumeCount: volumeCounts.readVolumeCount, authors: detail.authors, possibleGaps, expectedBookCount: detail.expectedBookCount ?? null, diff --git a/server/src/modules/series/utils/series-ladder.utils.test.ts b/server/src/modules/series/utils/series-ladder.utils.test.ts index 537ae52f6..cf57dcc21 100644 --- a/server/src/modules/series/utils/series-ladder.utils.test.ts +++ b/server/src/modules/series/utils/series-ladder.utils.test.ts @@ -90,6 +90,7 @@ describe('buildVolumeLadder', () => { expect(ladder.truncated).toBe(true); expect(ladder.gaps).toEqual([]); expect(ladder.next?.bookId).toBe(1); + expect(ladder.counts).toBeNull(); }); it('caps a very long ladder and says so', () => { @@ -100,6 +101,15 @@ describe('buildVolumeLadder', () => { expect(ladder.truncated).toBe(true); }); + it('counts every volume of a capped ladder, not just the drawn rungs', () => { + const members = Array.from({ length: 80 }, (_, i) => member(i + 1, String(i + 1), i < 60 ? 'read' : i === 60 ? 'reading' : null)); + members.push(member(81, '1'), member(82, null, 'read')); + const ladder = build(members); + + expect(ladder.counts).toEqual({ ownedVolumeCount: 81, readVolumeCount: 61, readingVolumeCount: 1 }); + expect(ladder.counts).toEqual(countSeriesVolumes({ members, bookCount: members.length, expectedBookCount: null })); + }); + describe('next volume', () => { it('prefers the one already in progress over an earlier unread one', () => { const ladder = build([member(1, '1'), member(2, '2', 'reading')]); @@ -130,6 +140,7 @@ describe('countSeriesVolumes', () => { expect(countSeriesVolumes({ members, bookCount: 4, expectedBookCount: 7 })).toEqual({ ownedVolumeCount: 2, readVolumeCount: 1, + readingVolumeCount: 1, }); }); @@ -139,6 +150,7 @@ describe('countSeriesVolumes', () => { expect(countSeriesVolumes({ members, bookCount: 5, expectedBookCount: null })).toEqual({ ownedVolumeCount: 3, readVolumeCount: 2, + readingVolumeCount: 1, }); }); @@ -148,6 +160,7 @@ describe('countSeriesVolumes', () => { expect(countSeriesVolumes({ members, bookCount: 5, expectedBookCount: null })).toEqual({ ownedVolumeCount: 5, readVolumeCount: 3, + readingVolumeCount: 0, }); }); @@ -157,6 +170,7 @@ describe('countSeriesVolumes', () => { expect(countSeriesVolumes({ members, bookCount: 3, expectedBookCount: null })).toEqual({ ownedVolumeCount: 3, readVolumeCount: 2, + readingVolumeCount: 0, }); }); @@ -164,6 +178,7 @@ describe('countSeriesVolumes', () => { expect(countSeriesVolumes({ members: [], bookCount: 0, expectedBookCount: null })).toEqual({ ownedVolumeCount: 0, readVolumeCount: 0, + readingVolumeCount: 0, }); }); }); diff --git a/server/src/modules/series/utils/series-ladder.utils.ts b/server/src/modules/series/utils/series-ladder.utils.ts index 2ebabd07a..36ccddb57 100644 --- a/server/src/modules/series/utils/series-ladder.utils.ts +++ b/server/src/modules/series/utils/series-ladder.utils.ts @@ -4,16 +4,19 @@ import { SERIES_VOLUME_SLOT_LIMIT } from '@bookorbit/types'; import type { SeriesMemberRow, SeriesVolumeMember } from '../series.repository'; import { computeSeriesGaps, resolveLadderRange } from './series-gaps.utils'; +export type SeriesVolumeCounts = { + ownedVolumeCount: number; + readVolumeCount: number; + readingVolumeCount: number; +}; + export type SeriesLadder = { volumes: SeriesVolumeSlot[]; truncated: boolean; gaps: number[]; next: { bookId: number; index: SeriesIndex | null; title: string | null } | null; -}; - -export type SeriesVolumeCounts = { - ownedVolumeCount: number; - readVolumeCount: number; + /** Logical volumes across the whole series, not just the capped slots; null when the members could not all be read. */ + counts: SeriesVolumeCounts | null; }; const STATUS_RANK: Record = { read: 3, reading: 2, unread: 1, missing: 0 }; @@ -62,6 +65,16 @@ function groupSeriesMembers(params: { return { gaps, range, byIndex, unnumbered }; } +function tallyVolumes(grouped: { byIndex: ReadonlyMap; unnumbered: readonly SeriesVolumeMember[] }): SeriesVolumeCounts { + const statuses = [...grouped.byIndex.values(), ...grouped.unnumbered].map((member) => memberStatus(member.status)); + + return { + ownedVolumeCount: statuses.length, + readVolumeCount: statuses.filter((status) => status === 'read').length, + readingVolumeCount: statuses.filter((status) => status === 'reading').length, + }; +} + /** * Counts logical volumes rather than book records. Numbered copies share one volume and keep the * furthest reading state, while books without a usable whole-numbered position stay independent. @@ -71,13 +84,7 @@ export function countSeriesVolumes(params: { bookCount: number; expectedBookCount: number | null; }): SeriesVolumeCounts { - const grouped = groupSeriesMembers(params); - const owned = [...grouped.byIndex.values(), ...grouped.unnumbered]; - - return { - ownedVolumeCount: owned.length, - readVolumeCount: owned.filter((member) => memberStatus(member.status) === 'read').length, - }; + return tallyVolumes(groupSeriesMembers(params)); } /** @@ -97,16 +104,17 @@ export function buildVolumeLadder(params: { const { members, bookCount, expectedBookCount } = params; if (members.length === 0) { - return { volumes: [], truncated: params.truncated, gaps: [], next: null }; + return { volumes: [], truncated: params.truncated, gaps: [], next: null, counts: null }; } // A series we could not read in full can still be counted, but naming a volume missing needs // every sibling in hand, so a truncated one draws no ladder rather than a wrong one. if (params.truncated) { - return { volumes: [], truncated: true, gaps: [], next: firstOpenMember(members) }; + return { volumes: [], truncated: true, gaps: [], next: firstOpenMember(members), counts: null }; } - const { gaps, range, byIndex, unnumbered } = groupSeriesMembers({ members, bookCount, expectedBookCount }); + const grouped = groupSeriesMembers({ members, bookCount, expectedBookCount }); + const { gaps, range, byIndex, unnumbered } = grouped; const volumes: SeriesVolumeSlot[] = []; let truncated = false; @@ -134,7 +142,7 @@ export function buildVolumeLadder(params: { volumes.push({ index: null, bookId: member.bookId, title: member.title, status: memberStatus(member.status) }); } - return { volumes, truncated, gaps, next: nextFromSlots(volumes, members) }; + return { volumes, truncated, gaps, next: nextFromSlots(volumes, members), counts: tallyVolumes(grouped) }; } /**