feat(books): add jump rail navigation to book listings (#318)

Add server-backed jump buckets so large library, collection, and smart-scope listings can jump directly to sorted sections without loading every row up front.

Closes #163
This commit is contained in:
neon
2026-06-11 23:16:58 -06:00
committed by GitHub
parent 43e417b66e
commit b1dff917ad
77 changed files with 14212 additions and 2279 deletions
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { jumpBucketKindForSort } from "../jump-buckets";
import type { SortSpec } from "../query";
describe("jumpBucketKindForSort", () => {
it("returns letter for title sorts in both directions", () => {
expect(jumpBucketKindForSort([{ field: "title", dir: "asc" }])).toBe("letter");
expect(jumpBucketKindForSort([{ field: "title", dir: "desc" }])).toBe("letter");
});
it("returns letter for author sorts in both directions", () => {
expect(jumpBucketKindForSort([{ field: "author", dir: "asc" }])).toBe("letter");
expect(jumpBucketKindForSort([{ field: "author", dir: "desc" }])).toBe("letter");
});
it("returns year for publishedYear sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "asc" }])).toBe("year");
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "desc" }])).toBe("year");
});
it("returns letter for an empty sort (defaults to title asc)", () => {
expect(jumpBucketKindForSort([])).toBe("letter");
});
it("returns null for ineligible primary sort fields", () => {
const ineligible: SortSpec[][] = [
[{ field: "addedAt", dir: "desc" }],
[{ field: "rating", dir: "desc" }],
[{ field: "fileSize", dir: "asc" }],
[{ field: "random", dir: "asc" }],
];
for (const sort of ineligible) {
expect(jumpBucketKindForSort(sort)).toBeNull();
}
});
it("only considers the primary sort field", () => {
expect(
jumpBucketKindForSort([
{ field: "addedAt", dir: "desc" },
{ field: "title", dir: "asc" },
]),
).toBeNull();
expect(
jumpBucketKindForSort([
{ field: "title", dir: "asc" },
{ field: "addedAt", dir: "desc" },
]),
).toBe("letter");
});
});
+1
View File
@@ -16,6 +16,7 @@ export * from "./library";
export * from "./metadata-fetch";
export * from "./pattern-resolver";
export * from "./query";
export * from "./jump-buckets";
export * from "./reader-settings";
export * from "./scanner";
export * from "./opds";
+38
View File
@@ -0,0 +1,38 @@
import type { SortField, SortSpec } from "./query";
export type JumpBucketKind = "letter" | "year";
/**
* One jump target on the rail. `index` is the absolute 0-based row index of the
* first book in this bucket under the listing's exact sort order, so a jump is
* just scroll-to-index. Buckets are returned in display order (already reversed
* for descending sorts).
*/
export type JumpBucket = {
key: string;
label: string;
index: number;
};
export type JumpBucketsResponse = {
buckets: JumpBucket[];
total: number;
};
const KIND_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpBucketKind>> = {
title: "letter",
author: "letter",
publishedYear: "year",
};
/**
* Single source of truth for rail eligibility, shared by the client (gate the
* rail and bucket fetches) and the server (validate + pick bucket expression).
* Only the primary sort field matters: secondary sorts reorder rows within
* equal primary values and cannot move bucket boundaries. An empty sort means
* title ascending, mirroring the server's default.
*/
export function jumpBucketKindForSort(sort: SortSpec[]): JumpBucketKind | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return KIND_BY_PRIMARY_SORT_FIELD[primary.field] ?? null;
}