fix(series): count every volume of series longer than the ladder cap

Send logical volume counts on each series summary, tallied from the member
grouping the ladder already builds, and read them on the cards instead of
counting the 60-slot ladder. Long series no longer stop at 60 or show as
complete after their first 60 volumes are read.

Closes #1987
This commit is contained in:
neon
2026-10-30 00:04:46 +00:00
parent 63e16ad757
commit 0206a98ccc
9 changed files with 119 additions and 54 deletions
@@ -24,6 +24,9 @@ function makeSeries(overrides: Partial<SeriesPage['items'][number]> = {}): Serie
lastAddedAt: null,
libraryNames: [],
expectedBookCount: null,
ownedVolumeCount: 1,
readVolumeCount: 0,
readingVolumeCount: 0,
volumes: [],
volumesTruncated: false,
gaps: [],
@@ -19,6 +19,9 @@ function summary(overrides: Partial<SeriesSummary> = {}): 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> = {}): 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)
})
})
@@ -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),
}
}
@@ -38,6 +38,9 @@ function makeSeries(overrides: Partial<SeriesSummary> = {}): 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' },