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