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