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
@@ -0,0 +1 @@
CREATE INDEX "bm_page_count_idx" ON "book_metadata" USING btree ("page_count");
File diff suppressed because it is too large Load Diff
@@ -799,6 +799,13 @@
"when": 1792002996105,
"tag": "0113_add_djvu_email_format",
"breakpoints": true
},
{
"idx": 114,
"version": "7",
"when": 1792163623360,
"tag": "0114_add_book_metadata_page_count_index",
"breakpoints": true
}
]
}
+1
View File
@@ -115,6 +115,7 @@ export const bookMetadata = pgTable(
index('bm_language_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.language}) gin_trgm_ops`),
index('bm_published_date_idx').on(t.publishedDate),
index('bm_published_year_idx').on(t.publishedYear),
index('bm_page_count_idx').on(t.pageCount),
index('bm_published_date_sort_idx').on(sql`coalesce(${t.publishedDate}, make_date(${t.publishedYear}, 1, 1))`),
index('bm_series_name_index_idx').on(
t.seriesName,
@@ -0,0 +1,420 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, notInArray, or, sql, type SQL } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules, ReadStatus } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
audiobookProgress,
authors,
bookAuthors,
bookFiles,
bookMetadata,
books,
readingProgress,
readingSessions,
userBookRatings,
userBookStatus,
userLibraryAccess,
userReadingDailyStats,
} from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
/** Who is asking, and so which books a hint may count and show. */
export interface HintScope {
userId: number;
isSuperuser: boolean;
contentFilters?: ContentFilterRules;
}
export interface HintBookGroup {
count: number;
/** Best first: books already in progress, then the most recently added. At most three. */
bookIds: number[];
/** Titles of `bookIds`, in the same order. */
bookTitles: (string | null)[];
}
export interface HintPeriodGroup extends HintBookGroup {
/** First year of the decade or century. */
period: number;
}
export interface HintLanguageGroup extends HintBookGroup {
language: string;
}
export interface HintResumeRow {
bookId: number;
fileId: number | null;
format: string | null;
}
export interface HintStalledBook extends HintResumeRow {
title: string | null;
percent: number | null;
startedAt: Date | null;
/** Every in-progress book that qualifies, not just this one. */
total: number;
}
export interface HintNearlyFinished extends HintBookGroup {
lead: HintResumeRow | null;
}
export interface HintAuthor extends HintBookGroup {
authorId: number;
name: string;
finished: number;
}
export interface HintPageCountGroup extends HintBookGroup {
inProgress: number;
}
const HINT_BOOK_LIMIT = 3;
// Statuses a reader has closed out. Everything else, including no status at all, is still to read.
const CLOSED_STATUSES: ReadStatus[] = ['read', 'skimmed', 'abandoned'];
const IN_PROGRESS_STATUSES: ReadStatus[] = ['reading', 'rereading', 'on_hold'];
/**
* Queries behind the achievement "how to get it" hints. Each reads one bounded slice of the user's own
* library or history through an index and returns counts plus at most three book ids, never a list.
*/
@Injectable()
export class AchievementHintsRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async sumReadingSecondsSince(userId: number, since: Date): Promise<number> {
const [row] = await this.db
.select({ value: sql<string | null>`sum(${readingSessions.durationSeconds})` })
.from(readingSessions)
.where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since)));
return Number(row?.value ?? 0);
}
async findMonthsWithReading(userId: number, year: number): Promise<number[]> {
const month = sql<number>`extract(month from ${userReadingDailyStats.day})::int`;
const rows = await this.db
.selectDistinct({ month })
.from(userReadingDailyStats)
.where(
and(
eq(userReadingDailyStats.userId, userId),
gte(userReadingDailyStats.day, sql`${`${year}-01-01`}::date`),
lt(userReadingDailyStats.day, sql`${`${year + 1}-01-01`}::date`),
gt(userReadingDailyStats.sessionsCount, 0),
),
);
return rows.map((row) => Number(row.month)).sort((a, b) => a - b);
}
/** First years of the decades (10) or centuries (100) the user has finished a book from. */
async findFinishedPeriods(userId: number, years: 10 | 100): Promise<number[]> {
const period = periodOf(years);
const rows = await this.db
.select({ period })
.from(userBookStatus)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, userBookStatus.bookId))
.where(and(eq(userBookStatus.userId, userId), eq(userBookStatus.status, 'read'), isNotNull(bookMetadata.publishedYear)))
.groupBy(period);
return rows.map((row) => Number(row.period)).sort((a, b) => a - b);
}
/**
* Unfinished books grouped by a decade or century the user has not finished yet, largest first. The
* finished periods are skipped as year ranges, so the year index only visits books in the others.
*/
async findUnfinishedByPeriod(scope: HintScope, years: 10 | 100, finishedPeriods: number[]): Promise<HintPeriodGroup[]> {
const period = periodOf(years);
const rows = await this.db
.select({ period, count: sql<number>`count(*)::int`, bookIds: topBookIds(), bookTitles: topBookTitles() })
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, scope.userId)))
.where(and(this.visibleTo(scope), stillToRead(), outsidePeriods(finishedPeriods, years)))
.groupBy(period)
.orderBy(desc(sql`count(*)`), asc(period));
return rows.map((row) => ({
period: Number(row.period),
count: Number(row.count),
bookIds: row.bookIds ?? [],
bookTitles: row.bookTitles ?? [],
}));
}
/** The in-progress book started before `startedBefore` that is furthest along, and how many qualify. */
async findStalledBook(scope: HintScope, startedBefore: Date): Promise<HintStalledBook | null> {
const percent = mergedPercent();
const [row] = await this.db
.select({
bookId: books.id,
fileId: books.primaryFileId,
format: bookFiles.format,
title: bookMetadata.title,
startedAt: userBookStatus.startedAt,
percent,
total: sql<number>`count(*) over ()::int`,
})
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, books.primaryFileId), eq(readingProgress.userId, scope.userId)))
.leftJoin(audiobookProgress, and(eq(audiobookProgress.bookId, books.id), eq(audiobookProgress.userId, scope.userId)))
.where(
and(
eq(userBookStatus.userId, scope.userId),
inArray(userBookStatus.status, IN_PROGRESS_STATUSES),
lt(userBookStatus.startedAt, startedBefore),
this.visibleTo(scope),
),
)
.orderBy(sql`${percent} desc nulls last`, asc(userBookStatus.startedAt), desc(books.id))
.limit(1);
if (!row) return null;
return { ...row, percent: row.percent == null ? null : Number(row.percent), total: Number(row.total) };
}
/** Finished books with no rating, most recently finished first. */
async findUnratedFinished(scope: HintScope): Promise<HintBookGroup> {
const rows = await this.db
.select({ bookId: books.id, title: bookMetadata.title, total: sql<number>`count(*) over ()::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, scope.userId)))
.where(and(eq(userBookStatus.userId, scope.userId), eq(userBookStatus.status, 'read'), isNull(userBookRatings.rating), this.visibleTo(scope)))
.orderBy(sql`${userBookStatus.finishedAt} desc nulls last`, desc(books.id))
.limit(HINT_BOOK_LIMIT);
return { count: Number(rows[0]?.total ?? 0), bookIds: rows.map((row) => row.bookId), bookTitles: rows.map((row) => row.title) };
}
/**
* Unfinished books at or past `minPercent`, furthest first. Driven by the user's own progress rows, then
* checked against the same percentage a book card shows: the fresher of text and audio progress.
*/
async findNearlyFinished(scope: HintScope, minPercent: number): Promise<HintNearlyFinished> {
const percent = mergedPercent();
const candidates = this.db
.select({ bookId: bookFiles.bookId })
.from(readingProgress)
.innerJoin(bookFiles, eq(bookFiles.id, readingProgress.bookFileId))
.where(and(eq(readingProgress.userId, scope.userId), gte(readingProgress.percentage, minPercent), lt(readingProgress.percentage, 100)))
.union(
this.db
.select({ bookId: audiobookProgress.bookId })
.from(audiobookProgress)
.where(
and(eq(audiobookProgress.userId, scope.userId), gte(audiobookProgress.percentage, minPercent), lt(audiobookProgress.percentage, 100)),
),
);
const rows = await this.db
.select({
bookId: books.id,
title: bookMetadata.title,
fileId: books.primaryFileId,
format: bookFiles.format,
total: sql<number>`count(*) over ()::int`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, books.primaryFileId), eq(readingProgress.userId, scope.userId)))
.leftJoin(audiobookProgress, and(eq(audiobookProgress.bookId, books.id), eq(audiobookProgress.userId, scope.userId)))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, scope.userId)))
.where(and(inArray(books.id, candidates), this.visibleTo(scope), stillToRead(), gte(percent, minPercent), lt(percent, 100)))
.orderBy(desc(percent), desc(books.id))
.limit(HINT_BOOK_LIMIT);
const lead = rows[0];
return {
count: Number(lead?.total ?? 0),
bookIds: rows.map((row) => row.bookId),
bookTitles: rows.map((row) => row.title),
lead: lead ? { bookId: lead.bookId, fileId: lead.fileId, format: lead.format } : null,
};
}
/** Unfinished books with a known page count inside the bounds, through the page-count index. */
async findUnfinishedByPageCount(scope: HintScope, bounds: { below?: number; atLeast?: number }): Promise<HintPageCountGroup> {
const conditions: SQL[] = [isNotNull(bookMetadata.pageCount), gt(bookMetadata.pageCount, 0)];
if (bounds.below !== undefined) conditions.push(lt(bookMetadata.pageCount, bounds.below));
if (bounds.atLeast !== undefined) conditions.push(gte(bookMetadata.pageCount, bounds.atLeast));
const [row] = await this.db
.select({
count: sql<number>`count(*)::int`,
inProgress: sql<number>`(count(*) filter (where ${inArray(userBookStatus.status, IN_PROGRESS_STATUSES)}))::int`,
bookIds: topBookIds(),
bookTitles: topBookTitles(),
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, scope.userId)))
.where(and(this.visibleTo(scope), stillToRead(), ...conditions));
return {
count: Number(row?.count ?? 0),
inProgress: Number(row?.inProgress ?? 0),
bookIds: row?.bookIds ?? [],
bookTitles: row?.bookTitles ?? [],
};
}
async findUnfinishedPublishedBefore(scope: HintScope, year: number): Promise<HintBookGroup> {
const [row] = await this.db
.select({ count: sql<number>`count(*)::int`, bookIds: topBookIds(), bookTitles: topBookTitles() })
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, scope.userId)))
.where(and(this.visibleTo(scope), stillToRead(), isNotNull(bookMetadata.publishedYear), lt(bookMetadata.publishedYear, year)));
return { count: Number(row?.count ?? 0), bookIds: row?.bookIds ?? [], bookTitles: row?.bookTitles ?? [] };
}
/**
* The author worth reading more of: among authors with finished books but fewer than `target`, the one
* whose unfinished books get closest to `target`, then the one already read most. Bounded by the
* authors of the user's finished books.
*/
async findAuthorToDeepen(scope: HintScope, target: number): Promise<HintAuthor | null> {
const finishedByAuthor = this.db.$with('finished_by_author').as(
this.db
.select({ authorId: bookAuthors.authorId, finished: sql<number>`count(*)::int`.as('finished') })
.from(userBookStatus)
.innerJoin(bookAuthors, eq(bookAuthors.bookId, userBookStatus.bookId))
.where(and(eq(userBookStatus.userId, scope.userId), eq(userBookStatus.status, 'read')))
.groupBy(bookAuthors.authorId)
.having(sql`count(*) < ${target}`),
);
const unread = sql<number>`count(${books.id})::int`;
const [row] = await this.db
.with(finishedByAuthor)
.select({
authorId: finishedByAuthor.authorId,
name: authors.name,
finished: finishedByAuthor.finished,
count: unread,
bookIds: topBookIds(),
bookTitles: topBookTitles(),
})
.from(finishedByAuthor)
.innerJoin(authors, eq(authors.id, finishedByAuthor.authorId))
.innerJoin(bookAuthors, eq(bookAuthors.authorId, finishedByAuthor.authorId))
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, scope.userId)))
.where(and(this.visibleTo(scope), stillToRead()))
.groupBy(finishedByAuthor.authorId, authors.name, finishedByAuthor.finished)
.orderBy(sql`least(${finishedByAuthor.finished} + ${unread}, ${target}) desc`, desc(finishedByAuthor.finished), desc(unread), asc(authors.name))
.limit(1);
if (!row) return null;
return { ...row, finished: Number(row.finished), count: Number(row.count), bookIds: row.bookIds ?? [], bookTitles: row.bookTitles ?? [] };
}
/** Language values on the user's finished books, as stored and in the database's collation order. */
async findFinishedLanguages(userId: number): Promise<string[]> {
const rows = await this.db
.selectDistinct({ language: bookMetadata.language })
.from(userBookStatus)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, userBookStatus.bookId))
.where(
and(eq(userBookStatus.userId, userId), eq(userBookStatus.status, 'read'), isNotNull(bookMetadata.language), ne(bookMetadata.language, '')),
)
.orderBy(asc(bookMetadata.language));
return rows.map((row) => row.language).filter((language): language is string => language !== null);
}
/**
* Unfinished books grouped by stored language, skipping `finishedLanguages` as ranges of the language
* index. The values must come in the database's collation order, as `findFinishedLanguages` returns them.
*/
async findUnfinishedByLanguage(scope: HintScope, finishedLanguages: string[]): Promise<HintLanguageGroup[]> {
const rows = await this.db
.select({ language: bookMetadata.language, count: sql<number>`count(*)::int`, bookIds: topBookIds(), bookTitles: topBookTitles() })
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, scope.userId)))
.where(and(this.visibleTo(scope), stillToRead(), ne(bookMetadata.language, ''), outsideValues(bookMetadata.language, finishedLanguages)))
.groupBy(bookMetadata.language)
.orderBy(desc(sql`count(*)`), asc(bookMetadata.language))
.limit(50);
return rows
.filter((row): row is typeof row & { language: string } => row.language !== null)
.map((row) => ({ language: row.language, count: Number(row.count), bookIds: row.bookIds ?? [], bookTitles: row.bookTitles ?? [] }));
}
/** Present books in libraries the user can open, minus anything their content filters hide. */
private visibleTo(scope: HintScope): SQL {
const clauses: SQL[] = [eq(books.status, 'present')];
if (!scope.isSuperuser) {
clauses.push(
inArray(
books.libraryId,
this.db.select({ libraryId: userLibraryAccess.libraryId }).from(userLibraryAccess).where(eq(userLibraryAccess.userId, scope.userId)),
),
);
if (scope.contentFilters) clauses.push(...buildContentFilterClauses(scope.contentFilters, this.db));
}
return and(...clauses)!;
}
}
/** Needs `user_book_status` left-joined for the user. */
function stillToRead(): SQL {
return or(isNull(userBookStatus.status), notInArray(userBookStatus.status, CLOSED_STATUSES))!;
}
/** Up to three book ids, in-progress first, then newest. Needs `user_book_status` left-joined for the user. */
function topBookIds(): SQL<number[] | null> {
return sql<number[] | null>`(array_agg(${books.id} order by ${topBookOrder()}))[1:${sql.raw(String(HINT_BOOK_LIMIT))}]`;
}
/** Titles for `topBookIds()`, in the same order. Needs `book_metadata` joined. */
function topBookTitles(): SQL<(string | null)[] | null> {
return sql<(string | null)[] | null>`(array_agg(${bookMetadata.title} order by ${topBookOrder()}))[1:${sql.raw(String(HINT_BOOK_LIMIT))}]`;
}
function topBookOrder(): SQL {
return sql`case when ${inArray(userBookStatus.status, IN_PROGRESS_STATUSES)} then 0 else 1 end, ${books.addedAt} desc, ${books.id} desc`;
}
// The width is inlined, not bound: Postgres cannot match a GROUP BY expression whose parameters differ from the select's.
function periodOf(years: 10 | 100): SQL<number> {
const width = sql.raw(String(years === 100 ? 100 : 10));
return sql<number>`(floor(${bookMetadata.publishedYear} / ${width}) * ${width})::int`;
}
/** Published years outside the given periods, as ranges the year index can serve. */
function outsidePeriods(periods: number[], years: 10 | 100): SQL {
const ranges: SQL[] = [];
let from: number | null = null;
for (const start of [...new Set(periods)].sort((a, b) => a - b)) {
if (from === null) ranges.push(lt(bookMetadata.publishedYear, start));
else if (from < start) ranges.push(and(gte(bookMetadata.publishedYear, from), lt(bookMetadata.publishedYear, start))!);
from = start + years;
}
ranges.push(from === null ? isNotNull(bookMetadata.publishedYear) : gte(bookMetadata.publishedYear, from));
return or(...ranges)!;
}
/** A column outside the given values, as ranges between them. `sorted` must follow the column's collation. */
function outsideValues(column: typeof bookMetadata.language, sorted: string[]): SQL {
if (sorted.length === 0) return isNotNull(column);
const ranges: SQL[] = [lt(column, sorted[0])];
for (let index = 1; index < sorted.length; index++) ranges.push(and(gt(column, sorted[index - 1]), lt(column, sorted[index]))!);
ranges.push(gt(column, sorted[sorted.length - 1]));
return or(...ranges)!;
}
/**
* The percentage a book card shows: text progress on the primary file or audiobook progress, whichever
* moved last. Needs both left-joined for the user.
*/
function mergedPercent(): SQL<number | null> {
return sql<number | null>`(case
when ${readingProgress.percentage} is not null and ${audiobookProgress.percentage} is not null
then case when ${readingProgress.lastReadAt} >= ${audiobookProgress.updatedAt} then ${readingProgress.percentage} else ${audiobookProgress.percentage} end
else coalesce(${readingProgress.percentage}, ${audiobookProgress.percentage})
end)`;
}
@@ -0,0 +1,367 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import type { AchievementHint } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import type { AchievementRow } from '../../db/schema';
import { AchievementHintsService, languageKey, nextTargets } from './achievement-hints.service';
import { ACHIEVEMENT_SEED } from './seed/achievement-seed';
const CATALOGUE: AchievementRow[] = ACHIEVEMENT_SEED.map((row) => ({
key: row.key,
groupKey: row.groupKey ?? null,
tier: row.tier ?? null,
category: row.category,
name: row.name,
description: row.description,
iconName: row.iconName,
rarity: row.rarity,
threshold: row.threshold ?? null,
hidden: row.hidden ?? false,
sortOrder: row.sortOrder,
}));
const NOW = new Date('2026-10-16T12:00:00Z');
const DAY_MS = 86_400_000;
function makeAchievementRepo(earned: string[] = []) {
return {
findAllAchievements: vi.fn().mockResolvedValue(CATALOGUE),
findUserEarnedKeys: vi.fn().mockResolvedValue(new Set(earned)),
sumReadingHours: vi.fn().mockResolvedValue(0),
};
}
function makeHintsRepo() {
return {
sumReadingSecondsSince: vi.fn().mockResolvedValue(0),
findMonthsWithReading: vi.fn().mockResolvedValue([]),
findFinishedPeriods: vi.fn().mockResolvedValue([]),
findUnfinishedByPeriod: vi.fn().mockResolvedValue([]),
findStalledBook: vi.fn().mockResolvedValue(null),
findUnratedFinished: vi.fn().mockResolvedValue({ count: 0, bookIds: [] }),
findNearlyFinished: vi.fn().mockResolvedValue({ count: 0, bookIds: [], lead: null }),
findUnfinishedByPageCount: vi.fn().mockResolvedValue({ count: 0, inProgress: 0, bookIds: [] }),
findUnfinishedPublishedBefore: vi.fn().mockResolvedValue({ count: 0, bookIds: [] }),
findAuthorToDeepen: vi.fn().mockResolvedValue(null),
findFinishedLanguages: vi.fn().mockResolvedValue([]),
findUnfinishedByLanguage: vi.fn().mockResolvedValue([]),
};
}
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
return { id: 7, isSuperuser: false, settings: { timezone: 'UTC' }, contentFilters: EMPTY_CONTENT_FILTER_RULES, ...overrides } as RequestUser;
}
function hintFor(hints: AchievementHint[], key: string): AchievementHint | undefined {
return hints.find((hint) => hint.key === key);
}
describe('AchievementHintsService', () => {
let achievements: ReturnType<typeof makeAchievementRepo>;
let hints: ReturnType<typeof makeHintsRepo>;
let service: AchievementHintsService;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
achievements = makeAchievementRepo();
hints = makeHintsRepo();
service = new AchievementHintsService(achievements as never, hints as never);
});
afterEach(() => {
vi.useRealTimers();
});
it('has only reasons something is out of reach when the library and history offer nothing', async () => {
const { hints: result } = await service.getHints(makeUser());
expect(result.map((hint) => [hint.key, hint.kind])).toEqual([
['long_book_1', 'unavailable'],
['old_soul', 'unavailable'],
['year_round_reader', 'unavailable'],
]);
});
it('speaks to the next unearned tier of a set and skips achievements already earned', async () => {
achievements = makeAchievementRepo(['hours_read_1', 'hours_read_2', 'slow_burn']);
service = new AchievementHintsService(achievements as never, hints as never);
achievements.sumReadingHours.mockResolvedValue(474);
hints.sumReadingSecondsSince.mockResolvedValue(44.3 * 3600);
hints.findStalledBook.mockResolvedValue({ bookId: 1, title: 'x', percent: 50, startedAt: new Date(), fileId: 2, format: 'epub', total: 1 });
const { hints: result } = await service.getHints(makeUser());
expect(hintFor(result, 'hours_read_3')).toEqual({
key: 'hours_read_3',
kind: 'reading_pace',
values: { hours: 44, days: 30, remaining: 26, etaDays: 18 },
});
expect(hintFor(result, 'slow_burn')).toBeUndefined();
expect(hints.findStalledBook).not.toHaveBeenCalled();
expect(hints.sumReadingSecondsSince).toHaveBeenCalledWith(7, new Date(NOW.getTime() - 30 * DAY_MS));
});
it('leaves out a reading pace under an hour', async () => {
hints.sumReadingSecondsSince.mockResolvedValue(40 * 60);
const { hints: result } = await service.getHints(makeUser());
expect(hintFor(result, 'hours_read_1')).toBeUndefined();
});
it('names the in-progress book that would earn Slow Burn, with a way back into it', async () => {
const startedAt = new Date('2026-01-09T07:12:00Z');
hints.findStalledBook.mockResolvedValue({
bookId: 112,
title: ' The Elements ',
percent: 93.36,
startedAt,
fileId: 118,
format: 'epub',
total: 17,
});
const { hints: result } = await service.getHints(makeUser());
expect(hints.findStalledBook).toHaveBeenCalledWith(expect.objectContaining({ userId: 7 }), new Date(NOW.getTime() - 90 * DAY_MS));
expect(hintFor(result, 'slow_burn')).toEqual({
key: 'slow_burn',
kind: 'stalled_book',
count: 17,
bookIds: [112],
bookTitles: ['The Elements'],
values: { title: 'The Elements', percent: 93, startedAt: startedAt.toISOString(), resume: { bookId: 112, fileId: 118, format: 'epub' } },
});
});
it('offers no resume target for a book without a primary file', async () => {
hints.findNearlyFinished.mockResolvedValue({ count: 2, bookIds: [5, 6], lead: { bookId: 5, fileId: null, format: null } });
const { hints: result } = await service.getHints(makeUser());
expect(hintFor(result, 'books_finished_1')).toEqual({
key: 'books_finished_1',
kind: 'nearly_finished',
count: 2,
bookIds: [5, 6],
values: { minPercent: 90, resume: null },
});
});
it('loads unrated finished books once for both rating achievements', async () => {
hints.findUnratedFinished.mockResolvedValue({ count: 45, bookIds: [163, 837, 317] });
const { hints: result } = await service.getHints(makeUser());
expect(hints.findUnratedFinished).toHaveBeenCalledTimes(1);
expect(hintFor(result, 'critic_1')).toMatchObject({ kind: 'unrated_finished', count: 45, bookIds: [163, 837, 317] });
expect(hintFor(result, 'first_verdict')).toMatchObject({ kind: 'unrated_finished', count: 45 });
});
it('marks a long-book tier unavailable when no unfinished book is long enough', async () => {
achievements = makeAchievementRepo(['long_book_1', 'page_turner', 'long_book_3']);
service = new AchievementHintsService(achievements as never, hints as never);
hints.findUnfinishedByPageCount.mockImplementation((_scope, bounds: { atLeast?: number }) =>
Promise.resolve(bounds.atLeast ? { count: 0, inProgress: 0, bookIds: [] } : { count: 8, inProgress: 2, bookIds: [663, 37, 674] }),
);
const { hints: result } = await service.getHints(makeUser());
expect(hints.findUnfinishedByPageCount).toHaveBeenCalledWith(expect.anything(), { atLeast: 1000 });
expect(hintFor(result, 'tome_tamer')).toEqual({ key: 'tome_tamer', kind: 'unavailable', values: { reason: 'no_long_book', pages: 1000 } });
expect(hintFor(result, 'short_story_fan')).toEqual({
key: 'short_story_fan',
kind: 'short_books',
count: 8,
bookIds: [663, 37, 674],
values: { maxPages: 100, inProgress: 2 },
});
});
it('points at the largest unfinished decade and says when too few centuries exist', async () => {
hints.findFinishedPeriods.mockImplementation((_userId: number, years: number) =>
Promise.resolve(years === 10 ? [1980, 2000, 2010, 2020] : [1900, 2000]),
);
hints.findUnfinishedByPeriod.mockImplementation((_scope, years: number) =>
Promise.resolve(
years === 10
? [
{ period: 1990, count: 17, bookIds: [614, 532, 496] },
{ period: 1970, count: 4, bookIds: [1] },
]
: [],
),
);
const { hints: result } = await service.getHints(makeUser());
expect(hints.findUnfinishedByPeriod).toHaveBeenCalledWith(expect.anything(), 10, [1980, 2000, 2010, 2020]);
expect(hintFor(result, 'decade_sampler')).toEqual({
key: 'decade_sampler',
kind: 'period_unread',
count: 17,
bookIds: [614, 532, 496],
values: { period: 1990, years: 10 },
});
expect(hintFor(result, 'century_span')).toEqual({
key: 'century_span',
kind: 'unavailable',
values: { reason: 'too_few_periods', years: 100, reachable: 2 },
});
});
it('marks Old Soul unavailable when nothing before 1900 is left to read', async () => {
const { hints: result } = await service.getHints(makeUser());
expect(hints.findUnfinishedPublishedBefore).toHaveBeenCalledWith(expect.anything(), 1900);
expect(hintFor(result, 'old_soul')).toEqual({ key: 'old_soul', kind: 'unavailable', values: { reason: 'nothing_before_year', year: 1900 } });
});
it('names the author to read more of', async () => {
hints.findAuthorToDeepen.mockResolvedValue({ authorId: 231, name: 'S.D. Perry', finished: 1, count: 10, bookIds: [337, 334, 232] });
const { hints: result } = await service.getHints(makeUser());
expect(hints.findAuthorToDeepen).toHaveBeenCalledWith(expect.anything(), 5);
expect(hintFor(result, 'author_deep_dive')).toEqual({
key: 'author_deep_dive',
kind: 'author_unread',
count: 10,
bookIds: [337, 334, 232],
values: { authorId: 231, author: 'S.D. Perry', finished: 1 },
});
});
it('suggests a language not read at all, treating spellings of one language as one', async () => {
hints.findFinishedLanguages.mockResolvedValue(['en', 'English']);
hints.findUnfinishedByLanguage.mockResolvedValue([
{ language: 'en-US', count: 40, bookIds: [1, 2, 3], bookTitles: ['A', 'B', 'C'] },
{ language: 'eng', count: 9, bookIds: [4], bookTitles: ['D'] },
{ language: 'Spanish', count: 2, bookIds: [100, 236], bookTitles: ['Cien años', null] },
{ language: 'es', count: 1, bookIds: [7], bookTitles: ['Rayuela'] },
{ language: 'UND', count: 30, bookIds: [8], bookTitles: ['E'] },
{ language: 'it', count: 2, bookIds: [227], bookTitles: ['F'] },
]);
const { hints: result } = await service.getHints(makeUser());
expect(hints.findUnfinishedByLanguage).toHaveBeenCalledWith(expect.anything(), ['en', 'English']);
expect(hintFor(result, 'polyglot_1')).toEqual({
key: 'polyglot_1',
kind: 'language_unread',
count: 3,
bookIds: [100, 236, 7],
bookTitles: ['Cien años', null, 'Rayuela'],
values: { language: 'es' },
});
});
it('skips decade, century and language hints before a first finished book', async () => {
const { hints: result } = await service.getHints(makeUser());
expect(hints.findUnfinishedByPeriod).not.toHaveBeenCalled();
expect(hints.findUnfinishedByLanguage).not.toHaveBeenCalled();
expect(hintFor(result, 'decade_sampler')).toBeUndefined();
expect(hintFor(result, 'century_span')).toBeUndefined();
expect(hintFor(result, 'polyglot_1')).toBeUndefined();
});
it('lists the months left this year when none has been missed', async () => {
hints.findMonthsWithReading.mockResolvedValue([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const { hints: result } = await service.getHints(makeUser());
expect(hints.findMonthsWithReading).toHaveBeenCalledWith(7, 2026);
expect(hintFor(result, 'year_round_reader')).toEqual({
key: 'year_round_reader',
kind: 'months_left',
count: 2,
values: { year: 2026, monthsRead: 10, months: [11, 12] },
});
});
it('says the year is out of reach once a month has passed without reading', async () => {
hints.findMonthsWithReading.mockResolvedValue([1, 2, 4, 5, 6, 7, 8, 9, 10]);
const { hints: result } = await service.getHints(makeUser());
expect(hintFor(result, 'year_round_reader')).toEqual({
key: 'year_round_reader',
kind: 'unavailable',
values: { reason: 'missed_month', year: 2026, month: 3 },
});
});
it("reads the year and month in the user's timezone", async () => {
vi.setSystemTime(new Date('2026-12-31T23:30:00Z'));
await service.getHints(makeUser({ settings: { timezone: 'Pacific/Kiritimati' } }));
expect(hints.findMonthsWithReading).toHaveBeenCalledWith(7, 2027);
});
it('leaves out a hint whose query fails and keeps the rest', async () => {
hints.findStalledBook.mockRejectedValue(new Error('boom'));
hints.findUnratedFinished.mockResolvedValue({ count: 3, bookIds: [1] });
const { hints: result } = await service.getHints(makeUser());
expect(hintFor(result, 'slow_burn')).toBeUndefined();
expect(hintFor(result, 'critic_1')).toBeDefined();
});
it('applies content filters to regular users and not to superusers', async () => {
const contentFilters = { ...EMPTY_CONTENT_FILTER_RULES, excludeGenreIds: [4] };
await service.getHints(makeUser({ contentFilters }));
expect(hints.findNearlyFinished).toHaveBeenLastCalledWith({ userId: 7, isSuperuser: false, contentFilters }, 90);
await service.getHints(makeUser({ isSuperuser: true, contentFilters }));
expect(hints.findNearlyFinished).toHaveBeenLastCalledWith({ userId: 7, isSuperuser: true, contentFilters: undefined }, 90);
});
});
describe('nextTargets', () => {
it('keeps the lowest unearned tier of each set and every unearned single achievement', () => {
const targets = nextTargets(CATALOGUE, new Set(['books_finished_1', 'books_finished_3', 'slow_burn']));
expect(targets.get('books_finished')?.key).toBe('books_finished_2');
expect(targets.get('decade_sampler')?.key).toBe('decade_sampler');
expect(targets.has('slow_burn')).toBe(false);
});
it('never targets a secret achievement', () => {
const targets = nextTargets(CATALOGUE, new Set());
expect(targets.has('all_nighter')).toBe(false);
expect(targets.has('reborn')).toBe(false);
});
it('has nothing for a set with every tier earned', () => {
const targets = nextTargets(CATALOGUE, new Set(['critic_1', 'critic_2', 'critic_3', 'critic_4']));
expect(targets.has('critic')).toBe(false);
});
});
describe('languageKey', () => {
it.each([
['en', 'en'],
['English', 'en'],
['english', 'en'],
['eng', 'en'],
['en-US', 'en'],
['Spanish', 'es'],
['spa', 'es'],
['ja-jp', 'ja'],
['ger', 'de'],
])('reads %s as %s', (value, key) => {
expect(languageKey(value)).toBe(key);
});
it.each(['', ' ', 'UND', 'mul', 'zxx', '1234'])('has no key for %j', (value) => {
expect(languageKey(value)).toBeNull();
});
});
@@ -0,0 +1,289 @@
import { Injectable, Logger } from '@nestjs/common';
import { REQUEST_LANGUAGE_CODES, normalizeLanguage } from '@bookorbit/types';
import type { AchievementHint, AchievementHintResumeTarget, AchievementHintsResponse } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveTimeZone, toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
import type { RequestUser } from '../../common/types/request-user';
import type { AchievementRow } from '../../db/schema';
import { AchievementRepository } from './achievement.repository';
import { AchievementHintsRepository, type HintBookGroup, type HintResumeRow, type HintScope } from './achievement-hints.repository';
import { OLD_SOUL_BEFORE_YEAR, SHORT_STORY_MAX_PAGES } from './evaluators/exploration.evaluator';
import { SLOW_BURN_DAYS } from './evaluators/reading.evaluator';
const DAY_MS = 86_400_000;
const PACE_DAYS = 30;
const NEARLY_FINISHED_PERCENT = 90;
const HINT_BOOK_LIMIT = 3;
type HintBuilder = (target: AchievementRow) => Promise<AchievementHint | null>;
/**
* "How to get it" hints for achievements that are still locked, drawn from the user's own library and
* reading history. Separate from the catalogue so the page never waits on them, and best effort: a hint
* whose query fails is left out rather than failing the rest.
*/
@Injectable()
export class AchievementHintsService {
private readonly logger = new Logger(AchievementHintsService.name);
constructor(
private readonly achievements: AchievementRepository,
private readonly hints: AchievementHintsRepository,
) {}
async getHints(user: RequestUser): Promise<AchievementHintsResponse> {
const [catalogue, earnedKeys] = await Promise.all([this.achievements.findAllAchievements(), this.achievements.findUserEarnedKeys(user.id)]);
const targets = nextTargets(catalogue, earnedKeys);
const scope: HintScope = { userId: user.id, isSuperuser: user.isSuperuser, contentFilters: user.isSuperuser ? undefined : user.contentFilters };
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
let unrated: Promise<HintBookGroup> | null = null;
const unratedFinished = (): Promise<HintBookGroup> => (unrated ??= this.hints.findUnratedFinished(scope));
const builders: Record<string, HintBuilder> = {
hours_read: (target) => this.readingPace(user.id, target),
books_finished: (target) => this.nearlyFinished(scope, target),
long_book: (target) => this.longBooks(scope, target),
slow_burn: (target) => this.stalledBook(scope, target),
critic: (target) => this.unrated(target, unratedFinished),
first_verdict: (target) => this.unrated(target, unratedFinished),
decade_sampler: (target) => this.period(scope, target, 10),
century_span: (target) => this.period(scope, target, 100),
short_story_fan: (target) => this.shortBooks(scope, target),
old_soul: (target) => this.oldBooks(scope, target),
author_deep_dive: (target) => this.author(scope, target),
polyglot: (target) => this.language(scope, target),
year_round_reader: (target) => this.monthsLeft(user.id, timeZone, target),
};
const built = await Promise.all(
Object.entries(builders).flatMap(([id, build]) => {
const target = targets.get(id);
return target ? [this.settle(user.id, target.key, () => build(target))] : [];
}),
);
return { hints: built.filter((hint): hint is AchievementHint => hint !== null) };
}
private async settle(userId: number, key: string, build: () => Promise<AchievementHint | null>): Promise<AchievementHint | null> {
const startedAt = Date.now();
try {
return await build();
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[achievement.hint] [fail] userId=${userId} key=${key} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - hint left out`,
);
return null;
}
}
private async readingPace(userId: number, target: AchievementRow): Promise<AchievementHint | null> {
const since = new Date(Date.now() - PACE_DAYS * DAY_MS);
const [seconds, totalHours] = await Promise.all([this.hints.sumReadingSecondsSince(userId, since), this.achievements.sumReadingHours(userId)]);
const hours = seconds / 3600;
const remaining = (target.threshold ?? 0) - totalHours;
if (hours < 1 || remaining <= 0) return null;
return {
key: target.key,
kind: 'reading_pace',
values: { hours: Math.round(hours), days: PACE_DAYS, remaining, etaDays: Math.ceil((remaining * PACE_DAYS) / hours) },
};
}
private async nearlyFinished(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
const group = await this.hints.findNearlyFinished(scope, NEARLY_FINISHED_PERCENT);
if (group.count === 0) return null;
return {
key: target.key,
kind: 'nearly_finished',
count: group.count,
bookIds: group.bookIds,
bookTitles: group.bookTitles,
values: { minPercent: NEARLY_FINISHED_PERCENT, resume: resumeTarget(group.lead) },
};
}
private async longBooks(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
const pages = target.threshold;
if (!pages) return null;
const group = await this.hints.findUnfinishedByPageCount(scope, { atLeast: pages });
if (group.count === 0) return { key: target.key, kind: 'unavailable', values: { reason: 'no_long_book', pages } };
return {
key: target.key,
kind: 'long_books',
count: group.count,
bookIds: group.bookIds,
bookTitles: group.bookTitles,
values: { minPages: pages },
};
}
private async stalledBook(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
const book = await this.hints.findStalledBook(scope, new Date(Date.now() - SLOW_BURN_DAYS * DAY_MS));
if (!book?.startedAt) return null;
const title = book.title?.trim() || null;
return {
key: target.key,
kind: 'stalled_book',
count: book.total,
bookIds: [book.bookId],
bookTitles: [title],
values: {
title,
percent: book.percent === null ? null : Math.floor(book.percent),
startedAt: book.startedAt.toISOString(),
resume: resumeTarget(book),
},
};
}
private async unrated(target: AchievementRow, load: () => Promise<HintBookGroup>): Promise<AchievementHint | null> {
const group = await load();
if (group.count === 0) return null;
return { key: target.key, kind: 'unrated_finished', count: group.count, bookIds: group.bookIds, bookTitles: group.bookTitles, values: {} };
}
private async period(scope: HintScope, target: AchievementRow, years: 10 | 100): Promise<AchievementHint | null> {
const needed = target.threshold;
if (!needed) return null;
const finished = await this.hints.findFinishedPeriods(scope.userId, years);
// Before a first finished book the advice is just to finish one, and the query would span the whole library.
if (finished.length === 0) return null;
const groups = await this.hints.findUnfinishedByPeriod(scope, years, finished);
const reachable = finished.length + groups.length;
if (reachable < needed) return { key: target.key, kind: 'unavailable', values: { reason: 'too_few_periods', years, reachable } };
const best = groups[0];
if (!best) return null;
return {
key: target.key,
kind: 'period_unread',
count: best.count,
bookIds: best.bookIds,
bookTitles: best.bookTitles,
values: { period: best.period, years },
};
}
private async shortBooks(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
const group = await this.hints.findUnfinishedByPageCount(scope, { below: SHORT_STORY_MAX_PAGES });
if (group.count === 0) return null;
return {
key: target.key,
kind: 'short_books',
count: group.count,
bookIds: group.bookIds,
bookTitles: group.bookTitles,
values: { maxPages: SHORT_STORY_MAX_PAGES, inProgress: group.inProgress },
};
}
private async oldBooks(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
const group = await this.hints.findUnfinishedPublishedBefore(scope, OLD_SOUL_BEFORE_YEAR);
if (group.count === 0) return { key: target.key, kind: 'unavailable', values: { reason: 'nothing_before_year', year: OLD_SOUL_BEFORE_YEAR } };
return {
key: target.key,
kind: 'old_books',
count: group.count,
bookIds: group.bookIds,
bookTitles: group.bookTitles,
values: { year: OLD_SOUL_BEFORE_YEAR },
};
}
private async author(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
if (!target.threshold) return null;
const author = await this.hints.findAuthorToDeepen(scope, target.threshold);
if (!author || author.count === 0) return null;
return {
key: target.key,
kind: 'author_unread',
count: author.count,
bookIds: author.bookIds,
bookTitles: author.bookTitles,
values: { authorId: author.authorId, author: author.name, finished: author.finished },
};
}
/**
* Polyglot counts stored language values, so "en" and "English" already count as two. A hint that
* suggested "en-US" to someone who reads English would be gaming that, so languages are compared as one
* key per language here, and the hint names a language the user has not read at all.
*/
private async language(scope: HintScope, target: AchievementRow): Promise<AchievementHint | null> {
const finished = await this.hints.findFinishedLanguages(scope.userId);
if (finished.length === 0) return null;
const groups = await this.hints.findUnfinishedByLanguage(scope, finished);
const finishedKeys = new Set(finished.map(languageKey));
const merged = new Map<string, HintBookGroup>();
for (const group of groups) {
const key = languageKey(group.language);
if (!key || finishedKeys.has(key)) continue;
const entry = merged.get(key) ?? { count: 0, bookIds: [], bookTitles: [] };
entry.count += group.count;
entry.bookIds.push(...group.bookIds);
entry.bookTitles.push(...group.bookTitles);
merged.set(key, entry);
}
const [language, best] = [...merged.entries()].sort((a, b) => b[1].count - a[1].count)[0] ?? [];
if (!language || !best) return null;
return {
key: target.key,
kind: 'language_unread',
count: best.count,
bookIds: best.bookIds.slice(0, HINT_BOOK_LIMIT),
bookTitles: best.bookTitles.slice(0, HINT_BOOK_LIMIT),
values: { language },
};
}
private async monthsLeft(userId: number, timeZone: string, target: AchievementRow): Promise<AchievementHint | null> {
const [year, month] = toDateKeyInTimeZone(new Date(), timeZone).split('-').map(Number);
const read = new Set(await this.hints.findMonthsWithReading(userId, year));
for (let past = 1; past < month; past++) {
if (!read.has(past)) return { key: target.key, kind: 'unavailable', values: { reason: 'missed_month', year, month: past } };
}
const months: number[] = [];
for (let ahead = month; ahead <= 12; ahead++) if (!read.has(ahead)) months.push(ahead);
if (months.length === 0) return null;
return { key: target.key, kind: 'months_left', count: months.length, values: { year, monthsRead: read.size, months } };
}
}
/**
* The achievement each hint speaks to, by group key for tier sets (its lowest unearned tier) and by key
* for single achievements. Earned and still-secret achievements get none.
*/
export function nextTargets(catalogue: AchievementRow[], earnedKeys: Set<string>): Map<string, AchievementRow> {
const targets = new Map<string, AchievementRow>();
for (const row of catalogue) {
if (earnedKeys.has(row.key) || row.hidden) continue;
const id = row.groupKey ?? row.key;
const current = targets.get(id);
if (!current || (row.tier ?? 0) < (current.tier ?? 0)) targets.set(id, row);
}
return targets;
}
let languagesByName: Map<string, string> | null = null;
/**
* One key per language, so "en", "eng", "en-US" and "English" agree. Null for values that name no
* language, like "und".
*/
export function languageKey(value: string): string | null {
const lowered = value.trim().toLowerCase();
if (!lowered || lowered === 'und' || lowered === 'mul' || lowered === 'zxx') return null;
if (!languagesByName) {
const names = new Intl.DisplayNames(['en'], { type: 'language' });
languagesByName = new Map(REQUEST_LANGUAGE_CODES.map((code) => [(names.of(code) ?? code).toLowerCase(), code]));
}
const code = languagesByName.get(lowered) ?? normalizeLanguage(lowered);
return /^[a-z]{2}$/.test(code) ? code : null;
}
function resumeTarget(row: HintResumeRow | null): AchievementHintResumeTarget | null {
return row?.fileId ? { bookId: row.bookId, fileId: row.fileId, format: row.format } : null;
}
@@ -1,11 +1,12 @@
import { Controller, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common';
import type { AchievementCatalogueResponse, AchievementCelebrationClaim } from '@bookorbit/types';
import type { AchievementCatalogueResponse, AchievementCelebrationClaim, AchievementHintsResponse } from '@bookorbit/types';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { AchievementService } from './achievement.service';
import { AchievementBackfillService, type BackfillResult } from './achievement-backfill.service';
import { AchievementHintsService } from './achievement-hints.service';
@Controller('achievements')
@UseGuards(JwtAuthGuard)
@@ -13,6 +14,7 @@ export class AchievementController {
constructor(
private readonly service: AchievementService,
private readonly backfillService: AchievementBackfillService,
private readonly hintsService: AchievementHintsService,
) {}
@Get()
@@ -20,6 +22,11 @@ export class AchievementController {
return this.service.getCatalogue(user);
}
@Get('hints')
async getHints(@CurrentUser() user: RequestUser): Promise<AchievementHintsResponse> {
return this.hintsService.getHints(user);
}
@Post('celebrations/claim')
async claimCelebration(@CurrentUser() user: RequestUser): Promise<AchievementCelebrationClaim | null> {
return this.service.claimCelebration(user);
@@ -6,6 +6,8 @@ import { UserModule } from '../user/user.module';
import { AchievementController } from './achievement.controller';
import { AchievementService } from './achievement.service';
import { AchievementBackfillService } from './achievement-backfill.service';
import { AchievementHintsRepository } from './achievement-hints.repository';
import { AchievementHintsService } from './achievement-hints.service';
import { AchievementRepository } from './achievement.repository';
import { AchievementEventsService } from './achievement-events.service';
import { EvaluatorRegistry } from './evaluators/evaluator-registry';
@@ -23,7 +25,9 @@ import { DevicesEvaluator } from './evaluators/devices.evaluator';
providers: [
AchievementService,
AchievementBackfillService,
AchievementHintsService,
AchievementRepository,
AchievementHintsRepository,
AchievementEventsService,
EvaluatorRegistry,
ReadingEvaluator,
@@ -40,6 +40,7 @@ function makeRepo() {
countBooksFinishedInYear: vi.fn().mockResolvedValue(0),
countDistinctEarnedCategories: vi.fn().mockResolvedValue(0),
countDistinctSeasonsWithReading: vi.fn().mockResolvedValue(0),
countDistinctMonthsWithReading: vi.fn().mockResolvedValue(0),
};
}
@@ -480,6 +481,16 @@ describe('AchievementService', () => {
expect(item?.currentProgress).toBe(2);
});
it('returns year_round_reader progress as months read this year', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('year_round_reader', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctMonthsWithReading.mockResolvedValue(10);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'year_round_reader');
expect(item?.currentProgress).toBe(10);
expect(repo.countDistinctMonthsWithReading).toHaveBeenCalledWith(1, new Date().getUTCFullYear());
});
it('returns null progress for consistent_reader', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('consistent_reader', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
@@ -415,6 +415,8 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
return this.repo.countBooksFinishedInYear(userId, getYearInTimeZone(new Date(), timeZone));
case 'category_sweeper':
return this.repo.countDistinctEarnedCategories(userId);
case 'year_round_reader':
return this.repo.countDistinctMonthsWithReading(userId, getYearInTimeZone(new Date(), timeZone));
case 'consistent_reader':
return null;
case 'weekend_rhythm':
@@ -21,7 +21,8 @@ const POLYGLOT_TIERS = [
const DECADE_SAMPLER_THRESHOLD = 5;
const SHORT_STORY_FAN_THRESHOLD = 5;
const SHORT_STORY_MAX_PAGES = 100;
export const SHORT_STORY_MAX_PAGES = 100;
export const OLD_SOUL_BEFORE_YEAR = 1900;
const THICK_AND_THIN_THIN_MAX = 100;
const THICK_AND_THIN_THICK_MIN = 800;
const TRILOGY_SIZE = 3;
@@ -107,7 +108,7 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
private async evaluateOldSoul(userId: number, bookId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('old_soul')) return;
const publishedYear = await this.repo.getBookPublishedYear(bookId);
if (publishedYear !== null && publishedYear < 1900) {
if (publishedYear !== null && publishedYear < OLD_SOUL_BEFORE_YEAR) {
const bookTitle = await this.repo.getBookTitle(bookId);
awards.push({ key: 'old_soul', context: { bookId, bookTitle, publishedYear } });
}
@@ -189,7 +190,7 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
private async evaluateOldSoulBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('old_soul')) return;
const hasBook = await this.repo.hasFinishedBookPublishedBefore(userId, 1900);
const hasBook = await this.repo.hasFinishedBookPublishedBefore(userId, OLD_SOUL_BEFORE_YEAR);
if (hasBook) {
awards.push({ key: 'old_soul', context: {} });
}
@@ -41,7 +41,7 @@ const LONG_BOOK_TIERS = [
const MARATHONER_MINUTES = 90;
const ONE_SITTING_THRESHOLD = 90;
const SLOW_BURN_DAYS = 90;
export const SLOW_BURN_DAYS = 90;
const MONTHLY_READER_2_THRESHOLD = 10;
const ALL_NIGHTER_START_HOUR = 1;
const ALL_NIGHTER_END_HOUR = 4;
+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",