fix(series): count duplicate formats as one volume

Preserve raw book counts for compatibility while reporting logical ownership and reading progress for duplicate series positions.

Closes #1871
This commit is contained in:
neon
2026-10-27 03:55:23 +00:00
parent 0752319647
commit 0b89e5a4a1
9 changed files with 210 additions and 37 deletions
@@ -3,6 +3,7 @@ import { computed, defineComponent, nextTick, reactive, ref, type PropType } fro
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import type { BookCard, SeriesBooksPage, SeriesDetail } from '@bookorbit/types' import type { BookCard, SeriesBooksPage, SeriesDetail } from '@bookorbit/types'
import SeriesDetailView from './SeriesDetailView.vue' import SeriesDetailView from './SeriesDetailView.vue'
import SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media' const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media'
@@ -319,7 +320,7 @@ describe('SeriesDetailView', () => {
expect(wrapper.find('[data-testid="series-media-group-books"]').exists()).toBe(true) expect(wrapper.find('[data-testid="series-media-group-books"]').exists()).toBe(true)
}) })
it('shows the ownership bar only once a provider has supplied a series total', async () => { it('falls back to book counts when a server does not return volume counts', async () => {
mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: 7 })) mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: 7 }))
const wrapper = mountView() const wrapper = mountView()
@@ -328,9 +329,37 @@ describe('SeriesDetailView', () => {
const bar = wrapper.get('[role="progressbar"]') const bar = wrapper.get('[role="progressbar"]')
expect(bar.attributes('aria-valuenow')).toBe('4') expect(bar.attributes('aria-valuenow')).toBe('4')
expect(bar.attributes('aria-valuemax')).toBe('7') expect(bar.attributes('aria-valuemax')).toBe('7')
expect(wrapper.text()).toContain('4 volumes')
const completion = wrapper.getComponent(SeriesCompletionBar)
expect(completion.props('readCount')).toBe(0)
expect(completion.props('totalCount')).toBe(4)
expect(wrapper.text()).toContain('4 of 7') expect(wrapper.text()).toContain('4 of 7')
}) })
it('uses logical volume counts for the header, reading progress, and ownership', async () => {
mocks.seriesInfo = ref(
makeSeriesInfo({
bookCount: 4,
readCount: 2,
ownedVolumeCount: 2,
readVolumeCount: 1,
expectedBookCount: 7,
}),
)
const wrapper = mountView()
await nextTick()
expect(wrapper.text()).toContain('2 volumes')
const completion = wrapper.getComponent(SeriesCompletionBar)
expect(completion.props('readCount')).toBe(1)
expect(completion.props('totalCount')).toBe(2)
const ownership = wrapper.get('[role="progressbar"]')
expect(ownership.attributes('aria-valuenow')).toBe('2')
expect(ownership.attributes('aria-valuemax')).toBe('7')
expect(wrapper.text()).toContain('2 of 7')
})
it('hides the ownership bar when no provider total is known', async () => { it('hides the ownership bar when no provider total is known', async () => {
mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: null })) mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: null }))
@@ -110,6 +110,8 @@ const canEditMetadata = computed(() => hasPermissionInAnyLibrary('library_edit_m
const safeLeadDescription = useSafeHtml(() => leadBook.value?.description) const safeLeadDescription = useSafeHtml(() => leadBook.value?.description)
const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5)) const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5))
const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length)) const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length))
const ownedVolumeCount = computed(() => seriesInfo.value?.ownedVolumeCount ?? seriesInfo.value?.bookCount ?? 0)
const readVolumeCount = computed(() => seriesInfo.value?.readVolumeCount ?? seriesInfo.value?.readCount ?? 0)
const addToCollectionOpen = ref(false) const addToCollectionOpen = ref(false)
const addToCollectionBookId = ref<number | null>(null) const addToCollectionBookId = ref<number | null>(null)
const leadFallbackStyle = computed(() => { const leadFallbackStyle = computed(() => {
@@ -538,7 +540,7 @@ defineOptions({ name: 'SeriesDetailView' })
<div class="min-w-0"> <div class="min-w-0">
<h1 class="text-xl font-bold text-foreground">{{ seriesInfo.name }}</h1> <h1 class="text-xl font-bold text-foreground">{{ seriesInfo.name }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>{{ t('series.detail.bookCount', { count: seriesInfo.bookCount }) }}</span> <span>{{ t('series.card.volumeCount', { count: ownedVolumeCount }) }}</span>
<span v-if="visibleSeriesAuthors.length > 0"> <span v-if="visibleSeriesAuthors.length > 0">
{{ t('series.detail.byAuthors', { authors: visibleSeriesAuthors.join(', ') }) }} {{ t('series.detail.byAuthors', { authors: visibleSeriesAuthors.join(', ') }) }}
<span v-if="hiddenSeriesAuthorsCount > 0"> {{ t('series.detail.moreAuthors', { count: hiddenSeriesAuthorsCount }) }}</span> <span v-if="hiddenSeriesAuthorsCount > 0"> {{ t('series.detail.moreAuthors', { count: hiddenSeriesAuthorsCount }) }}</span>
@@ -558,10 +560,10 @@ defineOptions({ name: 'SeriesDetailView' })
</div> </div>
<div class="mt-3 grid max-w-md gap-3 sm:grid-cols-2"> <div class="mt-3 grid max-w-md gap-3 sm:grid-cols-2">
<SeriesCompletionBar :read-count="seriesInfo.readCount" :total-count="seriesInfo.bookCount" /> <SeriesCompletionBar :read-count="readVolumeCount" :total-count="ownedVolumeCount" />
<SeriesOwnershipBar <SeriesOwnershipBar
v-if="seriesInfo.expectedBookCount !== null" v-if="seriesInfo.expectedBookCount !== null"
:owned-count="seriesInfo.bookCount" :owned-count="ownedVolumeCount"
:expected-count="seriesInfo.expectedBookCount" :expected-count="seriesInfo.expectedBookCount"
/> />
</div> </div>
+4
View File
@@ -71,6 +71,10 @@ export type SeriesDetail = {
name: string; name: string;
bookCount: number; bookCount: number;
readCount: number; readCount: number;
/** Logical volumes owned, collapsing book records that share a whole-numbered series position. */
ownedVolumeCount?: number;
/** Owned logical volumes whose furthest status is read. */
readVolumeCount?: number;
authors: string[]; authors: string[];
possibleGaps: number[]; possibleGaps: number[];
/** Total books a metadata provider reports for the series, or null when no provider has told us. */ /** Total books a metadata provider reports for the series, or null when no provider has told us. */
@@ -275,8 +275,12 @@ describe('SeriesRepository', () => {
it('returns detail row with authors and indices when series is found', async () => { it('returns detail row with authors and indices when series is found', async () => {
const mainChain = makeChain([{ id: 42, name: 'Dune', bookCount: 6, readCount: 2 }]); const mainChain = makeChain([{ id: 42, name: 'Dune', bookCount: 6, readCount: 2 }]);
const indicesChain = makeChain([{ idx: 1 }, { idx: 2 }, { idx: 3 }]); const membersChain = makeChain([
db.select.mockReturnValueOnce(mainChain).mockReturnValueOnce(indicesChain); { seriesIndex: '1', status: 'read' },
{ seriesIndex: '2', status: 'reading' },
{ seriesIndex: null, status: null },
]);
db.select.mockReturnValueOnce(mainChain).mockReturnValueOnce(membersChain);
const authorsMap = new Map([[42, ['Frank Herbert']]]); const authorsMap = new Map([[42, ['Frank Herbert']]]);
vi.spyOn(repo as never, 'fetchAuthorsForSeries').mockResolvedValue(authorsMap); vi.spyOn(repo as never, 'fetchAuthorsForSeries').mockResolvedValue(authorsMap);
@@ -289,7 +293,12 @@ describe('SeriesRepository', () => {
expect(result!.bookCount).toBe(6); expect(result!.bookCount).toBe(6);
expect(result!.readCount).toBe(2); expect(result!.readCount).toBe(2);
expect(result!.authors).toEqual(['Frank Herbert']); expect(result!.authors).toEqual(['Frank Herbert']);
expect(result!.indices).toEqual([1, 2, 3]); expect(result!.indices).toEqual(['1', '2']);
expect(result!.members).toEqual([
{ seriesIndex: '1', status: 'read' },
{ seriesIndex: '2', status: 'reading' },
{ seriesIndex: null, status: null },
]);
}); });
it('returns empty authors when map has no entry for seriesId', async () => { it('returns empty authors when map has no entry for seriesId', async () => {
@@ -303,6 +312,7 @@ describe('SeriesRepository', () => {
expect(result!.authors).toEqual([]); expect(result!.authors).toEqual([]);
expect(result!.indices).toEqual([]); expect(result!.indices).toEqual([]);
expect(result!.members).toEqual([]);
}); });
}); });
+17 -10
View File
@@ -30,14 +30,17 @@ type SeriesSummaryRow = {
libraryNames: string[]; libraryNames: string[];
}; };
/** One book of a series, as the list needs it to draw the volume ladder. */ export type SeriesVolumeMember = {
export type SeriesMemberRow = {
bookId: number;
seriesIndex: string | null; seriesIndex: string | null;
title: string | null;
status: string | null; status: string | null;
}; };
/** One book of a series, as the list needs it to draw the volume ladder. */
export type SeriesMemberRow = SeriesVolumeMember & {
bookId: number;
title: string | null;
};
export type SeriesFacetRow = { export type SeriesFacetRow = {
all: number; all: number;
notStarted: number; notStarted: number;
@@ -69,6 +72,7 @@ type SeriesDetailRow = {
expectedBookCount: number | null; expectedBookCount: number | null;
authors: string[]; authors: string[];
indices: string[]; indices: string[];
members: SeriesVolumeMember[];
}; };
@Injectable() @Injectable()
@@ -281,18 +285,20 @@ export class SeriesRepository {
const row = rows[0]; const row = rows[0];
const [authorsMap, indicesRows] = await Promise.all([ const [authorsMap, memberRows] = await Promise.all([
this.fetchAuthorsForSeries([params.seriesId], params.libraryIds, params.contentFilters), this.fetchAuthorsForSeries([params.seriesId], params.libraryIds, params.contentFilters),
this.db this.db
.select({ idx: bookSeriesMemberships.seriesIndex }) .select({
seriesIndex: bookSeriesMemberships.seriesIndex,
status: userBookStatus.status,
})
.from(books) .from(books)
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id)) .innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.where( .leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
and(eq(bookSeriesMemberships.seriesId, params.seriesId), libraryFilter, ...filterClauses, isNotNull(bookSeriesMemberships.seriesIndex)), .where(and(eq(bookSeriesMemberships.seriesId, params.seriesId), libraryFilter, ...filterClauses)),
),
]); ]);
const indices = indicesRows.map((r) => r.idx!); const indices = memberRows.map((row) => row.seriesIndex).filter((index): index is string => index !== null);
return { return {
id: row.id, id: row.id,
@@ -302,6 +308,7 @@ export class SeriesRepository {
expectedBookCount: row.expectedBookCount ?? null, expectedBookCount: row.expectedBookCount ?? null,
authors: authorsMap.get(params.seriesId) ?? [], authors: authorsMap.get(params.seriesId) ?? [],
indices, indices,
members: memberRows,
}; };
} }
@@ -339,6 +339,35 @@ describe('SeriesService', () => {
expect(result.total).toBe(3); expect(result.total).toBe(3);
}); });
it('reports logical volume counts without changing raw book counts', async () => {
seriesRepo.findDetail.mockResolvedValue({
id: 42,
name: 'Two formats',
bookCount: 4,
readCount: 2,
authors: [],
indices: ['1', '1', '2', '2'],
expectedBookCount: 3,
members: [
{ seriesIndex: '1', status: null },
{ seriesIndex: '1', status: 'read' },
{ seriesIndex: '2', status: 'reading' },
{ seriesIndex: '2', status: 'read' },
],
});
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 4 });
const result = await service.findBooks(reqUser(), 42, {});
expect(result.seriesInfo).toMatchObject({
bookCount: 4,
readCount: 2,
ownedVolumeCount: 2,
readVolumeCount: 2,
possibleGaps: [3],
});
});
it('preserves book order from repository', async () => { it('preserves book order from repository', async () => {
seriesRepo.findDetail.mockResolvedValue({ id: 42, name: 'Test', bookCount: 2, readCount: 0, authors: [], indices: ['1', '2'] }); seriesRepo.findDetail.mockResolvedValue({ id: 42, name: 'Test', bookCount: 2, readCount: 0, authors: [], indices: ['1', '2'] });
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [20, 10], total: 2 }); seriesRepo.findBookIds.mockResolvedValue({ bookIds: [20, 10], total: 2 });
@@ -453,6 +482,8 @@ describe('SeriesService', () => {
expect(result.seriesInfo.authors).toEqual(['Frank Herbert']); expect(result.seriesInfo.authors).toEqual(['Frank Herbert']);
expect(result.seriesInfo.possibleGaps).toEqual([]); expect(result.seriesInfo.possibleGaps).toEqual([]);
expect(result.seriesInfo.bookCount).toBe(0); expect(result.seriesInfo.bookCount).toBe(0);
expect(result.seriesInfo.ownedVolumeCount).toBe(0);
expect(result.seriesInfo.readVolumeCount).toBe(0);
}); });
it('throws 404 when series does not exist in any library', async () => { it('throws 404 when series does not exist in any library', async () => {
+7 -1
View File
@@ -14,7 +14,7 @@ import { ListSeriesBooksDto } from './dto/list-series-books.dto';
import { ListSeriesDto } from './dto/list-series.dto'; import { ListSeriesDto } from './dto/list-series.dto';
import { SeriesRepository } from './series.repository'; import { SeriesRepository } from './series.repository';
import { computeSeriesGaps } from './utils/series-gaps.utils'; import { computeSeriesGaps } from './utils/series-gaps.utils';
import { buildVolumeLadder } from './utils/series-ladder.utils'; import { buildVolumeLadder, countSeriesVolumes } from './utils/series-ladder.utils';
@Injectable() @Injectable()
export class SeriesService { export class SeriesService {
@@ -138,6 +138,8 @@ export class SeriesService {
name: existsInAnyLibrary.name, name: existsInAnyLibrary.name,
bookCount: 0, bookCount: 0,
readCount: 0, readCount: 0,
ownedVolumeCount: 0,
readVolumeCount: 0,
authors: existsInAnyLibrary.authors, authors: existsInAnyLibrary.authors,
possibleGaps: [], possibleGaps: [],
expectedBookCount: existsInAnyLibrary.expectedBookCount ?? null, expectedBookCount: existsInAnyLibrary.expectedBookCount ?? null,
@@ -149,6 +151,9 @@ export class SeriesService {
} }
const possibleGaps = computeSeriesGaps(detail.indices, detail.bookCount, detail.expectedBookCount); const possibleGaps = computeSeriesGaps(detail.indices, detail.bookCount, detail.expectedBookCount);
const volumeCounts = detail.members
? countSeriesVolumes({ members: detail.members, bookCount: detail.bookCount, expectedBookCount: detail.expectedBookCount })
: { ownedVolumeCount: detail.bookCount, readVolumeCount: detail.readCount };
let items: BooksPage['items'] = []; let items: BooksPage['items'] = [];
if (bookPage.bookIds.length > 0) { if (bookPage.bookIds.length > 0) {
@@ -186,6 +191,7 @@ export class SeriesService {
name: detail.name, name: detail.name,
bookCount: detail.bookCount, bookCount: detail.bookCount,
readCount: detail.readCount, readCount: detail.readCount,
...volumeCounts,
authors: detail.authors, authors: detail.authors,
possibleGaps, possibleGaps,
expectedBookCount: detail.expectedBookCount ?? null, expectedBookCount: detail.expectedBookCount ?? null,
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { SeriesMemberRow } from '../series.repository'; import type { SeriesMemberRow } from '../series.repository';
import { computeSeriesGaps, resolveLadderRange } from './series-gaps.utils'; import { computeSeriesGaps, resolveLadderRange } from './series-gaps.utils';
import { buildVolumeLadder } from './series-ladder.utils'; import { buildVolumeLadder, countSeriesVolumes } from './series-ladder.utils';
function member(bookId: number, seriesIndex: string | null, status: string | null = null, title = `Book ${bookId}`): SeriesMemberRow { function member(bookId: number, seriesIndex: string | null, status: string | null = null, title = `Book ${bookId}`): SeriesMemberRow {
return { bookId, seriesIndex, title, status }; return { bookId, seriesIndex, title, status };
@@ -122,3 +122,48 @@ describe('buildVolumeLadder', () => {
}); });
}); });
}); });
describe('countSeriesVolumes', () => {
it('counts ebook and audiobook copies at the same indexes once', () => {
const members = [member(1, '1'), member(2, '1', 'read'), member(3, '2', 'reading'), member(4, '2')];
expect(countSeriesVolumes({ members, bookCount: 4, expectedBookCount: 7 })).toEqual({
ownedVolumeCount: 2,
readVolumeCount: 1,
});
});
it('uses the furthest status of every numbered volume', () => {
const members = [member(1, '1', 'reading'), member(2, '1', 'read'), member(3, '2'), member(4, '2', 'read'), member(5, '3', 'reading')];
expect(countSeriesVolumes({ members, bookCount: 5, expectedBookCount: null })).toEqual({
ownedVolumeCount: 3,
readVolumeCount: 2,
});
});
it('keeps unnumbered and fractional books independent', () => {
const members = [member(1, '1', 'read'), member(2, null, 'read'), member(3, null), member(4, '1.5', 'read'), member(5, '1.5')];
expect(countSeriesVolumes({ members, bookCount: 5, expectedBookCount: null })).toEqual({
ownedVolumeCount: 5,
readVolumeCount: 3,
});
});
it('matches the ladder fallback when a zero index invalidates its numbered range', () => {
const members = [member(1, '0', 'read'), member(2, '1'), member(3, '1', 'read')];
expect(countSeriesVolumes({ members, bookCount: 3, expectedBookCount: null })).toEqual({
ownedVolumeCount: 3,
readVolumeCount: 2,
});
});
it('returns zero counts for an empty series', () => {
expect(countSeriesVolumes({ members: [], bookCount: 0, expectedBookCount: null })).toEqual({
ownedVolumeCount: 0,
readVolumeCount: 0,
});
});
});
@@ -1,7 +1,7 @@
import type { SeriesIndex, SeriesVolumeSlot, SeriesVolumeStatus } from '@bookorbit/types'; import type { SeriesIndex, SeriesVolumeSlot, SeriesVolumeStatus } from '@bookorbit/types';
import { SERIES_VOLUME_SLOT_LIMIT } from '@bookorbit/types'; import { SERIES_VOLUME_SLOT_LIMIT } from '@bookorbit/types';
import type { SeriesMemberRow } from '../series.repository'; import type { SeriesMemberRow, SeriesVolumeMember } from '../series.repository';
import { computeSeriesGaps, resolveLadderRange } from './series-gaps.utils'; import { computeSeriesGaps, resolveLadderRange } from './series-gaps.utils';
export type SeriesLadder = { export type SeriesLadder = {
@@ -11,6 +11,11 @@ export type SeriesLadder = {
next: { bookId: number; index: SeriesIndex | null; title: string | null } | null; next: { bookId: number; index: SeriesIndex | null; title: string | null } | null;
}; };
export type SeriesVolumeCounts = {
ownedVolumeCount: number;
readVolumeCount: number;
};
const STATUS_RANK: Record<SeriesVolumeStatus, number> = { read: 3, reading: 2, unread: 1, missing: 0 }; const STATUS_RANK: Record<SeriesVolumeStatus, number> = { read: 3, reading: 2, unread: 1, missing: 0 };
function memberStatus(status: string | null): SeriesVolumeStatus { function memberStatus(status: string | null): SeriesVolumeStatus {
@@ -25,6 +30,56 @@ function integerIndexOf(value: string | null): number | null {
return Number.isSafeInteger(parsed) ? parsed : null; return Number.isSafeInteger(parsed) ? parsed : null;
} }
function groupSeriesMembers<T extends SeriesVolumeMember>(params: {
members: T[];
bookCount: number;
expectedBookCount: number | null;
}): {
gaps: number[];
range: { from: number; to: number } | undefined;
byIndex: Map<number, T>;
unnumbered: T[];
} {
const { members, bookCount, expectedBookCount } = params;
const indices = members.map((member) => member.seriesIndex).filter((index): index is string => index !== null);
const gaps = computeSeriesGaps(indices, bookCount, expectedBookCount);
const range = resolveLadderRange(indices, bookCount, expectedBookCount);
const byIndex = new Map<number, T>();
const unnumbered: T[] = [];
for (const member of members) {
const index = integerIndexOf(member.seriesIndex);
if (index === null || !range || index < range.from || index > range.to) {
unnumbered.push(member);
continue;
}
const held = byIndex.get(index);
if (!held || STATUS_RANK[memberStatus(member.status)] > STATUS_RANK[memberStatus(held.status)]) {
byIndex.set(index, member);
}
}
return { gaps, range, byIndex, unnumbered };
}
/**
* 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.
*/
export function countSeriesVolumes(params: {
members: SeriesVolumeMember[];
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,
};
}
/** /**
* The volume ladder a list row draws: one slot per number the series should have, plus the books * The volume ladder a list row draws: one slot per number the series should have, plus the books
* that carry no usable number, at the end. * that carry no usable number, at the end.
@@ -51,23 +106,7 @@ export function buildVolumeLadder(params: {
return { volumes: [], truncated: true, gaps: [], next: firstOpenMember(members) }; return { volumes: [], truncated: true, gaps: [], next: firstOpenMember(members) };
} }
const indices = members.map((m) => m.seriesIndex).filter((idx): idx is string => idx !== null); const { gaps, range, byIndex, unnumbered } = groupSeriesMembers({ members, bookCount, expectedBookCount });
const gaps = computeSeriesGaps(indices, bookCount, expectedBookCount);
const range = resolveLadderRange(indices, bookCount, expectedBookCount);
const byIndex = new Map<number, SeriesMemberRow>();
const unnumbered: SeriesMemberRow[] = [];
for (const member of members) {
const index = integerIndexOf(member.seriesIndex);
if (index === null || !range || index < range.from || index > range.to) {
unnumbered.push(member);
continue;
}
const held = byIndex.get(index);
if (!held || STATUS_RANK[memberStatus(member.status)] > STATUS_RANK[memberStatus(held.status)]) {
byIndex.set(index, member);
}
}
const volumes: SeriesVolumeSlot[] = []; const volumes: SeriesVolumeSlot[] = [];
let truncated = false; let truncated = false;