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
@@ -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) };
}
/**