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