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' },
+10
View File
@@ -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}. */
@@ -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 () => {
+6 -1
View File
@@ -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,
@@ -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,
});
});
});
@@ -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<SeriesVolumeStatus, number> = { read: 3, reading: 2, unread: 1, missing: 0 };
@@ -62,6 +65,16 @@ function groupSeriesMembers<T extends SeriesVolumeMember>(params: {
return { gaps, range, byIndex, unnumbered };
}
function tallyVolumes(grouped: { byIndex: ReadonlyMap<number, SeriesVolumeMember>; 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) };
}
/**