mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user