fix(stats): resolve day boundaries in the reader's timezone

Day keys are written per reader but read back using UTC, the database server's CURRENT_DATE, or
the server process's local year, so streaks and day-based achievements were evaluated against the
wrong day. Route every lookup through one resolved timezone on the evaluation context.

Install tzdata so the container honours TZ for date and subprocesses. Node already resolved TZ
through ICU, so no reading data was affected by the missing package.

Closes #1084
This commit is contained in:
neon
2026-08-24 04:16:37 +00:00
parent 6a441f4452
commit 5f465156ac
22 changed files with 503 additions and 177 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ COPY server/requirements/kobo-cloudscraper.txt /tmp/kobo-cloudscraper-requiremen
# pip is build-only here. Leaving it installed also leaves pip/_vendor/vendor.txt,
# which Trivy reads as installed msgpack and setuptools and fails the image scan on.
RUN apk upgrade --no-cache && \
apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip tini && \
apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip tini tzdata && \
python3 -m venv /opt/bookorbit-python && \
/opt/bookorbit-python/bin/python -m pip install --no-cache-dir -r /tmp/kobo-cloudscraper-requirements.txt && \
/opt/bookorbit-python/bin/python -m pip uninstall -y pip && \
+1
View File
@@ -23,6 +23,7 @@ services:
SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
APP_URL: ${APP_URL?required}
CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}}
TZ: ${TZ:-UTC}
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
NODE_MAX_OLD_SPACE_SIZE: ${NODE_MAX_OLD_SPACE_SIZE:-auto}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { isDateKey, isValidTimeZone, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from './timezone.utils';
import { getYearInTimeZone, isDateKey, isValidTimeZone, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from './timezone.utils';
describe('timezone utils', () => {
it('validates IANA timezones and rejects invalid values', () => {
@@ -66,4 +66,11 @@ describe('timezone utils', () => {
it('throws when date key is malformed', () => {
expect(() => toTimeZoneStartOfDay('2026-02-30', 'UTC')).toThrow(RangeError);
});
it("reads the year in the given timezone, not the host's", () => {
const newYearsEveUtc = new Date('2026-12-31T23:30:00.000Z');
expect(getYearInTimeZone(newYearsEveUtc, 'UTC')).toBe(2026);
expect(getYearInTimeZone(newYearsEveUtc, 'Pacific/Auckland')).toBe(2027);
expect(getYearInTimeZone(newYearsEveUtc, 'America/New_York')).toBe(2026);
});
});
@@ -104,3 +104,7 @@ export function toTimeZoneStartOfDay(dateKey: string, timeZone: string): Date {
throw new RangeError(`Invalid timezone conversion for date key: ${dateKey}`);
}
export function getYearInTimeZone(date: Date, timeZone: string): number {
return Number(toDateKeyInTimeZone(date, timeZone).slice(0, 4));
}
@@ -10,6 +10,19 @@ function flattenSql(value: unknown): string {
return [flattenSql(record.value), flattenSql(record.queryChunks)].join(' ');
}
/** Collects the primitives drizzle binds into a query, so tests can assert on parameters rather than SQL text. */
function boundValues(node: unknown, out: unknown[] = [], seen = new Set<unknown>()): unknown[] {
if (node === null || node === undefined || seen.has(node)) return out;
if (typeof node === 'string' || typeof node === 'number' || node instanceof Date) {
out.push(node instanceof Date ? node.toISOString() : node);
return out;
}
if (typeof node !== 'object') return out;
seen.add(node);
for (const value of Object.values(node as Record<string, unknown>)) boundValues(value, out, seen);
return out;
}
function makeSelectChain(resolvedValue: unknown) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
const methods = ['from', 'where', 'orderBy', 'limit', 'groupBy', 'innerJoin', 'leftJoin', 'having', 'as', '$dynamic'];
@@ -618,7 +631,7 @@ describe('AchievementRepository', () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [{ streak_length: '7' }] }) };
const repo = makeRepo(db);
const result = await repo.getCurrentStreak(1);
const result = await repo.getCurrentStreak(1, 'UTC');
expect(result).toBe(7);
});
@@ -627,12 +640,88 @@ describe('AchievementRepository', () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [] }) };
const repo = makeRepo(db);
const result = await repo.getCurrentStreak(1);
const result = await repo.getCurrentStreak(1, 'UTC');
expect(result).toBe(0);
});
});
describe('reader-timezone day bucketing', () => {
// user_reading_daily_stats.day and reading_sessions are bucketed in the reader's timezone,
// so every "in a day" query has to agree with that rather than with UTC or the database server.
it("getCurrentStreak cuts off at the reader's today, not the database server's CURRENT_DATE", async () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [{ streak_length: '4' }] }) };
const repo = makeRepo(db);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-23T22:30:00.000Z'));
await repo.getCurrentStreak(1, 'Pacific/Auckland');
const sql = flattenSql(db.execute.mock.calls[0]![0]);
expect(sql).not.toContain('CURRENT_DATE');
// 22:30 UTC on the 23rd is already the 24th in Auckland.
expect(boundValues(db.execute.mock.calls[0]![0])).toContain('2026-08-24');
vi.useRealTimers();
});
it("getMaxPagesInADay groups by the reader's local day", async () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [{ max_pages: 10 }] }) };
const repo = makeRepo(db);
await repo.getMaxPagesInADay(1, 'Pacific/Auckland');
expect(flattenSql(db.execute.mock.calls[0]![0])).toContain('AT TIME ZONE');
expect(boundValues(db.execute.mock.calls[0]![0])).toContain('Pacific/Auckland');
});
it("getPagesOnDay windows the day in the reader's timezone", async () => {
const chain = makeSelectChain([{ value: 42 }]);
const repo = makeRepo({ select: vi.fn().mockReturnValue(chain) });
await repo.getPagesOnDay(1, new Date('2026-08-23T22:30:00.000Z'), 'Pacific/Auckland');
const bounds = boundValues(chain.where!.mock.calls[0]![0]);
// Auckland is UTC+12, so their 24th runs from 12:00 UTC on the 23rd to 12:00 UTC on the 24th.
expect(bounds).toContain('2026-08-23T12:00:00.000Z');
expect(bounds).toContain('2026-08-24T12:00:00.000Z');
});
it("countAnnotationsOnDay windows the day in the reader's timezone", async () => {
const chain = makeSelectChain([{ value: 3 }]);
const repo = makeRepo({ select: vi.fn().mockReturnValue(chain) });
await repo.countAnnotationsOnDay(1, new Date('2026-08-23T22:30:00.000Z'), 'Pacific/Auckland');
const bounds = boundValues(chain.where!.mock.calls[0]![0]);
expect(bounds).toContain('2026-08-23T12:00:00.000Z');
expect(bounds).toContain('2026-08-24T12:00:00.000Z');
});
});
describe('findUserTimeZone', () => {
it("returns the reader's configured timezone", async () => {
const chain = makeSelectChain([{ settings: { timezone: 'Europe/Stockholm' } }]);
const repo = makeRepo({ select: vi.fn().mockReturnValue(chain) });
await expect(repo.findUserTimeZone(1)).resolves.toBe('Europe/Stockholm');
});
it('falls back to UTC for a missing or unusable timezone', async () => {
for (const settings of [{}, { timezone: '' }, { timezone: 'Not/AZone' }, null]) {
const chain = makeSelectChain([{ settings }]);
const repo = makeRepo({ select: vi.fn().mockReturnValue(chain) });
await expect(repo.findUserTimeZone(1)).resolves.toBe('UTC');
}
});
it('falls back to UTC when the user row is missing', async () => {
const chain = makeSelectChain([]);
const repo = makeRepo({ select: vi.fn().mockReturnValue(chain) });
await expect(repo.findUserTimeZone(1)).resolves.toBe('UTC');
});
});
describe('countDistinctMonthsWithReading', () => {
it('returns count of distinct months with reading', async () => {
const chain = makeSelectChain([{ month: '1' }, { month: '3' }, { month: '7' }]);
@@ -1786,7 +1875,7 @@ describe('AchievementRepository', () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [{ max_pages: 203.7 }] }) };
const repo = makeRepo(db);
const result = await repo.getMaxPagesInADay(1);
const result = await repo.getMaxPagesInADay(1, 'UTC');
expect(result).toBe(203);
});
@@ -1795,7 +1884,7 @@ describe('AchievementRepository', () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [{ max_pages: 0 }] }) };
const repo = makeRepo(db);
const result = await repo.getMaxPagesInADay(1);
const result = await repo.getMaxPagesInADay(1, 'UTC');
expect(result).toBe(0);
});
@@ -1804,7 +1893,7 @@ describe('AchievementRepository', () => {
const db = { execute: vi.fn().mockResolvedValue({ rows: [] }) };
const repo = makeRepo(db);
const result = await repo.getMaxPagesInADay(1);
const result = await repo.getMaxPagesInADay(1, 'UTC');
expect(result).toBe(0);
});
@@ -2,6 +2,8 @@ import { Inject, Injectable } from '@nestjs/common';
import { and, count, countDistinct, desc, eq, gt, gte, isNotNull, isNull, lt, lte, ne, notInArray, sql, sum } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { addDateKeyDays } from '../../common/utils/reading-daily-stats.utils';
import { resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from '../../common/utils/timezone.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
@@ -110,6 +112,11 @@ export class AchievementRepository {
return row?.isSuperuser ?? false;
}
async findUserTimeZone(userId: number): Promise<string> {
const [row] = await this.db.select({ settings: users.settings }).from(users).where(eq(users.id, userId)).limit(1);
return resolveTimeZone((row?.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
}
async countFinishedBooks(userId: number): Promise<number> {
const [{ value }] = await this.db
.select({ value: count() })
@@ -265,8 +272,11 @@ export class AchievementRepository {
return row?.title ?? null;
}
async getCurrentStreak(userId: number): Promise<number> {
// Count consecutive days with reading activity ending at today or yesterday
async getCurrentStreak(userId: number, timeZone: string): Promise<number> {
// Count consecutive days with reading activity ending at today or yesterday.
// `day` is bucketed in the reader's timezone, so the "is it still alive" cutoff
// has to be their today too, not the database server's CURRENT_DATE.
const today = toDateKeyInTimeZone(new Date(), timeZone);
const result = await this.db.execute(sql`
WITH daily AS (
SELECT DISTINCT day::date as d
@@ -280,7 +290,7 @@ export class AchievementRepository {
SELECT COUNT(*) as streak_length
FROM streak
WHERE grp = (
SELECT grp FROM streak WHERE d >= CURRENT_DATE - INTERVAL '1 day' ORDER BY d DESC LIMIT 1
SELECT grp FROM streak WHERE d >= ${today}::date - INTERVAL '1 day' ORDER BY d DESC LIMIT 1
)
`);
const rows = (result as unknown as { rows: Array<{ streak_length: string }> }).rows;
@@ -679,9 +689,10 @@ export class AchievementRepository {
return rows.length > 0 ? rows[0].cnt : 0;
}
async countAnnotationsOnDay(userId: number, date: Date): Promise<number> {
const dayStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
async countAnnotationsOnDay(userId: number, date: Date, timeZone: string): Promise<number> {
const day = toDateKeyInTimeZone(date, timeZone);
const dayStart = toTimeZoneStartOfDay(day, timeZone);
const dayEnd = toTimeZoneStartOfDay(addDateKeyDays(day, 1), timeZone);
const [{ value }] = await this.db
.select({ value: count() })
.from(annotations)
@@ -988,9 +999,10 @@ export class AchievementRepository {
return Math.floor(Number(result?.value ?? 0));
}
async getPagesOnDay(userId: number, day: Date): Promise<number> {
const dayStart = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()));
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
async getPagesOnDay(userId: number, day: Date, timeZone: string): Promise<number> {
const dayKey = toDateKeyInTimeZone(day, timeZone);
const dayStart = toTimeZoneStartOfDay(dayKey, timeZone);
const dayEnd = toTimeZoneStartOfDay(addDateKeyDays(dayKey, 1), timeZone);
const [result] = await this.db
.select({
value: sql<number>`coalesce(sum(${bookMetadata.pageCount} * least(greatest(${readingSessions.progressDelta}, 0), 100) / 100.0), 0)::float`,
@@ -1014,7 +1026,7 @@ export class AchievementRepository {
return Math.floor(Number(result?.value ?? 0));
}
async getMaxPagesInADay(userId: number): Promise<number> {
async getMaxPagesInADay(userId: number, timeZone: string): Promise<number> {
const result = await this.db.execute<{ max_pages: number }>(sql`
SELECT COALESCE(MAX(day_pages), 0)::float AS max_pages FROM (
SELECT SUM(bm.page_count * LEAST(GREATEST(rs.progress_delta, 0), 100) / 100.0) AS day_pages
@@ -1025,7 +1037,7 @@ export class AchievementRepository {
AND bm.page_count IS NOT NULL AND bm.page_count > 0
AND rs.progress_delta IS NOT NULL AND rs.progress_delta > 0 AND rs.progress_delta <= 100
AND rs.duration_seconds > 0
GROUP BY DATE_TRUNC('day', rs.started_at AT TIME ZONE 'UTC')
GROUP BY DATE_TRUNC('day', rs.started_at AT TIME ZONE ${timeZone})
) daily
`);
const rows = (result as unknown as { rows: Array<{ max_pages: number }> }).rows;
@@ -9,6 +9,7 @@ function makeRepo() {
findUserAchievements: vi.fn().mockResolvedValue([]),
findUserEarnedKeys: vi.fn().mockResolvedValue(new Set()),
findUserIsSuperuser: vi.fn().mockResolvedValue(false),
findUserTimeZone: vi.fn().mockResolvedValue('UTC'),
award: vi.fn().mockResolvedValue({ id: 1, achievementKey: 'test', userId: 1, awardedAt: new Date(), contextJson: null }),
upsertCatalogue: vi.fn().mockResolvedValue(undefined),
countFinishedBooks: vi.fn().mockResolvedValue(0),
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/commo
import { NotificationType, ACHIEVEMENT_CATEGORY_LABELS } from '@bookorbit/types';
import type { AchievementCatalogueResponse, AchievementCategoryGroup, AchievementItem, AchievementCategory } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { getYearInTimeZone, resolveTimeZone, toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
import type { RequestUser } from '../../common/types/request-user';
import { NotificationService } from '../notification/notification.service';
@@ -61,7 +62,8 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
earnedMap.set(ua.achievementKey, ua);
}
const progressMap = await this.computeProgress(userId, user.isSuperuser, allAchievements, earnedMap);
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const progressMap = await this.computeProgress(userId, user.isSuperuser, timeZone, allAchievements, earnedMap);
const categoryOrder: AchievementCategory[] = ['reading', 'library', 'exploration', 'dedication', 'devices'];
const grouped = new Map<AchievementCategory, AchievementItem[]>();
@@ -127,8 +129,12 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
try {
if (!(await this.userService.isAchievementEnabled(userId))) return;
const [earnedKeys, isSuperuser] = await Promise.all([this.repo.findUserEarnedKeys(userId), this.repo.findUserIsSuperuser(userId)]);
const awards = await this.registry.evaluate({ userId, isSuperuser, eventName, payload }, earnedKeys);
const [earnedKeys, isSuperuser, timeZone] = await Promise.all([
this.repo.findUserEarnedKeys(userId),
this.repo.findUserIsSuperuser(userId),
this.repo.findUserTimeZone(userId),
]);
const awards = await this.registry.evaluate({ userId, isSuperuser, eventName, timeZone, payload }, earnedKeys);
// Backfill is a retroactive catch-up, so it awards silently instead of flooding the user with notifications.
const notify = eventName !== ACHIEVEMENT_EVENT_BACKFILL;
@@ -144,7 +150,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
}
if (awarded > 0) {
await this.evaluateMetaBadges(userId, isSuperuser, earnedKeys, notify);
await this.evaluateMetaBadges(userId, isSuperuser, timeZone, earnedKeys, notify);
}
if (awarded > 0) {
@@ -250,9 +256,9 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
});
}
private async evaluateMetaBadges(userId: number, isSuperuser: boolean, earnedKeys: Set<string>, notify: boolean): Promise<void> {
private async evaluateMetaBadges(userId: number, isSuperuser: boolean, timeZone: string, earnedKeys: Set<string>, notify: boolean): Promise<void> {
const metaAwards = await this.registry.evaluate(
{ userId, isSuperuser, eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED, payload: { userId } },
{ userId, isSuperuser, eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED, timeZone, payload: { userId } },
earnedKeys,
);
for (const award of metaAwards) {
@@ -268,6 +274,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
private async computeProgress(
userId: number,
isSuperuser: boolean,
timeZone: string,
allAchievements: AchievementRow[],
earnedMap: Map<string, UserAchievementRow>,
): Promise<Map<string, number>> {
@@ -287,7 +294,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
.map(([groupKey]) => groupKey);
const progressEntries = await Promise.all(
groupsToFetch.map(async (groupKey) => [groupKey, await this.getProgressForGroup(userId, isSuperuser, groupKey)] as const),
groupsToFetch.map(async (groupKey) => [groupKey, await this.getProgressForGroup(userId, isSuperuser, timeZone, groupKey)] as const),
);
for (const [groupKey, progress] of progressEntries) {
@@ -304,7 +311,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
await Promise.all(
singleBadgesToFetch.map(async (a) => {
const progress = await this.computeSingleBadgeProgress(userId, isSuperuser, a.key);
const progress = await this.computeSingleBadgeProgress(userId, isSuperuser, timeZone, a.key);
if (progress !== null) {
progressMap.set(a.key, progress);
}
@@ -314,7 +321,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
return progressMap;
}
private async computeSingleBadgeProgress(userId: number, isSuperuser: boolean, key: string): Promise<number | null> {
private async computeSingleBadgeProgress(userId: number, isSuperuser: boolean, timeZone: string, key: string): Promise<number | null> {
switch (key) {
case 'marathoner':
return this.repo.getMaxSessionMinutes(userId);
@@ -340,13 +347,13 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
case 'note_keeper':
return this.repo.countAnnotationsWithNotes(userId);
case 'deep_dive_session':
return this.repo.countAnnotationsOnDay(userId, new Date());
return this.repo.countAnnotationsOnDay(userId, new Date(), timeZone);
case 'monthly_reader_2': {
const now = new Date();
return this.repo.countBooksFinishedInMonth(userId, now.getUTCFullYear(), now.getUTCMonth() + 1);
const [year, month] = toDateKeyInTimeZone(new Date(), timeZone).split('-');
return this.repo.countBooksFinishedInMonth(userId, Number(year), Number(month));
}
case 'yearly_finisher_12':
return this.repo.countBooksFinishedInYear(userId, new Date().getUTCFullYear());
return this.repo.countBooksFinishedInYear(userId, getYearInTimeZone(new Date(), timeZone));
case 'category_sweeper':
return this.repo.countDistinctEarnedCategories(userId);
case 'consistent_reader':
@@ -354,15 +361,14 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
case 'weekend_rhythm':
return null;
case 'seasonal_reader': {
const currentYear = new Date().getFullYear();
return this.repo.countDistinctSeasonsWithReading(userId, currentYear);
return this.repo.countDistinctSeasonsWithReading(userId, getYearInTimeZone(new Date(), timeZone));
}
case 'across_the_board':
return this.repo.countDistinctRatingValues(userId);
case 'power_hour':
return this.repo.getMaxSessionPages(userId);
case 'speed_reader':
return this.repo.getMaxPagesInADay(userId);
return this.repo.getMaxPagesInADay(userId, timeZone);
case 'wordsmith':
return this.repo.getMaxNoteLength(userId);
case 'box_of_crayons':
@@ -376,7 +382,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
}
}
private async getProgressForGroup(userId: number, isSuperuser: boolean, groupKey: string): Promise<number | null> {
private async getProgressForGroup(userId: number, isSuperuser: boolean, timeZone: string, groupKey: string): Promise<number | null> {
switch (groupKey) {
case 'books_finished':
return this.repo.countFinishedBooks(userId);
@@ -393,7 +399,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
case 'polyglot':
return this.repo.countDistinctLanguagesRead(userId);
case 'streak':
return this.repo.getCurrentStreak(userId);
return this.repo.getCurrentStreak(userId, timeZone);
case 'long_book':
return this.repo.getMaxFinishedBookPageCount(userId);
case 'critic':
@@ -56,7 +56,13 @@ describe('DedicationEvaluator', () => {
it('awards streak_1 at 7-day streak', async () => {
repo.getCurrentStreak.mockResolvedValue(7);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'streak_1' }));
@@ -65,7 +71,13 @@ describe('DedicationEvaluator', () => {
it('awards multiple streak tiers at once', async () => {
repo.getCurrentStreak.mockResolvedValue(35);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
const keys = awards.map((a) => a.key);
@@ -79,7 +91,13 @@ describe('DedicationEvaluator', () => {
it('awards year_round_reader at 12 months', async () => {
repo.countDistinctMonthsWithReading.mockResolvedValue(12);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'year_round_reader' }));
@@ -93,6 +111,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', oldStatus: 'abandoned' } as never,
},
@@ -107,7 +126,13 @@ describe('DedicationEvaluator', () => {
const thirtyOneDaysAgo = new Date('2026-02-12T10:00:00Z');
repo.getPreviousSessionEndedAt.mockResolvedValue(thirtyOneDaysAgo);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'comeback_kid' }));
@@ -117,7 +142,13 @@ describe('DedicationEvaluator', () => {
const fiveDaysAgo = new Date('2026-03-10T10:00:00Z');
repo.getPreviousSessionEndedAt.mockResolvedValue(fiveDaysAgo);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'comeback_kid')).toBeUndefined();
@@ -128,7 +159,13 @@ describe('DedicationEvaluator', () => {
it('awards seasonal_reader when 4 distinct seasons have reading activity', async () => {
repo.countDistinctSeasonsWithReading.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'seasonal_reader' }));
@@ -137,7 +174,13 @@ describe('DedicationEvaluator', () => {
it('does not award seasonal_reader below 4 seasons', async () => {
repo.countDistinctSeasonsWithReading.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'seasonal_reader')).toBeUndefined();
@@ -146,7 +189,13 @@ describe('DedicationEvaluator', () => {
it('does not award seasonal_reader when already earned', async () => {
repo.countDistinctSeasonsWithReading.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(['seasonal_reader']),
);
expect(awards.find((a) => a.key === 'seasonal_reader')).toBeUndefined();
@@ -155,7 +204,7 @@ describe('DedicationEvaluator', () => {
it('awards seasonal_reader via backfill', async () => {
repo.countDistinctSeasonsWithReading.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'seasonal_reader' }));
@@ -166,7 +215,13 @@ describe('DedicationEvaluator', () => {
it('awards perfect_month when user read every day in any calendar month', async () => {
repo.hasReadEveryDayInAnyMonth.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'perfect_month' }));
@@ -175,7 +230,13 @@ describe('DedicationEvaluator', () => {
it('does not award perfect_month when no qualifying month', async () => {
repo.hasReadEveryDayInAnyMonth.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'perfect_month')).toBeUndefined();
@@ -184,7 +245,13 @@ describe('DedicationEvaluator', () => {
it('does not award perfect_month when already earned', async () => {
repo.hasReadEveryDayInAnyMonth.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(['perfect_month']),
);
expect(awards.find((a) => a.key === 'perfect_month')).toBeUndefined();
@@ -193,7 +260,7 @@ describe('DedicationEvaluator', () => {
it('awards perfect_month via backfill', async () => {
repo.hasReadEveryDayInAnyMonth.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'perfect_month' }));
@@ -204,7 +271,13 @@ describe('DedicationEvaluator', () => {
it('awards consistent_reader when 30+ consecutive days with min 5 minutes each', async () => {
repo.hasConsecutiveDaysWithMinReading.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'consistent_reader' }));
@@ -213,7 +286,13 @@ describe('DedicationEvaluator', () => {
it('does not award consistent_reader when condition not met', async () => {
repo.hasConsecutiveDaysWithMinReading.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'consistent_reader')).toBeUndefined();
@@ -222,7 +301,13 @@ describe('DedicationEvaluator', () => {
it('does not award consistent_reader when already earned', async () => {
repo.hasConsecutiveDaysWithMinReading.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(['consistent_reader']),
);
expect(awards.find((a) => a.key === 'consistent_reader')).toBeUndefined();
@@ -231,7 +316,7 @@ describe('DedicationEvaluator', () => {
it('awards consistent_reader via backfill', async () => {
repo.hasConsecutiveDaysWithMinReading.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'consistent_reader' }));
@@ -242,7 +327,13 @@ describe('DedicationEvaluator', () => {
it('awards weekend_rhythm when 4+ consecutive weekends have reading', async () => {
repo.hasConsecutiveWeekendsWithReading.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'weekend_rhythm' }));
@@ -251,7 +342,13 @@ describe('DedicationEvaluator', () => {
it('does not award weekend_rhythm when condition not met', async () => {
repo.hasConsecutiveWeekendsWithReading.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'weekend_rhythm')).toBeUndefined();
@@ -260,7 +357,13 @@ describe('DedicationEvaluator', () => {
it('does not award weekend_rhythm when already earned', async () => {
repo.hasConsecutiveWeekendsWithReading.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(['weekend_rhythm']),
);
expect(awards.find((a) => a.key === 'weekend_rhythm')).toBeUndefined();
@@ -269,7 +372,7 @@ describe('DedicationEvaluator', () => {
it('awards weekend_rhythm via backfill', async () => {
repo.hasConsecutiveWeekendsWithReading.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'weekend_rhythm' }));
@@ -283,6 +386,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: {
userId: 1,
@@ -304,6 +408,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: {
userId: 1,
@@ -324,6 +429,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: {
userId: 1,
@@ -345,6 +451,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: {
userId: 1,
@@ -364,7 +471,7 @@ describe('DedicationEvaluator', () => {
it('awards weekend_warrior via backfill when user has a weekend marathon', async () => {
repo.hasWeekendMarathon.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'weekend_warrior' }));
@@ -378,6 +485,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', oldStatus: 'reading' } as never,
},
@@ -392,6 +500,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', oldStatus: 'reading' } as never,
},
@@ -406,6 +515,7 @@ describe('DedicationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', oldStatus: 'reading' } as never,
},
@@ -417,7 +527,7 @@ describe('DedicationEvaluator', () => {
it('awards yearly_finisher_12 via backfill', async () => {
repo.countBooksFinishedInYear.mockResolvedValue(12);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'yearly_finisher_12' }));
@@ -428,7 +538,7 @@ describe('DedicationEvaluator', () => {
it('awards comeback_kid via backfill when large session gap exists', async () => {
repo.hasLargeGapBetweenAnySessions.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'comeback_kid' }));
@@ -437,7 +547,7 @@ describe('DedicationEvaluator', () => {
it('does not award comeback_kid via backfill when no large gap', async () => {
repo.hasLargeGapBetweenAnySessions.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards.find((a) => a.key === 'comeback_kid')).toBeUndefined();
@@ -446,7 +556,7 @@ describe('DedicationEvaluator', () => {
it('awards reborn via backfill when a previously abandoned book was finished', async () => {
repo.hasAnyBookRebornFromAbandoned.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'reborn' }));
@@ -455,7 +565,7 @@ describe('DedicationEvaluator', () => {
it('does not award reborn via backfill when no abandoned book was finished', async () => {
repo.hasAnyBookRebornFromAbandoned.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards.find((a) => a.key === 'reborn')).toBeUndefined();
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { toZonedTime } from 'date-fns-tz';
import { getYearInTimeZone } from '../../../common/utils/timezone.utils';
import { AchievementRepository } from '../achievement.repository';
import {
ACHIEVEMENT_EVENT_BACKFILL,
@@ -41,10 +43,10 @@ export class DedicationEvaluator implements IAchievementEvaluator {
if (ctx.eventName === ACHIEVEMENT_EVENT_READING_SESSION_SAVED) {
const payload = ctx.payload as unknown as ReadingSessionSavedPayload;
await this.evaluateStreakTiers(ctx.userId, earnedKeys, awards);
await this.evaluateYearRoundReader(ctx.userId, earnedKeys, awards);
await this.evaluateStreakTiers(ctx.userId, ctx.timeZone, earnedKeys, awards);
await this.evaluateYearRoundReader(ctx.userId, ctx.timeZone, earnedKeys, awards);
await this.evaluateComebackKid(ctx.userId, payload, earnedKeys, awards);
await this.evaluateSeasonalReader(ctx.userId, earnedKeys, awards);
await this.evaluateSeasonalReader(ctx.userId, ctx.timeZone, earnedKeys, awards);
await this.evaluatePerfectMonth(ctx.userId, earnedKeys, awards);
await this.evaluateConsistentReader(ctx.userId, earnedKeys, awards);
await this.evaluateWeekendRhythm(ctx.userId, earnedKeys, awards);
@@ -60,11 +62,11 @@ export class DedicationEvaluator implements IAchievementEvaluator {
}
if (ctx.eventName === ACHIEVEMENT_EVENT_BACKFILL) {
await this.evaluateStreakTiers(ctx.userId, earnedKeys, awards);
await this.evaluateYearRoundReader(ctx.userId, earnedKeys, awards);
await this.evaluateStreakTiers(ctx.userId, ctx.timeZone, earnedKeys, awards);
await this.evaluateYearRoundReader(ctx.userId, ctx.timeZone, earnedKeys, awards);
await this.evaluateComebackKidBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateRebornBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateSeasonalReader(ctx.userId, earnedKeys, awards);
await this.evaluateSeasonalReader(ctx.userId, ctx.timeZone, earnedKeys, awards);
await this.evaluatePerfectMonth(ctx.userId, earnedKeys, awards);
await this.evaluateConsistentReader(ctx.userId, earnedKeys, awards);
await this.evaluateWeekendRhythm(ctx.userId, earnedKeys, awards);
@@ -75,11 +77,11 @@ export class DedicationEvaluator implements IAchievementEvaluator {
return awards;
}
private async evaluateStreakTiers(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
private async evaluateStreakTiers(userId: number, timeZone: string, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
const unearnedTiers = STREAK_TIERS.filter((t) => !earnedKeys.has(t.key));
if (unearnedTiers.length === 0) return;
const streak = await this.repo.getCurrentStreak(userId);
const streak = await this.repo.getCurrentStreak(userId, timeZone);
for (const tier of unearnedTiers) {
if (streak >= tier.threshold) {
@@ -88,9 +90,9 @@ export class DedicationEvaluator implements IAchievementEvaluator {
}
}
private async evaluateYearRoundReader(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
private async evaluateYearRoundReader(userId: number, timeZone: string, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('year_round_reader')) return;
const currentYear = new Date().getFullYear();
const currentYear = getYearInTimeZone(new Date(), timeZone);
const months = await this.repo.countDistinctMonthsWithReading(userId, currentYear);
if (months >= 12) {
awards.push({ key: 'year_round_reader', context: { year: currentYear } });
@@ -138,9 +140,9 @@ export class DedicationEvaluator implements IAchievementEvaluator {
}
}
private async evaluateSeasonalReader(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
private async evaluateSeasonalReader(userId: number, timeZone: string, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('seasonal_reader')) return;
const currentYear = new Date().getFullYear();
const currentYear = getYearInTimeZone(new Date(), timeZone);
const seasonCount = await this.repo.countDistinctSeasonsWithReading(userId, currentYear);
if (seasonCount >= SEASONAL_READER_THRESHOLD) {
awards.push({ key: 'seasonal_reader', context: { year: currentYear, seasonCount } });
@@ -21,10 +21,11 @@ describe('DevicesEvaluator', () => {
const progressCtx = {
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
payload: { userId: 1, bookId: 5, progress: 50, source: 'kobo' } as never,
};
const backfillCtx = { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
const backfillCtx = { userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
beforeEach(() => {
repo = makeRepo();
@@ -85,7 +86,7 @@ describe('DevicesEvaluator', () => {
it('short-circuits a web session when the user has no external device', async () => {
repo.hasAnyExternalDevice.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toHaveLength(0);
@@ -7,6 +7,7 @@ export interface EvaluationContext {
userId: number;
isSuperuser: boolean;
eventName: string;
timeZone: string;
payload: Record<string, unknown>;
}
@@ -45,6 +45,7 @@ describe('ExplorationEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: makeStatusPayload({ newStatus: 'reading' }) as never,
},
@@ -57,7 +58,7 @@ describe('ExplorationEvaluator', () => {
it('awards tier 1 at 5 genres', async () => {
repo.countDistinctGenresRead.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'genre_explorer_1' }));
@@ -68,7 +69,7 @@ describe('ExplorationEvaluator', () => {
it('awards tier 1 at 2 languages', async () => {
repo.countDistinctLanguagesRead.mockResolvedValue(2);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'polyglot_1' }));
@@ -79,7 +80,7 @@ describe('ExplorationEvaluator', () => {
it('awards old_soul for pre-1900 book', async () => {
repo.getBookPublishedYear.mockResolvedValue(1850);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'old_soul' }));
@@ -88,7 +89,7 @@ describe('ExplorationEvaluator', () => {
it('does not award old_soul for 1900+ book', async () => {
repo.getBookPublishedYear.mockResolvedValue(1901);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'old_soul')).toBeUndefined();
@@ -99,7 +100,7 @@ describe('ExplorationEvaluator', () => {
it('awards new_release for current year book', async () => {
repo.getBookPublishedYear.mockResolvedValue(new Date().getFullYear());
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'new_release' }));
@@ -110,7 +111,7 @@ describe('ExplorationEvaluator', () => {
it('awards century_span at 3+ centuries', async () => {
repo.countDistinctCenturiesRead.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'century_span' }));
@@ -121,7 +122,7 @@ describe('ExplorationEvaluator', () => {
it('awards author_deep_dive at 5+ books from same author', async () => {
repo.maxBooksPerAuthor.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'author_deep_dive' }));
@@ -132,7 +133,7 @@ describe('ExplorationEvaluator', () => {
it('awards decade_sampler when 5+ distinct decades read', async () => {
repo.countDistinctDecadesRead.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'decade_sampler' }));
@@ -141,7 +142,7 @@ describe('ExplorationEvaluator', () => {
it('does not award decade_sampler below 5 decades', async () => {
repo.countDistinctDecadesRead.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'decade_sampler')).toBeUndefined();
@@ -150,7 +151,7 @@ describe('ExplorationEvaluator', () => {
it('does not award decade_sampler when already earned', async () => {
repo.countDistinctDecadesRead.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['decade_sampler']),
);
expect(awards.find((a) => a.key === 'decade_sampler')).toBeUndefined();
@@ -161,7 +162,7 @@ describe('ExplorationEvaluator', () => {
it('awards short_story_fan when 5+ short books finished', async () => {
repo.countFinishedBooksByMaxPageCount.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'short_story_fan' }));
@@ -170,7 +171,7 @@ describe('ExplorationEvaluator', () => {
it('does not award short_story_fan below 5 short books', async () => {
repo.countFinishedBooksByMaxPageCount.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'short_story_fan')).toBeUndefined();
@@ -179,7 +180,7 @@ describe('ExplorationEvaluator', () => {
it('does not award short_story_fan when already earned', async () => {
repo.countFinishedBooksByMaxPageCount.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['short_story_fan']),
);
expect(awards.find((a) => a.key === 'short_story_fan')).toBeUndefined();
@@ -191,7 +192,7 @@ describe('ExplorationEvaluator', () => {
repo.hasFinishedBookUnderPages.mockResolvedValue(true);
repo.hasFinishedBookOverPages.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'thick_and_thin' }));
@@ -201,7 +202,7 @@ describe('ExplorationEvaluator', () => {
repo.hasFinishedBookUnderPages.mockResolvedValue(true);
repo.hasFinishedBookOverPages.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'thick_and_thin')).toBeUndefined();
@@ -211,7 +212,7 @@ describe('ExplorationEvaluator', () => {
repo.hasFinishedBookUnderPages.mockResolvedValue(false);
repo.hasFinishedBookOverPages.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'thick_and_thin')).toBeUndefined();
@@ -220,7 +221,7 @@ describe('ExplorationEvaluator', () => {
it('short-circuits when no thin book - does not query thick books', async () => {
repo.hasFinishedBookUnderPages.mockResolvedValue(false);
await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(repo.hasFinishedBookOverPages).not.toHaveBeenCalled();
@@ -230,7 +231,7 @@ describe('ExplorationEvaluator', () => {
repo.hasFinishedBookUnderPages.mockResolvedValue(true);
repo.hasFinishedBookOverPages.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['thick_and_thin']),
);
expect(awards.find((a) => a.key === 'thick_and_thin')).toBeUndefined();
@@ -241,7 +242,7 @@ describe('ExplorationEvaluator', () => {
it('awards trilogy_master when a 3-book series is completed', async () => {
repo.hasCompletedSeriesOfSize.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'trilogy_master' }));
@@ -250,7 +251,7 @@ describe('ExplorationEvaluator', () => {
it('does not award trilogy_master when no 3-book series completed', async () => {
repo.hasCompletedSeriesOfSize.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'trilogy_master')).toBeUndefined();
@@ -259,7 +260,7 @@ describe('ExplorationEvaluator', () => {
it('passes size=3 to repo method', async () => {
repo.hasCompletedSeriesOfSize.mockResolvedValue(true);
await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(repo.hasCompletedSeriesOfSize).toHaveBeenCalledWith(1, 3);
@@ -268,7 +269,7 @@ describe('ExplorationEvaluator', () => {
it('does not award trilogy_master when already earned', async () => {
repo.hasCompletedSeriesOfSize.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['trilogy_master']),
);
expect(awards.find((a) => a.key === 'trilogy_master')).toBeUndefined();
@@ -279,7 +280,7 @@ describe('ExplorationEvaluator', () => {
it('awards genre_devotee when 10+ books in same genre', async () => {
repo.maxBooksPerGenre.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'genre_devotee' }));
@@ -288,7 +289,7 @@ describe('ExplorationEvaluator', () => {
it('does not award genre_devotee below 10 books in a genre', async () => {
repo.maxBooksPerGenre.mockResolvedValue(9);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'genre_devotee')).toBeUndefined();
@@ -297,7 +298,7 @@ describe('ExplorationEvaluator', () => {
it('does not award genre_devotee when already earned', async () => {
repo.maxBooksPerGenre.mockResolvedValue(20);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['genre_devotee']),
);
expect(awards.find((a) => a.key === 'genre_devotee')).toBeUndefined();
@@ -305,7 +306,7 @@ describe('ExplorationEvaluator', () => {
});
describe('backfill for new exploration badges', () => {
const backfillCtx = { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
const backfillCtx = { userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
it('awards decade_sampler in backfill', async () => {
repo.countDistinctDecadesRead.mockResolvedValue(5);
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { getYearInTimeZone } from '../../../common/utils/timezone.utils';
import { AchievementRepository } from '../achievement.repository';
import { ACHIEVEMENT_EVENT_BACKFILL, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, type BookStatusChangedPayload } from '../achievement-events.service';
import type { AchievementAward, EvaluationContext, IAchievementEvaluator } from './evaluator.interface';
@@ -36,7 +37,7 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
async evaluate(ctx: EvaluationContext, earnedKeys: Set<string>): Promise<AchievementAward[]> {
if (ctx.eventName === ACHIEVEMENT_EVENT_BACKFILL) {
return this.evaluateBackfill(ctx.userId, earnedKeys);
return this.evaluateBackfill(ctx.userId, ctx.timeZone, earnedKeys);
}
const payload = ctx.payload as unknown as BookStatusChangedPayload;
@@ -47,7 +48,7 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
await this.evaluateGenreExplorerTiers(ctx.userId, earnedKeys, awards);
await this.evaluatePolyglotTiers(ctx.userId, earnedKeys, awards);
await this.evaluateOldSoul(ctx.userId, payload.bookId, earnedKeys, awards);
await this.evaluateNewRelease(ctx.userId, payload.bookId, earnedKeys, awards);
await this.evaluateNewRelease(ctx.userId, ctx.timeZone, payload.bookId, earnedKeys, awards);
await this.evaluateCenturySpan(ctx.userId, earnedKeys, awards);
await this.evaluateAuthorDeepDive(ctx.userId, earnedKeys, awards);
await this.evaluateDecadeSampler(ctx.userId, earnedKeys, awards);
@@ -59,13 +60,13 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
return awards;
}
private async evaluateBackfill(userId: number, earnedKeys: Set<string>): Promise<AchievementAward[]> {
private async evaluateBackfill(userId: number, timeZone: string, earnedKeys: Set<string>): Promise<AchievementAward[]> {
const awards: AchievementAward[] = [];
await this.evaluateGenreExplorerTiers(userId, earnedKeys, awards);
await this.evaluatePolyglotTiers(userId, earnedKeys, awards);
await this.evaluateOldSoulBackfill(userId, earnedKeys, awards);
await this.evaluateNewReleaseBackfill(userId, earnedKeys, awards);
await this.evaluateNewReleaseBackfill(userId, timeZone, earnedKeys, awards);
await this.evaluateCenturySpan(userId, earnedKeys, awards);
await this.evaluateAuthorDeepDive(userId, earnedKeys, awards);
await this.evaluateDecadeSampler(userId, earnedKeys, awards);
@@ -112,10 +113,16 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
}
}
private async evaluateNewRelease(userId: number, bookId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
private async evaluateNewRelease(
userId: number,
timeZone: string,
bookId: number,
earnedKeys: Set<string>,
awards: AchievementAward[],
): Promise<void> {
if (earnedKeys.has('new_release')) return;
const publishedYear = await this.repo.getBookPublishedYear(bookId);
const currentYear = new Date().getFullYear();
const currentYear = getYearInTimeZone(new Date(), timeZone);
if (publishedYear !== null && publishedYear === currentYear) {
const bookTitle = await this.repo.getBookTitle(bookId);
awards.push({ key: 'new_release', context: { bookId, bookTitle, publishedYear } });
@@ -188,9 +195,9 @@ export class ExplorationEvaluator implements IAchievementEvaluator {
}
}
private async evaluateNewReleaseBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
private async evaluateNewReleaseBackfill(userId: number, timeZone: string, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('new_release')) return;
const currentYear = new Date().getFullYear();
const currentYear = getYearInTimeZone(new Date(), timeZone);
const hasBook = await this.repo.hasFinishedBookPublishedInYear(userId, currentYear);
if (hasBook) {
awards.push({ key: 'new_release', context: { publishedYear: currentYear } });
@@ -48,6 +48,7 @@ describe('LibraryEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 1, newStatus: 'read', oldStatus: 'reading' },
},
@@ -62,6 +63,7 @@ describe('LibraryEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 1, newStatus: 'reading', oldStatus: 'unread' },
},
@@ -73,7 +75,13 @@ describe('LibraryEvaluator', () => {
it('awards tiers on library catalog changes without requiring read status transitions', async () => {
repo.countAccessibleBooks.mockResolvedValue(250);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED, payload: { userId: 1, libraryId: 10 } as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED,
payload: { userId: 1, libraryId: 10 } as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'library_builder_1' }));
@@ -85,7 +93,7 @@ describe('LibraryEvaluator', () => {
it('awards annotator_1 at 10 annotations', async () => {
repo.countAnnotations.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'annotator_1' }));
@@ -94,7 +102,7 @@ describe('LibraryEvaluator', () => {
it('does not include bookmarked (now handled by MilestonesEvaluator)', async () => {
repo.countAnnotations.mockResolvedValue(1);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(),
);
expect(awards.find((a) => a.key === 'bookmarked')).toBeUndefined();
@@ -108,6 +116,7 @@ describe('LibraryEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 1, newStatus: 'read', oldStatus: 'reading' },
},
@@ -119,7 +128,13 @@ describe('LibraryEvaluator', () => {
it('awards multi_format on library catalog changes', async () => {
repo.countDistinctFormats.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED, payload: { userId: 1, libraryId: 2 } as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED,
payload: { userId: 1, libraryId: 2 } as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'multi_format' }));
@@ -130,7 +145,7 @@ describe('LibraryEvaluator', () => {
it('awards curator at 10 collections', async () => {
repo.countCollections.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_COLLECTION_CREATED, payload: { userId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_COLLECTION_CREATED, payload: { userId: 1 } },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'curator' }));
@@ -139,7 +154,7 @@ describe('LibraryEvaluator', () => {
it('does not award curator below 10', async () => {
repo.countCollections.mockResolvedValue(9);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_COLLECTION_CREATED, payload: { userId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_COLLECTION_CREATED, payload: { userId: 1 } },
new Set(),
);
expect(awards.find((a) => a.key === 'curator')).toBeUndefined();
@@ -150,7 +165,7 @@ describe('LibraryEvaluator', () => {
it('awards note_keeper when 20+ annotations have notes', async () => {
repo.countAnnotationsWithNotes.mockResolvedValue(25);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'note_keeper' }));
@@ -159,7 +174,7 @@ describe('LibraryEvaluator', () => {
it('does not award note_keeper below 20', async () => {
repo.countAnnotationsWithNotes.mockResolvedValue(24);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(),
);
expect(awards.find((a) => a.key === 'note_keeper')).toBeUndefined();
@@ -168,7 +183,7 @@ describe('LibraryEvaluator', () => {
it('does not award note_keeper when already earned', async () => {
repo.countAnnotationsWithNotes.mockResolvedValue(50);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(['note_keeper']),
);
expect(awards.find((a) => a.key === 'note_keeper')).toBeUndefined();
@@ -177,7 +192,7 @@ describe('LibraryEvaluator', () => {
it('awards note_keeper via backfill', async () => {
repo.countAnnotationsWithNotes.mockResolvedValue(25);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'note_keeper' }));
@@ -188,7 +203,7 @@ describe('LibraryEvaluator', () => {
it('awards deep_dive_session when 10+ annotations created today', async () => {
repo.countAnnotationsOnDay.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'deep_dive_session' }));
@@ -197,7 +212,7 @@ describe('LibraryEvaluator', () => {
it('does not award deep_dive_session below 10 today', async () => {
repo.countAnnotationsOnDay.mockResolvedValue(9);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(),
);
expect(awards.find((a) => a.key === 'deep_dive_session')).toBeUndefined();
@@ -206,7 +221,7 @@ describe('LibraryEvaluator', () => {
it('does not award deep_dive_session when already earned', async () => {
repo.countAnnotationsOnDay.mockResolvedValue(15);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1 } },
new Set(['deep_dive_session']),
);
expect(awards.find((a) => a.key === 'deep_dive_session')).toBeUndefined();
@@ -215,7 +230,7 @@ describe('LibraryEvaluator', () => {
it('awards deep_dive_session via backfill', async () => {
repo.hasAnyDayWithAnnotationCount.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'deep_dive_session' }));
@@ -224,7 +239,7 @@ describe('LibraryEvaluator', () => {
it('does not award deep_dive_session via backfill when no qualifying day', async () => {
repo.hasAnyDayWithAnnotationCount.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards.find((a) => a.key === 'deep_dive_session')).toBeUndefined();
@@ -235,7 +250,13 @@ describe('LibraryEvaluator', () => {
it('awards wordsmith for a note longer than 280 chars', async () => {
repo.getAnnotationNoteLength.mockResolvedValue(300);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
payload: { userId: 1, bookId: 1, annotationId: 9 },
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'wordsmith' }));
@@ -244,7 +265,13 @@ describe('LibraryEvaluator', () => {
it('does not award wordsmith for a note of exactly 280 chars', async () => {
repo.getAnnotationNoteLength.mockResolvedValue(280);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
payload: { userId: 1, bookId: 1, annotationId: 9 },
},
new Set(),
);
expect(awards.find((a) => a.key === 'wordsmith')).toBeUndefined();
@@ -253,7 +280,13 @@ describe('LibraryEvaluator', () => {
it('does not award wordsmith when the annotation has no note', async () => {
repo.getAnnotationNoteLength.mockResolvedValue(null);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
payload: { userId: 1, bookId: 1, annotationId: 9 },
},
new Set(),
);
expect(awards.find((a) => a.key === 'wordsmith')).toBeUndefined();
@@ -262,7 +295,7 @@ describe('LibraryEvaluator', () => {
it('awards wordsmith via backfill', async () => {
repo.getMaxNoteLength.mockResolvedValue(500);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'wordsmith' }));
@@ -273,7 +306,13 @@ describe('LibraryEvaluator', () => {
it('awards box_of_crayons when all 5 colors are used', async () => {
repo.countDistinctColors.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
payload: { userId: 1, bookId: 1, annotationId: 9 },
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'box_of_crayons' }));
@@ -282,7 +321,13 @@ describe('LibraryEvaluator', () => {
it('does not award box_of_crayons with only 4 colors', async () => {
repo.countDistinctColors.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
payload: { userId: 1, bookId: 1, annotationId: 9 },
},
new Set(),
);
expect(awards.find((a) => a.key === 'box_of_crayons')).toBeUndefined();
@@ -291,7 +336,7 @@ describe('LibraryEvaluator', () => {
it('awards box_of_crayons via backfill', async () => {
repo.countDistinctColors.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'box_of_crayons' }));
@@ -52,7 +52,7 @@ export class LibraryEvaluator implements IAchievementEvaluator {
const payload = ctx.payload as unknown as AnnotationCreatedPayload;
await this.evaluateAnnotatorTiers(ctx.userId, earnedKeys, awards);
await this.evaluateNoteKeeper(ctx.userId, earnedKeys, awards);
await this.evaluateDeepDiveSession(ctx.userId, payload, earnedKeys, awards);
await this.evaluateDeepDiveSession(ctx.userId, ctx.timeZone, payload, earnedKeys, awards);
await this.evaluateWordsmith(ctx.userId, payload, earnedKeys, awards);
await this.evaluateBoxOfCrayons(ctx.userId, earnedKeys, awards);
}
@@ -145,13 +145,13 @@ export class LibraryEvaluator implements IAchievementEvaluator {
private async evaluateDeepDiveSession(
userId: number,
timeZone: string,
payload: AnnotationCreatedPayload,
earnedKeys: Set<string>,
awards: AchievementAward[],
): Promise<void> {
if (earnedKeys.has('deep_dive_session')) return;
const today = new Date();
const count = await this.repo.countAnnotationsOnDay(userId, today);
const count = await this.repo.countAnnotationsOnDay(userId, new Date(), timeZone);
if (count >= DEEP_DIVE_SESSION_THRESHOLD) {
awards.push({ key: 'deep_dive_session', context: { count, bookId: payload.bookId } });
}
@@ -46,7 +46,13 @@ describe('MilestonesEvaluator', () => {
describe('bookmarked', () => {
it('awards bookmarked on annotation creation', async () => {
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 5 } as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
payload: { userId: 1, bookId: 5 } as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'bookmarked' }));
@@ -189,7 +195,7 @@ describe('MilestonesEvaluator', () => {
it('awards first_series via backfill', async () => {
repo.hasFinishedBookInSeries.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'first_series' }));
@@ -200,7 +206,13 @@ describe('MilestonesEvaluator', () => {
it('awards category_sweeper when all 4 categories have at least one badge earned', async () => {
repo.countDistinctEarnedCategories.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED, payload: { userId: 1, keys: ['some_key'] } as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED,
payload: { userId: 1, keys: ['some_key'] } as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'category_sweeper' }));
@@ -209,7 +221,13 @@ describe('MilestonesEvaluator', () => {
it('does not award category_sweeper below 4 categories', async () => {
repo.countDistinctEarnedCategories.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED, payload: { userId: 1, keys: ['some_key'] } as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED,
payload: { userId: 1, keys: ['some_key'] } as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'category_sweeper')).toBeUndefined();
@@ -218,7 +236,13 @@ describe('MilestonesEvaluator', () => {
it('does not award category_sweeper when already earned', async () => {
repo.countDistinctEarnedCategories.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED, payload: { userId: 1, keys: ['some_key'] } as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED,
payload: { userId: 1, keys: ['some_key'] } as never,
},
new Set(['category_sweeper']),
);
expect(awards.find((a) => a.key === 'category_sweeper')).toBeUndefined();
@@ -227,7 +251,7 @@ describe('MilestonesEvaluator', () => {
it('awards category_sweeper via backfill', async () => {
repo.countDistinctEarnedCategories.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'category_sweeper' }));
@@ -236,7 +260,7 @@ describe('MilestonesEvaluator', () => {
it('does not award category_sweeper via backfill below 4 categories', async () => {
repo.countDistinctEarnedCategories.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards.find((a) => a.key === 'category_sweeper')).toBeUndefined();
@@ -247,7 +271,7 @@ describe('MilestonesEvaluator', () => {
it('awards bookmarked via backfill when user has annotations', async () => {
repo.countAnnotations.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'bookmarked' }));
@@ -256,7 +280,7 @@ describe('MilestonesEvaluator', () => {
it('does not award bookmarked via backfill when no annotations', async () => {
repo.countAnnotations.mockResolvedValue(0);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards.find((a) => a.key === 'bookmarked')).toBeUndefined();
@@ -265,7 +289,7 @@ describe('MilestonesEvaluator', () => {
it('awards new_year_reader via backfill when a Jan 1 session exists', async () => {
repo.hasSessionOnJanFirst.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'new_year_reader' }));
@@ -338,7 +362,7 @@ describe('MilestonesEvaluator', () => {
it('awards dnf_self_aware via backfill when an abandoned book exists', async () => {
repo.hasAbandonedBook.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'dnf_self_aware' }));
@@ -19,7 +19,7 @@ function makeRatingPayload(overrides: Partial<Record<string, unknown>> = {}) {
describe('RatingEvaluator', () => {
let evaluator: RatingEvaluator;
let repo: ReturnType<typeof makeRepo>;
const backfillCtx = { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
const backfillCtx = { userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
beforeEach(() => {
repo = makeRepo();
@@ -27,7 +27,7 @@ describe('RatingEvaluator', () => {
});
function ratingCtx(payload = makeRatingPayload()) {
return { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, payload: payload as never };
return { userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, payload: payload as never };
}
it('supports rating-changed and backfill events', () => {
@@ -59,7 +59,7 @@ function makeStatusPayload(overrides: Partial<Record<string, unknown>> = {}) {
describe('ReadingEvaluator', () => {
let evaluator: ReadingEvaluator;
let repo: ReturnType<typeof makeRepo>;
const backfillCtx = { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
const backfillCtx = { userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
beforeEach(() => {
repo = makeRepo();
@@ -77,7 +77,7 @@ describe('ReadingEvaluator', () => {
it('awards tier 1 when 1 book finished', async () => {
repo.countFinishedBooks.mockResolvedValue(1);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'books_finished_1' }));
@@ -86,7 +86,7 @@ describe('ReadingEvaluator', () => {
it('awards multiple tiers at once when count is high enough', async () => {
repo.countFinishedBooks.mockResolvedValue(55);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
const keys = awards.map((a) => a.key);
@@ -100,7 +100,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(55);
const earned = new Set(['books_finished_1', 'books_finished_2']);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
earned,
);
const keys = awards.map((a) => a.key);
@@ -115,6 +115,7 @@ describe('ReadingEvaluator', () => {
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: makeStatusPayload({ newStatus: 'reading' }) as never,
},
@@ -129,7 +130,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.sumPagesRead.mockResolvedValue(500);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'pages_read_1' }));
@@ -138,7 +139,13 @@ describe('ReadingEvaluator', () => {
it('awards pages_read tiers on reading session events', async () => {
repo.sumPagesRead.mockResolvedValue(500);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'pages_read_1' }));
@@ -149,7 +156,13 @@ describe('ReadingEvaluator', () => {
it('awards hours_read_1 at 10 hours', async () => {
repo.sumReadingHours.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
{
userId: 1,
isSuperuser: false,
timeZone: 'UTC',
eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
payload: makeSessionPayload() as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'hours_read_1' }));
@@ -303,7 +316,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.getBookPageCount.mockResolvedValue(320);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'long_book_1' }));
@@ -314,7 +327,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.getBookPageCount.mockResolvedValue(550);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'long_book_1' }));
@@ -327,7 +340,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.getBookPageCount.mockResolvedValue(780);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'long_book_1' }));
@@ -340,7 +353,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.getBookPageCount.mockResolvedValue(1100);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'long_book_1' }));
@@ -353,7 +366,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.getBookPageCount.mockResolvedValue(1200);
await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(repo.getBookPageCount).toHaveBeenCalledOnce();
@@ -365,7 +378,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(1);
repo.wasBookStartedAndFinishedOnSameDay.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'sprint' }));
@@ -377,7 +390,7 @@ describe('ReadingEvaluator', () => {
repo.countFinishedBooks.mockResolvedValue(3);
repo.countBooksFinishedInDateRange.mockResolvedValue(3);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'binge_reader' }));
@@ -460,7 +473,7 @@ describe('ReadingEvaluator', () => {
const finishedAt = new Date('2025-04-15T00:00:00Z'); // 104 days later
repo.getBookStartedAndFinishedAt.mockResolvedValue({ startedAt, finishedAt });
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'slow_burn' }));
@@ -471,7 +484,7 @@ describe('ReadingEvaluator', () => {
const finishedAt = new Date('2025-04-01T00:00:00Z'); // exactly 90 days
repo.getBookStartedAndFinishedAt.mockResolvedValue({ startedAt, finishedAt });
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'slow_burn')).toBeUndefined();
@@ -480,7 +493,7 @@ describe('ReadingEvaluator', () => {
it('does not award slow_burn when dates are missing', async () => {
repo.getBookStartedAndFinishedAt.mockResolvedValue({ startedAt: null, finishedAt: null });
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'slow_burn')).toBeUndefined();
@@ -489,7 +502,7 @@ describe('ReadingEvaluator', () => {
it('does not award slow_burn when status book info returns null', async () => {
repo.getBookStartedAndFinishedAt.mockResolvedValue(null);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'slow_burn')).toBeUndefined();
@@ -500,7 +513,7 @@ describe('ReadingEvaluator', () => {
const finishedAt = new Date('2025-05-01T00:00:00Z');
repo.getBookStartedAndFinishedAt.mockResolvedValue({ startedAt, finishedAt });
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['slow_burn']),
);
expect(awards.find((a) => a.key === 'slow_burn')).toBeUndefined();
@@ -597,7 +610,7 @@ describe('ReadingEvaluator', () => {
it('awards monthly_reader_2 when 10+ books finished in current month', async () => {
repo.countBooksFinishedInMonth.mockResolvedValue(10);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'monthly_reader_2' }));
@@ -606,7 +619,7 @@ describe('ReadingEvaluator', () => {
it('does not award monthly_reader_2 when fewer than 10 books', async () => {
repo.countBooksFinishedInMonth.mockResolvedValue(9);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'monthly_reader_2')).toBeUndefined();
@@ -615,7 +628,7 @@ describe('ReadingEvaluator', () => {
it('does not award monthly_reader_2 when already earned', async () => {
repo.countBooksFinishedInMonth.mockResolvedValue(15);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
{ userId: 1, isSuperuser: false, timeZone: 'UTC', eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, payload: makeStatusPayload() as never },
new Set(['monthly_reader_2']),
);
expect(awards.find((a) => a.key === 'monthly_reader_2')).toBeUndefined();
@@ -87,7 +87,7 @@ export class ReadingEvaluator implements IAchievementEvaluator {
this.evaluateEarlyBird(payload, earnedKeys, awards);
this.evaluateOneSitting(payload, earnedKeys, awards);
await this.evaluatePowerHour(payload, earnedKeys, awards);
await this.evaluateSpeedReader(ctx.userId, payload, earnedKeys, awards);
await this.evaluateSpeedReader(ctx.userId, ctx.timeZone, payload, earnedKeys, awards);
}
if (ctx.eventName === ACHIEVEMENT_EVENT_BACKFILL) {
@@ -104,7 +104,7 @@ export class ReadingEvaluator implements IAchievementEvaluator {
await this.evaluateMonthlyReader2Backfill(ctx.userId, earnedKeys, awards);
await this.evaluateOneSittingBackfill(ctx.userId, earnedKeys, awards);
await this.evaluatePowerHourBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateSpeedReaderBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateSpeedReaderBackfill(ctx.userId, ctx.timeZone, earnedKeys, awards);
}
return awards;
@@ -338,12 +338,13 @@ export class ReadingEvaluator implements IAchievementEvaluator {
private async evaluateSpeedReader(
userId: number,
timeZone: string,
payload: ReadingSessionSavedPayload,
earnedKeys: Set<string>,
awards: AchievementAward[],
): Promise<void> {
if (earnedKeys.has('speed_reader')) return;
const pages = await this.repo.getPagesOnDay(userId, payload.startedAt);
const pages = await this.repo.getPagesOnDay(userId, payload.startedAt, timeZone);
if (pages >= SPEED_READER_PAGES) {
awards.push({ key: 'speed_reader', context: { pages } });
}
@@ -357,9 +358,9 @@ export class ReadingEvaluator implements IAchievementEvaluator {
}
}
private async evaluateSpeedReaderBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
private async evaluateSpeedReaderBackfill(userId: number, timeZone: string, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('speed_reader')) return;
const maxDayPages = await this.repo.getMaxPagesInADay(userId);
const maxDayPages = await this.repo.getMaxPagesInADay(userId, timeZone);
if (maxDayPages >= SPEED_READER_PAGES) {
awards.push({ key: 'speed_reader', context: { pages: maxDayPages } });
}
@@ -70,6 +70,7 @@ export class LibraryScanSchedulerService implements OnApplicationBootstrap, OnMo
const job = CronJob.from({
cronTime: expression,
timeZone: this.timeZone,
onTick: () => this.runScheduledScan(libraryId),
start: false,
});
@@ -86,7 +86,7 @@ export class ReadingSessionService {
endedAt,
progressDelta: dto.progressDelta ?? null,
endProgress: dto.endProgress ?? null,
timezone: (user.settings as unknown as UserSettings)?.timezone ?? 'UTC',
timezone: resolveTimeZone((user.settings as unknown as UserSettings)?.timezone, 'UTC'),
});
}
} catch (error) {