feat(books): add adaptive temporal jump rail

Add bounded, user-scoped temporal buckets for supported date sorts and adapt resolution to populated dates. Keep rail navigation responsive with viewport sizing, sparse-gap compression, and virtual-grid synchronization.
This commit is contained in:
neon
2026-07-16 00:41:46 +00:00
parent 014c0af456
commit 5923ea95a5
34 changed files with 1223 additions and 193 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { jumpBucketKindForSort } from "../jump-buckets";
import { jumpBucketKindForSort, temporalJumpBucketPrecisionForSort } from "../jump-buckets";
import type { SortSpec } from "../query";
describe("jumpBucketKindForSort", () => {
@@ -14,14 +14,16 @@ describe("jumpBucketKindForSort", () => {
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 temporal for publishedYear sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "asc" }])).toBe("temporal");
expect(jumpBucketKindForSort([{ field: "publishedYear", dir: "desc" }])).toBe("temporal");
});
it("returns year for publishedDate sorts", () => {
expect(jumpBucketKindForSort([{ field: "publishedDate", dir: "asc" }])).toBe("year");
expect(jumpBucketKindForSort([{ field: "publishedDate", dir: "desc" }])).toBe("year");
it("returns temporal for all supported date sorts", () => {
for (const field of ["addedAt", "updatedAt", "publishedDate", "lastReadAt", "startedAt", "finishedAt"] as const) {
expect(jumpBucketKindForSort([{ field, dir: "asc" }])).toBe("temporal");
expect(jumpBucketKindForSort([{ field, dir: "desc" }])).toBe("temporal");
}
});
it("returns letter for an empty sort (defaults to title asc)", () => {
@@ -29,12 +31,7 @@ describe("jumpBucketKindForSort", () => {
});
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" }],
];
const ineligible: SortSpec[][] = [[{ field: "rating", dir: "desc" }], [{ field: "fileSize", dir: "asc" }], [{ field: "random", dir: "asc" }]];
for (const sort of ineligible) {
expect(jumpBucketKindForSort(sort)).toBeNull();
}
@@ -43,7 +40,7 @@ describe("jumpBucketKindForSort", () => {
it("only considers the primary sort field", () => {
expect(
jumpBucketKindForSort([
{ field: "addedAt", dir: "desc" },
{ field: "rating", dir: "desc" },
{ field: "title", dir: "asc" },
]),
).toBeNull();
@@ -55,3 +52,20 @@ describe("jumpBucketKindForSort", () => {
).toBe("letter");
});
});
describe("temporalJumpBucketPrecisionForSort", () => {
it("keeps publication sorts at year precision", () => {
expect(temporalJumpBucketPrecisionForSort([{ field: "publishedDate", dir: "asc" }])).toBe("year");
expect(temporalJumpBucketPrecisionForSort([{ field: "publishedYear", dir: "asc" }])).toBe("year");
});
it("uses full date precision for exact timestamps", () => {
for (const field of ["addedAt", "updatedAt", "lastReadAt", "startedAt", "finishedAt"] as const) {
expect(temporalJumpBucketPrecisionForSort([{ field, dir: "asc" }])).toBe("date");
}
});
it("returns null for non-temporal sorts", () => {
expect(temporalJumpBucketPrecisionForSort([{ field: "title", dir: "asc" }])).toBeNull();
});
});
+38 -2
View File
@@ -1,6 +1,13 @@
import type { SortField, SortSpec } from "./query";
import type { BookQuery, SortField, SortSpec } from "./query";
export type JumpBucketKind = "letter" | "year";
export type JumpBucketKind = "letter" | "temporal";
export type TemporalJumpBucketUnit = "day" | "month" | "year";
export type TemporalJumpBucketGranularity = {
unit: TemporalJumpBucketUnit;
step: number;
};
/**
* One jump target on the rail. `index` is the absolute 0-based row index of the
@@ -12,18 +19,42 @@ export type JumpBucket = {
key: string;
label: string;
index: number;
isUnknown?: boolean;
};
export type JumpBucketsResponse = {
buckets: JumpBucket[];
total: number;
kind: JumpBucketKind;
granularity: TemporalJumpBucketGranularity | null;
};
export type JumpBucketsQuery = BookQuery & {
maxBuckets: number;
};
const KIND_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpBucketKind>> = {
title: "letter",
author: "letter",
addedAt: "temporal",
updatedAt: "temporal",
publishedDate: "temporal",
publishedYear: "temporal",
lastReadAt: "temporal",
startedAt: "temporal",
finishedAt: "temporal",
};
export type TemporalJumpBucketPrecision = "date" | "year";
const TEMPORAL_PRECISION_BY_SORT_FIELD: Partial<Record<SortField, TemporalJumpBucketPrecision>> = {
addedAt: "date",
updatedAt: "date",
publishedDate: "year",
publishedYear: "year",
lastReadAt: "date",
startedAt: "date",
finishedAt: "date",
};
/**
@@ -37,3 +68,8 @@ export function jumpBucketKindForSort(sort: SortSpec[]): JumpBucketKind | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return KIND_BY_PRIMARY_SORT_FIELD[primary.field] ?? null;
}
export function temporalJumpBucketPrecisionForSort(sort: SortSpec[]): TemporalJumpBucketPrecision | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
return TEMPORAL_PRECISION_BY_SORT_FIELD[primary.field] ?? null;
}