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
+123 -10
View File
@@ -1,7 +1,8 @@
import type { BookCard, BooksPage, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import type { BookCard, BooksPage, JumpBucketsResponse, SortSpec, TemporalJumpBucketGranularity } from '@bookorbit/types';
import { eq } from 'drizzle-orm';
import { refreshPrimaryAuthorSortNamesForBooks } from '../src/db/book-author-sort-key';
import { bookAuthors, bookMetadata, books, bookSeries, authors } from '../src/db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookSeries, readingProgress, userBookStatus, users } from '../src/db/schema';
import {
authHeader,
closeMetadataWriteE2EContext,
@@ -19,6 +20,11 @@ type SeededBook = {
authorSortName: string | null;
publishedYear: number | null;
seriesName: string | null;
addedAt: string;
updatedAt: string;
lastReadAt: string | null;
startedAt: string | null;
finishedAt: string | null;
};
// Mirrors the SQL letter bucket expression: empty -> null, A-Z -> letter, else '#'.
@@ -29,13 +35,30 @@ function letterOf(value: string | null): string | null {
return /^[A-Z]$/.test(first) ? first : '#';
}
function expectedBucketKey(book: SeededBook, field: 'title' | 'author' | 'publishedYear', collapsed: boolean): string | null {
if (field === 'publishedYear') return book.publishedYear !== null ? String(book.publishedYear) : null;
function expectedBucketKey(
book: SeededBook,
field: 'title' | 'author' | 'publishedYear',
collapsed: boolean,
granularity: TemporalJumpBucketGranularity | null,
): string | null {
if (field === 'publishedYear') {
if (book.publishedYear === null) return '__unknown__';
const step = granularity?.step ?? 1;
return String(Math.floor(book.publishedYear / step) * step);
}
if (field === 'author') return letterOf(book.authorSortName);
if (collapsed) return letterOf(book.seriesName ?? book.title);
return letterOf(book.title);
}
function expectedTemporalBucketKey(value: string | null, granularity: TemporalJumpBucketGranularity | null): string {
if (value === null) return '__unknown__';
if (!granularity || granularity.unit === 'day') return value;
if (granularity.unit === 'month') return value.slice(0, 7);
const year = Number(value.slice(0, 4));
return String(Math.floor(year / granularity.step) * granularity.step);
}
describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: MetadataWriteE2EContext;
let libraryId!: number;
@@ -59,6 +82,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
folderPath: `/seed/${Math.random().toString(36).slice(2)}`,
status: 'present',
addedAt: input.addedAt,
updatedAt: input.addedAt,
})
.returning({ id: books.id });
@@ -200,10 +224,63 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
authorSortName: spec.author ? (spec.author.sortName ?? spec.author.name) : null,
publishedYear: spec.publishedYear ?? null,
seriesName: spec.series?.name ?? null,
addedAt: spec.addedAt,
updatedAt: spec.addedAt,
lastReadAt: null,
startedAt: null,
finishedAt: null,
});
}
await refreshPrimaryAuthorSortNamesForBooks(ctx.db, allIds);
const [admin] = await ctx.db.select({ id: users.id }).from(users).where(eq(users.username, 'metadata-write-e2e-admin')).limit(1);
if (!admin) throw new Error('e2e admin user not found');
const statusRows = allIds.flatMap((bookId, index) => {
if (index % 5 === 0) return [];
const day = String(index + 1).padStart(2, '0');
const startedAt = `2025-03-${day}`;
const finishedAt = `2025-04-${day}`;
const seeded = seededById.get(bookId)!;
seeded.startedAt = startedAt;
seeded.finishedAt = finishedAt;
return [
{
userId: admin.id,
bookId,
status: 'read' as const,
source: 'manual' as const,
startedAt: new Date(`${startedAt}T12:00:00Z`),
finishedAt: new Date(`${finishedAt}T12:00:00Z`),
},
];
});
await ctx.db.insert(userBookStatus).values(statusRows);
const progressBookIds = allIds.filter((_, index) => index % 5 !== 0);
const insertedFiles = await ctx.db
.insert(bookFiles)
.values(
progressBookIds.map((bookId, index) => ({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `/seed/progress-${bookId}.epub`,
relPath: `progress-${bookId}.epub`,
ino: BigInt(900_000 + index),
format: 'epub',
role: 'content',
})),
)
.returning({ id: bookFiles.id, bookId: bookFiles.bookId });
await ctx.db.insert(readingProgress).values(
insertedFiles.map((file, index) => {
const day = String(index + 1).padStart(2, '0');
const lastReadAt = `2025-05-${day}`;
seededById.get(file.bookId)!.lastReadAt = lastReadAt;
return { bookFileId: file.id, userId: admin.id, percentage: 50, updatedAt: new Date(`${lastReadAt}T12:00:00Z`) };
}),
);
});
afterAll(async () => {
@@ -215,7 +292,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
method: 'POST',
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
headers: authHeader(ctx.adminToken),
payload: { sort, pagination: { page: 0, size: 50 }, ...(collapseSeries ? { collapseSeries: true } : {}) },
payload: { sort, pagination: { page: 0, size: 50 }, maxBuckets: 32, ...(collapseSeries ? { collapseSeries: true } : {}) },
});
expect(response.statusCode).toBe(201);
return response.json() as JumpBucketsResponse;
@@ -246,6 +323,7 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
expect(listing.items.length).toBe(listing.total);
expect(bucketsResponse.total).toBe(listing.total);
expect(bucketsResponse.buckets.length).toBeGreaterThan(1);
expect(bucketsResponse.buckets.length).toBeLessThanOrEqual(32);
let previousIndex = -1;
for (const bucket of bucketsResponse.buckets) {
@@ -256,16 +334,17 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
const itemAtAnchor = listing.items[bucket.index] as BookCard;
const seeded = seededById.get(itemAtAnchor.id);
expect(seeded, `listing item ${itemAtAnchor.id} at index ${bucket.index} must be a seeded book`).toBeDefined();
expect(expectedBucketKey(seeded!, field, collapsed), `bucket ${bucket.key} -> book ${itemAtAnchor.id} (${seeded!.title})`).toBe(
bucket.key,
);
expect(
expectedBucketKey(seeded!, field, collapsed, bucketsResponse.granularity),
`bucket ${bucket.key} -> book ${itemAtAnchor.id} (${seeded!.title})`,
).toBe(bucket.key);
if (bucket.index > 0) {
const itemBefore = listing.items[bucket.index - 1] as BookCard;
const seededBefore = seededById.get(itemBefore.id);
expect(seededBefore).toBeDefined();
expect(
expectedBucketKey(seededBefore!, field, collapsed),
expectedBucketKey(seededBefore!, field, collapsed, bucketsResponse.granularity),
`item before bucket ${bucket.key} must belong to a different bucket`,
).not.toBe(bucket.key);
}
@@ -275,12 +354,46 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
}
}
it('preserves every populated year when sparse years fit the bounded capacity', async () => {
const response = await fetchBuckets([{ field: 'publishedYear', dir: 'asc' }], false);
const expectedYears = [...new Set([...seededById.values()].flatMap((book) => (book.publishedYear === null ? [] : [String(book.publishedYear)])))];
const returnedKeys = new Set(response.buckets.map((bucket) => bucket.key));
expect(response.granularity).toEqual({ unit: 'year', step: 1 });
for (const year of expectedYears) expect(returnedKeys.has(year)).toBe(true);
});
const temporalFields = ['addedAt', 'updatedAt', 'lastReadAt', 'startedAt', 'finishedAt'] as const;
for (const field of temporalFields) {
for (const dir of dirs) {
it(`anchors ${field} ${dir} temporal buckets to exact flat-listing offsets`, async () => {
const sort: SortSpec[] = [{ field, dir }];
const [bucketsResponse, listing] = await Promise.all([fetchBuckets(sort, false), fetchListing(sort, false)]);
expect(bucketsResponse.kind).toBe('temporal');
expect(bucketsResponse.buckets.length).toBeLessThanOrEqual(32);
expect(bucketsResponse.total).toBe(listing.total);
for (const bucket of bucketsResponse.buckets) {
const itemAtAnchor = listing.items[bucket.index] as BookCard;
const seeded = seededById.get(itemAtAnchor.id)!;
expect(expectedTemporalBucketKey(seeded[field], bucketsResponse.granularity)).toBe(bucket.key);
if (bucket.index > 0) {
const itemBefore = listing.items[bucket.index - 1] as BookCard;
const seededBefore = seededById.get(itemBefore.id)!;
expect(expectedTemporalBucketKey(seededBefore[field], bucketsResponse.granularity)).not.toBe(bucket.key);
}
}
});
}
}
it('rejects ineligible sorts with 400', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
headers: authHeader(ctx.adminToken),
payload: { sort: [{ field: 'addedAt', dir: 'desc' }], pagination: { page: 0, size: 50 } },
payload: { sort: [{ field: 'rating', dir: 'desc' }], pagination: { page: 0, size: 50 }, maxBuckets: 32 },
});
expect(response.statusCode).toBe(400);
});