diff --git a/Dockerfile b/Dockerfile index 1c2324dd1..098a7630f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/docker-compose.yml b/docker-compose.yml index 8892f9472..b0eca0b91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/server/src/common/utils/timezone.utils.test.ts b/server/src/common/utils/timezone.utils.test.ts index cf00819e5..4cd529f45 100644 --- a/server/src/common/utils/timezone.utils.test.ts +++ b/server/src/common/utils/timezone.utils.test.ts @@ -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); + }); }); diff --git a/server/src/common/utils/timezone.utils.ts b/server/src/common/utils/timezone.utils.ts index c702afb68..c2a6e7473 100644 --- a/server/src/common/utils/timezone.utils.ts +++ b/server/src/common/utils/timezone.utils.ts @@ -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)); +} diff --git a/server/src/modules/achievement/achievement.repository.test.ts b/server/src/modules/achievement/achievement.repository.test.ts index 2b2e1f915..91b6302a7 100644 --- a/server/src/modules/achievement/achievement.repository.test.ts +++ b/server/src/modules/achievement/achievement.repository.test.ts @@ -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[] { + 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)) boundValues(value, out, seen); + return out; +} + function makeSelectChain(resolvedValue: unknown) { const chain: Record> = {}; 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); }); diff --git a/server/src/modules/achievement/achievement.repository.ts b/server/src/modules/achievement/achievement.repository.ts index e8bc65b22..34434dbe5 100644 --- a/server/src/modules/achievement/achievement.repository.ts +++ b/server/src/modules/achievement/achievement.repository.ts @@ -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 { + 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 { const [{ value }] = await this.db .select({ value: count() }) @@ -265,8 +272,11 @@ export class AchievementRepository { return row?.title ?? null; } - async getCurrentStreak(userId: number): Promise { - // Count consecutive days with reading activity ending at today or yesterday + async getCurrentStreak(userId: number, timeZone: string): Promise { + // 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 { - 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 { + 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 { - 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 { + 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`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 { + async getMaxPagesInADay(userId: number, timeZone: string): Promise { 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; diff --git a/server/src/modules/achievement/achievement.service.test.ts b/server/src/modules/achievement/achievement.service.test.ts index 27ca250b2..628417bae 100644 --- a/server/src/modules/achievement/achievement.service.test.ts +++ b/server/src/modules/achievement/achievement.service.test.ts @@ -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), diff --git a/server/src/modules/achievement/achievement.service.ts b/server/src/modules/achievement/achievement.service.ts index 7c220feaa..59d49ce1e 100644 --- a/server/src/modules/achievement/achievement.service.ts +++ b/server/src/modules/achievement/achievement.service.ts @@ -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(); @@ -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, notify: boolean): Promise { + private async evaluateMetaBadges(userId: number, isSuperuser: boolean, timeZone: string, earnedKeys: Set, notify: boolean): Promise { 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, ): Promise> { @@ -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 { + private async computeSingleBadgeProgress(userId: number, isSuperuser: boolean, timeZone: string, key: string): Promise { 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 { + private async getProgressForGroup(userId: number, isSuperuser: boolean, timeZone: string, groupKey: string): Promise { 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': diff --git a/server/src/modules/achievement/evaluators/dedication.evaluator.test.ts b/server/src/modules/achievement/evaluators/dedication.evaluator.test.ts index 36a4d100f..523d39a0f 100644 --- a/server/src/modules/achievement/evaluators/dedication.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/dedication.evaluator.test.ts @@ -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(); diff --git a/server/src/modules/achievement/evaluators/dedication.evaluator.ts b/server/src/modules/achievement/evaluators/dedication.evaluator.ts index f3867e789..85bf51630 100644 --- a/server/src/modules/achievement/evaluators/dedication.evaluator.ts +++ b/server/src/modules/achievement/evaluators/dedication.evaluator.ts @@ -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, awards: AchievementAward[]): Promise { + private async evaluateStreakTiers(userId: number, timeZone: string, earnedKeys: Set, awards: AchievementAward[]): Promise { 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, awards: AchievementAward[]): Promise { + private async evaluateYearRoundReader(userId: number, timeZone: string, earnedKeys: Set, awards: AchievementAward[]): Promise { 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, awards: AchievementAward[]): Promise { + private async evaluateSeasonalReader(userId: number, timeZone: string, earnedKeys: Set, awards: AchievementAward[]): Promise { 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 } }); diff --git a/server/src/modules/achievement/evaluators/devices.evaluator.test.ts b/server/src/modules/achievement/evaluators/devices.evaluator.test.ts index 530f7aa14..3268396be 100644 --- a/server/src/modules/achievement/evaluators/devices.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/devices.evaluator.test.ts @@ -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); diff --git a/server/src/modules/achievement/evaluators/evaluator.interface.ts b/server/src/modules/achievement/evaluators/evaluator.interface.ts index 40437a45b..857e994dd 100644 --- a/server/src/modules/achievement/evaluators/evaluator.interface.ts +++ b/server/src/modules/achievement/evaluators/evaluator.interface.ts @@ -7,6 +7,7 @@ export interface EvaluationContext { userId: number; isSuperuser: boolean; eventName: string; + timeZone: string; payload: Record; } diff --git a/server/src/modules/achievement/evaluators/exploration.evaluator.test.ts b/server/src/modules/achievement/evaluators/exploration.evaluator.test.ts index 6b29c42f1..74e54fe37 100644 --- a/server/src/modules/achievement/evaluators/exploration.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/exploration.evaluator.test.ts @@ -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); diff --git a/server/src/modules/achievement/evaluators/exploration.evaluator.ts b/server/src/modules/achievement/evaluators/exploration.evaluator.ts index e949af3f0..8ce173ac2 100644 --- a/server/src/modules/achievement/evaluators/exploration.evaluator.ts +++ b/server/src/modules/achievement/evaluators/exploration.evaluator.ts @@ -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): Promise { 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): Promise { + private async evaluateBackfill(userId: number, timeZone: string, earnedKeys: Set): Promise { 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, awards: AchievementAward[]): Promise { + private async evaluateNewRelease( + userId: number, + timeZone: string, + bookId: number, + earnedKeys: Set, + awards: AchievementAward[], + ): Promise { 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, awards: AchievementAward[]): Promise { + private async evaluateNewReleaseBackfill(userId: number, timeZone: string, earnedKeys: Set, awards: AchievementAward[]): Promise { 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 } }); diff --git a/server/src/modules/achievement/evaluators/library.evaluator.test.ts b/server/src/modules/achievement/evaluators/library.evaluator.test.ts index 6b3689cd1..fbdcef23b 100644 --- a/server/src/modules/achievement/evaluators/library.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/library.evaluator.test.ts @@ -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' })); diff --git a/server/src/modules/achievement/evaluators/library.evaluator.ts b/server/src/modules/achievement/evaluators/library.evaluator.ts index af7ce0016..0ca81eaf9 100644 --- a/server/src/modules/achievement/evaluators/library.evaluator.ts +++ b/server/src/modules/achievement/evaluators/library.evaluator.ts @@ -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, awards: AchievementAward[], ): Promise { 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 } }); } diff --git a/server/src/modules/achievement/evaluators/milestones.evaluator.test.ts b/server/src/modules/achievement/evaluators/milestones.evaluator.test.ts index 44c5bc213..fea48883e 100644 --- a/server/src/modules/achievement/evaluators/milestones.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/milestones.evaluator.test.ts @@ -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' })); diff --git a/server/src/modules/achievement/evaluators/rating.evaluator.test.ts b/server/src/modules/achievement/evaluators/rating.evaluator.test.ts index 48d69f4e1..fea08b016 100644 --- a/server/src/modules/achievement/evaluators/rating.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/rating.evaluator.test.ts @@ -19,7 +19,7 @@ function makeRatingPayload(overrides: Partial> = {}) { describe('RatingEvaluator', () => { let evaluator: RatingEvaluator; let repo: ReturnType; - 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', () => { diff --git a/server/src/modules/achievement/evaluators/reading.evaluator.test.ts b/server/src/modules/achievement/evaluators/reading.evaluator.test.ts index 6b35b9c11..7c421b5fd 100644 --- a/server/src/modules/achievement/evaluators/reading.evaluator.test.ts +++ b/server/src/modules/achievement/evaluators/reading.evaluator.test.ts @@ -59,7 +59,7 @@ function makeStatusPayload(overrides: Partial> = {}) { describe('ReadingEvaluator', () => { let evaluator: ReadingEvaluator; let repo: ReturnType; - 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(); diff --git a/server/src/modules/achievement/evaluators/reading.evaluator.ts b/server/src/modules/achievement/evaluators/reading.evaluator.ts index c334019b5..3a980047c 100644 --- a/server/src/modules/achievement/evaluators/reading.evaluator.ts +++ b/server/src/modules/achievement/evaluators/reading.evaluator.ts @@ -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, awards: AchievementAward[], ): Promise { 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, awards: AchievementAward[]): Promise { + private async evaluateSpeedReaderBackfill(userId: number, timeZone: string, earnedKeys: Set, awards: AchievementAward[]): Promise { 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 } }); } diff --git a/server/src/modules/library/library-scan-scheduler.service.ts b/server/src/modules/library/library-scan-scheduler.service.ts index 5bca26cfa..0194173b8 100644 --- a/server/src/modules/library/library-scan-scheduler.service.ts +++ b/server/src/modules/library/library-scan-scheduler.service.ts @@ -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, }); diff --git a/server/src/modules/reading-session/reading-session.service.ts b/server/src/modules/reading-session/reading-session.service.ts index ca256a88b..4c0ad4571 100644 --- a/server/src/modules/reading-session/reading-session.service.ts +++ b/server/src/modules/reading-session/reading-session.service.ts @@ -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) {