feat(achievements): rebuild the page as a trophy cabinet with hints

Replace the card grid with medals, a showcase, category shelves, a record book and a
detail sheet. Add GET /achievements/hints, which explains how to unlock what is still
locked from the user's own library and reading history, backed by a new page-count
index. Year-Round Reader now reports its months read as progress.
This commit is contained in:
neon
2026-10-16 15:44:16 +00:00
parent c544a28e77
commit 83f17d6ff8
66 changed files with 28937 additions and 1143 deletions
+269
View File
@@ -0,0 +1,269 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import type { AchievementHint, AchievementHintsResponse } from '@bookorbit/types';
import * as schema from '../src/db/schema';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createLibraryWithFolder,
createReadingSession,
createUserAndLogin,
grantLibraryAccess,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
const SCENARIO_TIMEOUT_MS = 60_000;
const DAY_MS = 86_400_000;
interface SeededBook {
bookId: number;
fileId: number;
}
let inoSequence = 950_000;
async function seedBook(
ctx: AuthorizationMatrixE2EContext,
library: CreatedLibrary,
options: { title: string; year?: number; pages?: number; language?: string; authorId?: number },
): Promise<SeededBook> {
const slug = `${options.title.toLowerCase().replaceAll(' ', '-')}-${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({ libraryId: library.libraryId, libraryFolderId: library.libraryFolderId, folderPath: `${library.folderPath}/${slug}` })
.returning({ id: schema.books.id });
const bookId = book!.id;
await ctx.db.insert(schema.bookMetadata).values({
bookId,
title: options.title,
publishedYear: options.year ?? null,
pageCount: options.pages ?? null,
language: options.language ?? null,
});
inoSequence += 1;
const [file] = await ctx.db
.insert(schema.bookFiles)
.values({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `${library.folderPath}/${slug}/${slug}.epub`,
relPath: `${slug}/${slug}.epub`,
ino: BigInt(inoSequence),
sizeBytes: 2048,
format: 'epub',
role: 'content',
})
.returning({ id: schema.bookFiles.id });
await ctx.db.update(schema.books).set({ primaryFileId: file!.id }).where(eq(schema.books.id, bookId));
if (options.authorId) await ctx.db.insert(schema.bookAuthors).values({ bookId, authorId: options.authorId });
return { bookId, fileId: file!.id };
}
async function createAuthor(ctx: AuthorizationMatrixE2EContext, name: string): Promise<number> {
const [author] = await ctx.db
.insert(schema.authors)
.values({ name: `${name} ${randomUUID()}` })
.returning({ id: schema.authors.id });
return author!.id;
}
describe('Achievement hints (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: AuthorizationMatrixE2EContext;
let reader!: TestUserSession;
let stranger!: TestUserSession;
let authorId!: number;
const seeded: Record<string, SeededBook> = {};
async function fetchHints(token: string): Promise<AchievementHint[]> {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/achievements/hints', headers: authHeader(token) });
expect(response.statusCode).toBe(200);
return (response.json() as AchievementHintsResponse).hints;
}
function hintFor(hints: AchievementHint[], key: string): AchievementHint | undefined {
return hints.find((hint) => hint.key === key);
}
beforeAll(async () => {
ctx = await createAuthorizationMatrixE2EContext();
const shelf = await createLibraryWithFolder(ctx, { name: `hints-shelf-${randomUUID()}` });
const hidden = await createLibraryWithFolder(ctx, { name: `hints-hidden-${randomUUID()}` });
reader = await createUserAndLogin(ctx);
stranger = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, reader.userId, shelf.libraryId);
await grantLibraryAccess(ctx, stranger.userId, shelf.libraryId);
authorId = await createAuthor(ctx, 'Hint Author');
const otherAuthorId = await createAuthor(ctx, 'Other Author');
seeded.finishedRated = await seedBook(ctx, shelf, { title: 'Finished Rated', year: 2015, pages: 250, language: 'English', authorId });
seeded.finishedUnrated = await seedBook(ctx, shelf, {
title: 'Finished Unrated',
year: 2021,
pages: 250,
language: 'English',
authorId: otherAuthorId,
});
seeded.stalled = await seedBook(ctx, shelf, { title: 'Stalled Saga', year: 2018, pages: 400, language: 'English' });
seeded.nearly = await seedBook(ctx, shelf, { title: 'Nearly There', year: 2019, pages: 200, language: 'English' });
seeded.ninetiesShort = await seedBook(ctx, shelf, { title: 'Nineties Novella', year: 1994, pages: 80, language: 'Spanish', authorId });
seeded.ninetiesLong = await seedBook(ctx, shelf, { title: 'Nineties Epic', year: 1997, pages: 280, language: 'English' });
seeded.variant = await seedBook(ctx, shelf, { title: 'Variant Spelling', year: 2016, pages: 220, language: 'en-US', authorId });
seeded.sixties = await seedBook(ctx, shelf, { title: 'Sixties', year: 1965, pages: 220 });
seeded.seventies = await seedBook(ctx, shelf, { title: 'Seventies', year: 1972, pages: 220 });
seeded.eighties = await seedBook(ctx, shelf, { title: 'Eighties', year: 1988, pages: 220 });
seeded.missing = await seedBook(ctx, shelf, { title: 'Gone Missing', year: 1994, pages: 60, language: 'Italian', authorId });
await ctx.db.update(schema.books).set({ status: 'missing' }).where(eq(schema.books.id, seeded.missing.bookId));
// Everything here would change an answer if it leaked past library access.
seeded.hiddenOld = await seedBook(ctx, hidden, { title: 'Hidden Classic', year: 1850, pages: 1200, language: 'French', authorId });
seeded.hiddenNineties = await seedBook(ctx, hidden, { title: 'Hidden Nineties', year: 1991, pages: 50, language: 'German', authorId });
const now = Date.now();
await ctx.db.insert(schema.userBookStatus).values([
{ userId: reader.userId, bookId: seeded.finishedRated.bookId, status: 'read', source: 'manual', finishedAt: new Date(now - 20 * DAY_MS) },
{ userId: reader.userId, bookId: seeded.finishedUnrated.bookId, status: 'read', source: 'manual', finishedAt: new Date(now - 2 * DAY_MS) },
{ userId: reader.userId, bookId: seeded.stalled.bookId, status: 'reading', startedAt: new Date(now - 120 * DAY_MS) },
{ userId: reader.userId, bookId: seeded.nearly.bookId, status: 'reading', startedAt: new Date(now - 10 * DAY_MS) },
]);
await ctx.db.insert(schema.userBookRatings).values({ userId: reader.userId, bookId: seeded.finishedRated.bookId, rating: 4 });
await ctx.db.insert(schema.readingProgress).values([
{ userId: reader.userId, bookFileId: seeded.stalled.fileId, percentage: 80.6 },
{ userId: reader.userId, bookFileId: seeded.nearly.fileId, percentage: 95 },
]);
const sessionStart = new Date(now - 3 * DAY_MS);
await createReadingSession(ctx, {
userId: reader.userId,
bookId: seeded.nearly.bookId,
bookFileId: seeded.nearly.fileId,
startedAt: sessionStart,
endedAt: new Date(sessionStart.getTime() + 2 * 3600 * 1000),
});
});
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
it('refuses an anonymous request', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/achievements/hints' });
expect(response.statusCode).toBe(401);
});
it('measures the reading pace toward the next hours tier', async () => {
const hints = await fetchHints(reader.accessToken);
expect(hintFor(hints, 'hours_read_1')).toEqual({
key: 'hours_read_1',
kind: 'reading_pace',
values: { hours: 2, days: 30, remaining: 8, etaDays: 120 },
});
});
it('names the stalled book, its progress and how to resume it', async () => {
const hints = await fetchHints(reader.accessToken);
const hint = hintFor(hints, 'slow_burn');
expect(hint).toMatchObject({
kind: 'stalled_book',
count: 1,
bookIds: [seeded.stalled.bookId],
values: { title: 'Stalled Saga', percent: 80, resume: { bookId: seeded.stalled.bookId, fileId: seeded.stalled.fileId, format: 'epub' } },
});
});
it('counts books past 90% and finished books without a rating', async () => {
const hints = await fetchHints(reader.accessToken);
expect(hintFor(hints, 'books_finished_1')).toMatchObject({ kind: 'nearly_finished', count: 1, bookIds: [seeded.nearly.bookId] });
expect(hintFor(hints, 'critic_1')).toMatchObject({
kind: 'unrated_finished',
count: 1,
bookIds: [seeded.finishedUnrated.bookId],
bookTitles: ['Finished Unrated'],
});
expect(hintFor(hints, 'first_verdict')).toMatchObject({ kind: 'unrated_finished', count: 1 });
});
it('suggests the largest unfinished decade from the libraries the reader can open', async () => {
const hints = await fetchHints(reader.accessToken);
const hint = hintFor(hints, 'decade_sampler');
expect(hint).toMatchObject({ kind: 'period_unread', count: 2, values: { period: 1990, years: 10 } });
expect([...(hint?.bookIds ?? [])].sort()).toEqual([seeded.ninetiesShort.bookId, seeded.ninetiesLong.bookId].sort());
});
it('keeps hidden and missing books out of every count', async () => {
const hints = await fetchHints(reader.accessToken);
const hiddenIds = new Set([seeded.hiddenOld.bookId, seeded.hiddenNineties.bookId, seeded.missing.bookId]);
for (const hint of hints) {
for (const bookId of hint.bookIds ?? []) expect(hiddenIds.has(bookId)).toBe(false);
}
expect(hintFor(hints, 'old_soul')).toEqual({ key: 'old_soul', kind: 'unavailable', values: { reason: 'nothing_before_year', year: 1900 } });
expect(hintFor(hints, 'century_span')).toEqual({
key: 'century_span',
kind: 'unavailable',
values: { reason: 'too_few_periods', years: 100, reachable: 2 },
});
expect(hintFor(hints, 'long_book_1')).toMatchObject({
kind: 'long_books',
count: 1,
bookIds: [seeded.stalled.bookId],
values: { minPages: 300 },
});
expect(hintFor(hints, 'short_story_fan')).toMatchObject({
kind: 'short_books',
count: 1,
bookIds: [seeded.ninetiesShort.bookId],
values: { maxPages: 100, inProgress: 0 },
});
});
it('names the author to read more of and a language not read yet', async () => {
const hints = await fetchHints(reader.accessToken);
const author = hintFor(hints, 'author_deep_dive');
expect(author).toMatchObject({ kind: 'author_unread', count: 2, values: { authorId, finished: 1 } });
expect([...(author?.bookIds ?? [])].sort()).toEqual([seeded.ninetiesShort.bookId, seeded.variant.bookId].sort());
expect(hintFor(hints, 'polyglot_1')).toEqual({
key: 'polyglot_1',
kind: 'language_unread',
count: 1,
bookIds: [seeded.ninetiesShort.bookId],
bookTitles: ['Nineties Novella'],
values: { language: 'es' },
});
});
it('answers the year-round reader from the calendar so far', async () => {
const hints = await fetchHints(reader.accessToken);
const now = new Date();
const hint = hintFor(hints, 'year_round_reader');
if (now.getUTCMonth() === 0) {
expect(hint).toMatchObject({ kind: 'months_left', count: 12 });
} else {
expect(hint).toEqual({
key: 'year_round_reader',
kind: 'unavailable',
values: { reason: 'missed_month', year: now.getUTCFullYear(), month: 1 },
});
}
});
it("never shows one reader's history to another", async () => {
const hints = await fetchHints(stranger.accessToken);
expect(hintFor(hints, 'slow_burn')).toBeUndefined();
expect(hintFor(hints, 'critic_1')).toBeUndefined();
expect(hintFor(hints, 'books_finished_1')).toBeUndefined();
expect(hintFor(hints, 'hours_read_1')).toBeUndefined();
expect(hintFor(hints, 'author_deep_dive')).toBeUndefined();
});
});
@@ -1,7 +1,7 @@
{
"totalRoutes": 650,
"totalRoutes": 651,
"byPermission": {
"(none)": 282,
"(none)": 283,
"Permission.ManageMetadataConfig": 38,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 35,
@@ -491,6 +491,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/achievement/achievement.controller.ts",
"className": "AchievementController",
"methodName": "getHints",
"httpMethod": "GET",
"path": "/achievements/hints",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": ["JwtAuthGuard"]
},
{
"file": "server/src/modules/achievement/achievement.controller.ts",
"className": "AchievementController",