feat(series): rebuild the list as a card and a list view sharing one volume track

Both views are toggled from the header and read from the same ladder: one
segment per volume the series should have, coloured read, reading, in library
or missing. Gaps were previously only on the series detail page, so the list
could never say a volume was absent; a Gaps filter now decides that in SQL,
mirroring the existing gap finder branch for branch so the two surfaces can
never disagree.

The card's fanned covers are laid out from cached percentages against an
aspect-ratio box, so nothing is measured at runtime and hover spreads the fan
through one custom property instead of per-cover reactive state.

See docs/COMMIT_GUIDELINES.md for the API additions to SeriesSummary.
This commit is contained in:
neon
2026-08-27 04:11:10 +00:00
parent b41675c608
commit c1794b677c
32 changed files with 2696 additions and 921 deletions
+48
View File
@@ -1,6 +1,29 @@
import type { BooksPage } from "./book";
import type { SeriesIndex } from "./series-index";
export type SeriesVolumeStatus = "read" | "reading" | "unread" | "missing";
/**
* One position on a series' volume ladder. A `missing` slot is a number the series should
* have and the library does not; every other slot has a book behind it. Slots with no usable
* index sit at the end with `index: null`.
*/
export type SeriesVolumeSlot = {
index: number | null;
bookId: number | null;
title: string | null;
status: SeriesVolumeStatus;
};
/**
* Upper bound on the ladder a list row carries. A longer series still reports its true counts;
* only the per-slot detail is cut, flagged by `volumesTruncated`.
*/
export const SERIES_VOLUME_SLOT_LIMIT = 60;
/** Missing numbers named in a list row before the rest collapse into `gapCount`. */
export const SERIES_GAP_PREVIEW_LIMIT = 8;
export type SeriesSummary = {
id: number;
name: string;
@@ -9,6 +32,30 @@ export type SeriesSummary = {
authors: string[];
coverBookIds: number[];
lastAddedAt: string | null;
/** Books the user has in progress. */
readingCount: number;
/** Libraries the series spans, in name order. Usually one. */
libraryNames: string[];
/** Total a metadata provider reports for the series, or null when no provider has said. */
expectedBookCount: number | null;
volumes: SeriesVolumeSlot[];
volumesTruncated: boolean;
/** Missing numbers, capped at {@link SERIES_GAP_PREVIEW_LIMIT}. */
gaps: number[];
gapCount: number;
/** The volume to open next: the one in progress, else the first unread. */
nextBookId: number | null;
nextIndex: SeriesIndex | null;
nextTitle: string | null;
};
/** Counts for the whole result set, ignoring the active completion filter. */
export type SeriesFacets = {
all: number;
notStarted: number;
inProgress: number;
complete: number;
hasGaps: number;
};
export type SeriesPage = {
@@ -16,6 +63,7 @@ export type SeriesPage = {
total: number;
page: number;
size: number;
facets: SeriesFacets;
};
export type SeriesDetail = {