fix(books): record reading dates on the reader local calendar day

Reading attempt date keys came from toISOString(), which is UTC's day, so an evening finish west
of Greenwich filed tomorrow while the reading session it came from filed today. Callers now pass
the instant and UserBookStatusService resolves the reader timezone itself, because device syncs
carry no request user and a caller that forgot would silently fall back to UTC.

Closes #1458
This commit is contained in:
neon
2026-09-21 02:39:51 +00:00
parent b057e5e9d1
commit 2759893bf6
13 changed files with 363 additions and 62 deletions
+20 -4
View File
@@ -2474,7 +2474,11 @@ describe('BookService', () => {
expect.any(Date),
);
expect(libraryService.findOne).toHaveBeenCalledWith(2);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97, {
origin: 'bookorbit',
timeZone: 'UTC',
strongRereadEvidence: false,
});
});
it('does not fail progress save when auto status update fails', async () => {
@@ -2507,7 +2511,11 @@ describe('BookService', () => {
null,
expect.any(Date),
);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97, {
origin: 'bookorbit',
timeZone: 'UTC',
strongRereadEvidence: false,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.progress_status_update] [fail] userId=1 bookId=11 libraryId=2'));
warnSpy.mockRestore();
});
@@ -2538,7 +2546,11 @@ describe('BookService', () => {
);
expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(user.id, 8, 50, 'OEBPS/ch1.xhtml', 'KoboSpan', 'kobo.25.1', 25);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 4, 90);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 4, 90, {
origin: 'bookorbit',
timeZone: 'UTC',
strongRereadEvidence: false,
});
});
it('does not mirror EPUB percentage to Kobo state when two-way sync is disabled', async () => {
@@ -2615,7 +2627,11 @@ describe('BookService', () => {
await service.saveProgress(user.id, 8, { percentage: 2.43, source: 'narration' } as never, user);
expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(user.id, 8, 32, null, null, null, null);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 32, 4, 90);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 32, 4, 90, {
origin: 'bookorbit',
timeZone: 'UTC',
strongRereadEvidence: false,
});
});
it('treats a narration write on a file with no stored position as progress', async () => {
+18 -24
View File
@@ -2154,12 +2154,11 @@ export class BookService {
sourceUpdatedAt: saved.capturedAt,
});
const strongRereadEvidence = previous != null && previous.percentage - dto.percentage >= 10;
await this.autoUpdateReadStatusForProgress(
userId,
{ bookId, libraryId },
dto.percentage,
strongRereadEvidence ? { origin: 'bookorbit', strongRereadEvidence: true } : {},
);
await this.autoUpdateReadStatusForProgress(userId, { bookId, libraryId }, dto.percentage, {
origin: 'bookorbit',
timeZone: this.resolveUserTimeZone(user),
strongRereadEvidence,
});
}
async syncEbookProgressForAudiobookPlayback(
@@ -2190,18 +2189,14 @@ export class BookService {
const startedAt = Date.now();
try {
const library = await this.libraryService.findOne(file.libraryId);
if (Object.keys(activity).length > 0) {
await this.userBookStatusService.autoUpdate(
userId,
file.bookId,
percentage,
library.readingThreshold,
library.markAsFinishedPercentComplete,
activity,
);
} else {
await this.userBookStatusService.autoUpdate(userId, file.bookId, percentage, library.readingThreshold, library.markAsFinishedPercentComplete);
}
await this.userBookStatusService.autoUpdate(
userId,
file.bookId,
percentage,
library.readingThreshold,
library.markAsFinishedPercentComplete,
activity,
);
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
@@ -2290,12 +2285,11 @@ export class BookService {
syncSiblingEpubs: text.moved,
});
const strongRereadEvidence = previous != null && previous.percentage - text.percentage >= 10;
await this.autoUpdateReadStatusForProgress(
userId,
file,
text.percentage,
strongRereadEvidence ? { origin: 'bookorbit', strongRereadEvidence: true } : {},
);
await this.autoUpdateReadStatusForProgress(userId, file, text.percentage, {
origin: 'bookorbit',
timeZone: this.resolveUserTimeZone(user),
strongRereadEvidence,
});
}
async syncAudioProgressForExternalEbookProgress(
@@ -342,6 +342,12 @@ describe('KoboReadingStateService', () => {
99,
expect.objectContaining({ origin: 'kobo', strongRereadEvidence: false }),
);
// Issue #1458: the activity instant travels intact so the day is resolved on the reader's
// calendar downstream, rather than being pre-truncated to a UTC date key here.
const koboActivity = userBookStatusService.autoUpdate.mock.calls[0]![5] as { occurredAt: Date; timeZone: string };
expect(koboActivity.occurredAt).toBeInstanceOf(Date);
expect(Number.isNaN(koboActivity.occurredAt.getTime())).toBe(false);
expect(typeof koboActivity.timeZone).toBe('string');
expect(progressInsert.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
@@ -209,7 +209,7 @@ export class KoboReadingStateService {
if ((bookmarkChanged || statusChanged) && mergedPercent !== null) {
await this.autoUpdateReadStatus(userId, bookId, mergedPercent, readingThreshold, finishedThreshold, {
occurredOn: effectiveLastModified.slice(0, 10),
occurredAt: new Date(effectiveLastModified),
strongRereadEvidence,
});
}
@@ -333,13 +333,14 @@ export class KoboReadingStateService {
percent: number,
readingThreshold: number,
finishedThreshold: number,
activity: { occurredOn: string; strongRereadEvidence: boolean },
activity: { occurredAt: Date; strongRereadEvidence: boolean },
): Promise<void> {
const startedAt = Date.now();
try {
await this.userBookStatusService.autoUpdate(userId, bookId, percent, readingThreshold, finishedThreshold, {
origin: 'kobo',
occurredOn: activity.occurredOn,
occurredAt: activity.occurredAt,
timeZone: await this.findUserTimeZone(userId),
strongRereadEvidence: activity.strongRereadEvidence,
});
} catch (error: unknown) {
@@ -16,8 +16,8 @@ const DEVICE_ID = 'abcdef12-3456-7890-abcd-ef1234567890';
const HASH_A = 'a'.repeat(32);
const HASH_B = 'b'.repeat(32);
function makeUser(): RequestUser {
return { id: 7, settings: {} } as unknown as RequestUser;
function makeUser(settings: Record<string, unknown> = {}): RequestUser {
return { id: 7, settings } as unknown as RequestUser;
}
function deviceFields() {
@@ -279,10 +279,19 @@ describe('KoreaderPluginService', () => {
it('applies a status when no server status exists', async () => {
const result = await service.uploadBookStates(makeUser(), statesDto([{ hash: HASH_A, status: 'complete', statusModified: '2026-06-01' }]));
expect(userBookStatusService.setManual).toHaveBeenCalledWith(7, 20, 'read');
expect(userBookStatusService.setManual).toHaveBeenCalledWith(7, 20, 'read', 'UTC');
expect(result.results[0]).toMatchObject({ hash: HASH_A, statusApplied: true, ratingApplied: false, reviewApplied: false });
});
it('files a batched status change on the reader own calendar, resolved once for the batch', async () => {
await service.uploadBookStates(
makeUser({ timezone: 'America/Chicago' }),
statesDto([{ hash: HASH_A, status: 'complete', statusModified: '2026-06-01' }]),
);
expect(userBookStatusService.setManual).toHaveBeenCalledWith(7, 20, 'read', 'America/Chicago');
});
it('treats an identical status as applied without writing', async () => {
serverStatus('read', '2026-06-05T08:00:00.000Z');
@@ -297,7 +306,7 @@ describe('KoreaderPluginService', () => {
await service.uploadBookStates(makeUser(), statesDto([{ hash: HASH_A, status: 'abandoned', statusModified: '2026-06-06' }]));
expect(userBookStatusService.setManual).toHaveBeenCalledWith(7, 20, 'abandoned');
expect(userBookStatusService.setManual).toHaveBeenCalledWith(7, 20, 'abandoned', 'UTC');
});
it('keeps the server status on a same-day tie or older device date', async () => {
@@ -520,6 +529,7 @@ describe('KoreaderPluginService', () => {
{ bookFile: { id: 11, bookId: 21, libraryId: 1 }, percentage: 0.1, progress: undefined, timestamp: undefined },
],
{ device: 'Kobo Libra 2', deviceId: DEVICE_ID },
'UTC',
);
expect(result.results).toEqual([
{ hash: HASH_A, accepted: true },
@@ -542,10 +552,26 @@ describe('KoreaderPluginService', () => {
7,
[{ bookFile: { id: 10, bookId: 20, libraryId: 1 }, percentage: 0.4, progress: undefined, timestamp: undefined }],
expect.any(Object),
'UTC',
);
});
});
describe('bulkProgress timezone', () => {
it('resolves the reader own timezone once for the whole sweep', async () => {
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1, format: 'epub' }]]));
koreaderService.applyBulkProgress.mockResolvedValue({ shared: 1, stale: 0, held: 0 });
await service.bulkProgress(makeUser({ timezone: 'America/Chicago' }), {
...deviceFields(),
items: [{ hash: HASH_A, percentage: 0.4 }],
} as BulkProgressDto);
expect(koreaderService.applyBulkProgress).toHaveBeenCalledTimes(1);
expect(koreaderService.applyBulkProgress.mock.calls[0]![3]).toBe('America/Chicago');
});
});
describe('sweepComplete', () => {
it('records the sweep and returns the library version', async () => {
const dto = { ...deviceFields(), booksMatched: 3, pageStatsUploaded: 120, annotationsUpserted: 4 } as SweepCompleteDto;
@@ -5,6 +5,7 @@ import type { ReadStatus, UserBookStatus } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { mapWithConcurrency } from '../../common/utils/batch.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
import { ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, AchievementEventsService } from '../achievement/achievement-events.service';
import { UserBookNoteService, type UserBookNoteDto } from '../user-book-note/user-book-note.service';
import { BookService } from '../book/book.service';
@@ -75,6 +76,8 @@ interface BookStateBatch {
ratingWrites: Map<number, number | null>;
noteWrites: Map<number, string | null>;
appliedAt: Date;
/** Resolved once for the request so a large batch does not look the reader's day up per book. */
timeZone: string;
}
export interface BulkProgressResult {
@@ -172,6 +175,7 @@ export class KoreaderPluginService {
ratingWrites: new Map<number, number | null>(),
noteWrites: new Map<number, string | null>(),
appliedAt: new Date(),
timeZone: resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC'),
};
// Several input entries can resolve to one book (duplicate hashes, or different
@@ -239,10 +243,12 @@ export class KoreaderPluginService {
results.push({ hash, accepted: true });
}
const applied = await this.koreaderService.applyBulkProgress(user.id, entries, {
device: dto.deviceModel,
deviceId: dto.deviceId,
});
const applied = await this.koreaderService.applyBulkProgress(
user.id,
entries,
{ device: dto.deviceModel, deviceId: dto.deviceId },
resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC'),
);
this.logger.log(
`[${BULK_PROGRESS_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} accepted=${results.length} shared=${applied.shared} stale=${applied.stale} held=${applied.held} unmatched=${unmatched.length} - bulk progress completed`,
@@ -436,7 +442,7 @@ export class KoreaderPluginService {
if (!statusModified || statusModified <= serverDate) return false;
}
await this.userBookStatusService.setManual(userId, bookId, mapped);
await this.userBookStatusService.setManual(userId, bookId, mapped, state.timeZone);
state.staleStatusBookIds.add(bookId);
return true;
}
+14 -10
View File
@@ -7,6 +7,7 @@ import { StatsCache } from '../../common/cache/stats-cache';
import type { RequestUser } from '../../common/types/request-user';
import { mapWithConcurrency } from '../../common/utils/batch.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
import { isSemverNewer } from '../../common/utils/semver.utils';
import { KoreaderRepository, type DeviceProgressUpsert } from './koreader.repository';
import { KoreaderChapterService } from './koreader-chapter.service';
@@ -176,13 +177,12 @@ export class KoreaderService {
throw new NotFoundException('Book not found for the given document hash');
}
await this.applyProgressForResolvedFile(userId, bookFile, {
percentage: data.percentage,
progress: data.progress,
device,
deviceId,
timestamp: data.timestamp,
});
await this.applyProgressForResolvedFile(
userId,
bookFile,
{ percentage: data.percentage, progress: data.progress, device, deviceId, timestamp: data.timestamp },
{ timeZone: resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC') },
);
this.logger.log(
`[${SYNC_EVENT}] [end] userId=${userId} bookFileId=${bookFile.id} device=${device} durationMs=${Date.now() - startedAt} percentage=${data.percentage} - save progress completed`,
@@ -195,7 +195,7 @@ export class KoreaderService {
userId: number,
bookFile: KoreaderProgressBookFile,
data: { percentage: number; progress?: string; device: string; deviceId: string; timestamp?: number },
options?: { skipSharedProgress?: boolean },
options?: { skipSharedProgress?: boolean; timeZone?: string },
): Promise<KoreaderProgressApplyResult> {
const chapterIndex = this.chapterService.parseChapterIndexFromProgress(data.progress ?? null);
@@ -226,7 +226,7 @@ export class KoreaderService {
if (!(await this.resolveResetHold(userId, bookFile, data))) return result;
await this.applySharedProgress(userId, bookFile, data, result.previousPercentage);
await this.applySharedProgress(userId, bookFile, data, result.previousPercentage, options?.timeZone);
result.shared = true;
return result;
}
@@ -288,6 +288,7 @@ export class KoreaderService {
userId: number,
entries: BulkProgressEntry[],
device: { device: string; deviceId: string },
timeZone?: string,
): Promise<{ shared: number; stale: number; held: number }> {
if (entries.length === 0) return { shared: 0, stale: 0, held: 0 };
@@ -394,6 +395,7 @@ export class KoreaderService {
plan.entry.bookFile,
{ percentage: plan.entry.percentage, progress: plan.entry.progress, timestamp: plan.entry.timestamp },
plan.previousPercentage,
timeZone,
);
}
});
@@ -406,6 +408,7 @@ export class KoreaderService {
bookFile: KoreaderProgressBookFile,
data: { percentage: number; progress?: string; timestamp?: number },
previousPercentage: number | null,
timeZone?: string,
) {
const bookorbitPercentage = toBookorbitPercentage(data.percentage);
// KOReader reports a paged document's position as a page number and a reflowable one's as
@@ -433,7 +436,8 @@ export class KoreaderService {
const strongRereadEvidence = previousPercentage !== null && previousPercentage - bookorbitPercentage >= 10;
await this.bookService.autoUpdateReadStatusForProgress(userId, bookFile, bookorbitPercentage, {
origin: 'koreader',
occurredOn: data.timestamp ? new Date(data.timestamp * 1000).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
occurredAt: data.timestamp ? new Date(data.timestamp * 1000) : new Date(),
timeZone,
strongRereadEvidence,
});
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
@@ -524,3 +524,64 @@ describe('ReadingSessionService - deleteSessionByBook', () => {
expect(bookAccessOrder).toBeLessThan(repoOrder);
});
});
/**
* Issue #1458: the session itself was filed on the reader's local day while the read status it
* triggers was filed on UTC's, so an evening finish showed a session on one date and a Finish
* Date on the next. Both now come off the same timezone.
*/
describe('ReadingSessionService - read status activity', () => {
const repo = { saveSession: vi.fn() };
const bookService = {
verifyFileAccess: vi.fn(),
autoUpdateReadStatusForProgress: vi.fn(),
};
let service: ReadingSessionService;
// 8:08 PM on the 19th in Chicago, which is already the 20th in UTC.
const ENDED_AT = '2026-09-20T01:08:15.815Z';
const chicagoReader = makeUser({ id: 12, settings: { timezone: 'America/Chicago' } });
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
repo.saveSession.mockResolvedValue({ kind: 'saved' });
bookService.verifyFileAccess.mockResolvedValue({ id: 6558, bookId: 2179, libraryId: 1 });
bookService.autoUpdateReadStatusForProgress.mockResolvedValue(undefined);
service = new ReadingSessionService(repo as never, bookService as never, { emit: vi.fn() } as never, { invalidateUser: vi.fn() } as never);
});
async function saveFinishingSession(user: RequestUser) {
await service.save(
6558,
{ sessionId: 'session-1', startedAt: '2026-09-20T01:02:15.815Z', endedAt: ENDED_AT, durationSeconds: 360, progressDelta: 58, endProgress: 100 },
user,
);
}
it('hands the status update the instant, not a pre-truncated UTC day', async () => {
await saveFinishingSession(chicagoReader);
expect(bookService.autoUpdateReadStatusForProgress).toHaveBeenCalledWith(
12,
expect.objectContaining({ bookId: 2179 }),
100,
expect.objectContaining({ occurredAt: new Date(ENDED_AT), origin: 'bookorbit', meaningfulActivity: true }),
);
});
it('files the session and the status against the same timezone', async () => {
await saveFinishingSession(chicagoReader);
const sessionTimeZone = repo.saveSession.mock.calls[0]?.[9];
const statusTimeZone = bookService.autoUpdateReadStatusForProgress.mock.calls[0]?.[3]?.timeZone;
expect(sessionTimeZone).toBe('America/Chicago');
expect(statusTimeZone).toBe(sessionTimeZone);
});
it('falls back to UTC for a reader who has set no timezone', async () => {
await saveFinishingSession(makeUser({ id: 12 }));
expect(bookService.autoUpdateReadStatusForProgress.mock.calls[0]?.[3]?.timeZone).toBe('UTC');
});
});
@@ -89,7 +89,8 @@ export class ReadingSessionService {
if (meaningfulActivity && file && dto.endProgress != null) {
await this.bookService.autoUpdateReadStatusForProgress(user.id, file, dto.endProgress, {
origin: source === 'koreader' ? 'koreader' : 'bookorbit',
occurredOn: endedAt.toISOString().slice(0, 10),
occurredAt: endedAt,
timeZone: this.resolveUserTimeZone(user),
meaningfulActivity: true,
});
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { UserBookStatusRow } from '../../db/schema';
import { deriveLifecycle } from './user-book-status.repository';
import { deriveLifecycle, UserBookStatusRepository } from './user-book-status.repository';
import type { SessionBoundaries } from './user-book-status.repository';
function makeRow(overrides: Partial<UserBookStatusRow> = {}): UserBookStatusRow {
@@ -94,3 +94,27 @@ describe('deriveLifecycle with sessionBoundaries', () => {
}
});
});
describe('findUserTimeZone', () => {
function makeRepo(rows: { settings: unknown }[]) {
const chain = { from: () => chain, where: () => chain, limit: () => Promise.resolve(rows) };
return new UserBookStatusRepository({ select: () => chain } as never);
}
it('returns the reader configured timezone', async () => {
await expect(makeRepo([{ settings: { timezone: 'America/Chicago' } }]).findUserTimeZone(1)).resolves.toBe('America/Chicago');
});
it('falls back to UTC when the reader has set none', async () => {
await expect(makeRepo([{ settings: {} }]).findUserTimeZone(1)).resolves.toBe('UTC');
});
it('falls back to UTC rather than trusting an unusable stored value', async () => {
await expect(makeRepo([{ settings: { timezone: 'Mars/Olympus_Mons' } }]).findUserTimeZone(1)).resolves.toBe('UTC');
await expect(makeRepo([{ settings: { timezone: 42 } }]).findUserTimeZone(1)).resolves.toBe('UTC');
});
it('falls back to UTC when the user row is gone', async () => {
await expect(makeRepo([]).findUserTimeZone(1)).resolves.toBe('UTC');
});
});
@@ -3,8 +3,9 @@ import { and, eq, inArray, max, min } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
import * as schema from '../../db/schema';
import { readingSessions, userBookStatus } from '../../db/schema';
import { readingSessions, userBookStatus, users } from '../../db/schema';
import type { ReadStatus, ReadStatusSource } from '@bookorbit/types';
import type { UserBookStatusRow } from '../../db/schema';
@@ -52,6 +53,16 @@ export class UserBookStatusRepository {
return row ?? null;
}
/**
* Reading dates are calendar days in the reader's own timezone, so every path that stamps
* one has to resolve it. Device syncs carry no request user, so it is read here rather than
* threaded from the caller: a caller that forgets would silently file the day in UTC.
*/
async findUserTimeZone(userId: number): Promise<string> {
const [row] = await this.db.select({ settings: users.settings }).from(users).where(eq(users.id, userId)).limit(1);
return resolveTimeZone((row?.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
}
async findByBookIds(userId: number, bookIds: number[]) {
if (bookIds.length === 0) return [];
return this.db
@@ -32,6 +32,7 @@ const mockRepo = {
) => Promise<void>
>(),
findSessionBoundariesForBook: vi.fn<(...args: [number, number]) => Promise<SessionBoundaries>>(),
findUserTimeZone: vi.fn<(...args: [number]) => Promise<string>>(),
};
const mockAchievementEvents = {
emit: vi.fn(),
@@ -50,6 +51,7 @@ beforeEach(() => {
mockRepo.upsert.mockResolvedValue(undefined);
mockRepo.upsertState.mockResolvedValue(undefined);
mockRepo.findSessionBoundariesForBook.mockResolvedValue({ firstStartedAt: null, lastEndedAt: null });
mockRepo.findUserTimeZone.mockResolvedValue('UTC');
mockKoboProjection.isEnabled.mockResolvedValue(true);
mockKoboProjection.project.mockResolvedValue([]);
service = new UserBookStatusService(mockRepo as unknown as UserBookStatusRepository, mockAchievementEvents as never, mockKoboProjection as never);
@@ -766,3 +768,134 @@ describe('kobo status projection', () => {
});
});
});
/**
* Reading dates are calendar days, so the only question that matters is whose calendar.
* Issue #1458: they were filed against UTC's, which puts an evening read west of Greenwich
* on tomorrow and an early-morning read east of it on yesterday.
*/
describe('reading dates are filed on the reader local day', () => {
const mockAttempts = {
recordActivity: vi.fn(),
applyManualStatus: vi.fn(),
};
let attemptService: UserBookStatusService;
beforeEach(() => {
mockAttempts.recordActivity.mockReset();
mockAttempts.applyManualStatus.mockReset();
mockAttempts.recordActivity.mockResolvedValue(null);
mockAttempts.applyManualStatus.mockResolvedValue({
status: 'read',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-09-20T01:08:15.815Z',
});
attemptService = new UserBookStatusService(
mockRepo as unknown as UserBookStatusRepository,
mockAchievementEvents as never,
mockKoboProjection as never,
mockAttempts as never,
);
});
// 8:08 PM on the 19th in Chicago is already the 20th in UTC.
const EVENING_IN_CHICAGO = new Date('2026-09-20T01:08:15.815Z');
it('files an evening finish west of UTC on the day the reader just lived', async () => {
await attemptService.autoUpdate(1, 10, 100, 25, 98, { occurredAt: EVENING_IN_CHICAGO, timeZone: 'America/Chicago' });
expect(mockAttempts.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ occurredOn: '2026-09-19' }));
});
it('files an early-morning read east of UTC on the day the reader is actually in', async () => {
// 6:08 AM on the 20th in Tokyo is still the 19th in UTC.
await attemptService.autoUpdate(1, 10, 100, 25, 98, { occurredAt: new Date('2026-09-19T21:08:15.815Z'), timeZone: 'Asia/Tokyo' });
expect(mockAttempts.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ occurredOn: '2026-09-20' }));
});
it('keeps the UTC day for a reader who is actually in UTC', async () => {
await attemptService.autoUpdate(1, 10, 100, 25, 98, { occurredAt: EVENING_IN_CHICAGO, timeZone: 'UTC' });
expect(mockAttempts.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ occurredOn: '2026-09-20' }));
});
it('looks the timezone up when the caller has no request user to read it from', async () => {
mockRepo.findUserTimeZone.mockResolvedValue('America/Chicago');
await attemptService.autoUpdate(1, 10, 100, 25, 98, { occurredAt: EVENING_IN_CHICAGO });
expect(mockRepo.findUserTimeZone).toHaveBeenCalledWith(1);
expect(mockAttempts.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ occurredOn: '2026-09-19' }));
});
it('trusts the caller timezone over a lookup', async () => {
await attemptService.autoUpdate(1, 10, 100, 25, 98, { occurredAt: EVENING_IN_CHICAGO, timeZone: 'America/Chicago' });
expect(mockRepo.findUserTimeZone).not.toHaveBeenCalled();
});
it('uses the reader current day when the caller reports no instant', async () => {
vi.useFakeTimers();
vi.setSystemTime(EVENING_IN_CHICAGO);
try {
await attemptService.autoUpdate(1, 10, 100, 25, 98, { timeZone: 'America/Chicago' });
} finally {
vi.useRealTimers();
}
expect(mockAttempts.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ occurredOn: '2026-09-19' }));
});
it('falls back to now rather than dropping the update when a device sends an unparseable time', async () => {
vi.useFakeTimers();
vi.setSystemTime(EVENING_IN_CHICAGO);
try {
await attemptService.autoUpdate(1, 10, 100, 25, 98, { occurredAt: new Date('not a date'), timeZone: 'America/Chicago' });
} finally {
vi.useRealTimers();
}
expect(mockAttempts.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ occurredOn: '2026-09-19' }));
});
it('marks a book read in the evening as read today, not tomorrow', async () => {
mockRepo.findUserTimeZone.mockResolvedValue('America/Chicago');
vi.useFakeTimers();
vi.setSystemTime(EVENING_IN_CHICAGO);
try {
await attemptService.setManual(1, 10, 'read');
} finally {
vi.useRealTimers();
}
// applyManualStatus(userId, bookId, status, startedOn, endedOn, today, options)
expect(mockAttempts.applyManualStatus.mock.calls[0]?.[5]).toBe('2026-09-19');
});
it('converts an explicitly patched instant on the reader calendar', async () => {
mockRepo.findUserTimeZone.mockResolvedValue('America/Chicago');
await attemptService.updateManual(1, 10, { finishedAt: EVENING_IN_CHICAGO });
expect(mockAttempts.applyManualStatus.mock.calls[0]?.[4]).toBe('2026-09-19');
});
it('resolves the timezone once for a bulk edit rather than once per book', async () => {
mockRepo.findUserTimeZone.mockResolvedValue('America/Chicago');
mockAttempts.applyManualStatus.mockResolvedValue({
status: 'read',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-09-20T01:08:15.815Z',
});
await attemptService.bulkSetManual(1, [10, 11, 12, 13], 'read');
expect(mockAttempts.applyManualStatus).toHaveBeenCalledTimes(4);
expect(mockRepo.findUserTimeZone).toHaveBeenCalledTimes(1);
});
});
@@ -7,6 +7,7 @@ import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from
import { KoboStatusProjectionRepository } from './kobo-status-projection.repository';
import { ReadingAttemptService } from './reading-attempt.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
const DEFAULT_FINISH_THRESHOLD = 98;
const READING_THRESHOLD = 0.25;
@@ -20,7 +21,10 @@ type ManualStatusPatch = {
};
export type AutoReadingActivity = {
occurredOn?: string;
/** The instant the activity happened. The calendar day is derived from it per reader timezone. */
occurredAt?: Date;
/** Saves a lookup when the caller already holds the reader's timezone. Never a default. */
timeZone?: string;
origin?: 'bookorbit' | 'kobo' | 'koreader';
strongRereadEvidence?: boolean;
meaningfulActivity?: boolean;
@@ -37,8 +41,8 @@ export class UserBookStatusService {
@Optional() private readonly attempts?: ReadingAttemptService,
) {}
async setManual(userId: number, bookId: number, status: ReadStatus): Promise<void> {
await this.updateManual(userId, bookId, { status });
async setManual(userId: number, bookId: number, status: ReadStatus, timeZone?: string): Promise<void> {
await this.updateManual(userId, bookId, { status }, undefined, timeZone);
}
async updateManual(
@@ -46,8 +50,9 @@ export class UserBookStatusService {
bookId: number,
patch: ManualStatusPatch,
dateKeys?: { startedOn?: string | null; endedOn?: string | null },
timeZone?: string,
): Promise<UserBookStatus> {
const { state, statusChanged } = await this.applyManualStatus(userId, bookId, patch, dateKeys);
const { state, statusChanged } = await this.applyManualStatus(userId, bookId, patch, dateKeys, timeZone);
if (statusChanged) {
await this.projectToKobo(userId, [bookId], state.status);
}
@@ -59,6 +64,7 @@ export class UserBookStatusService {
bookId: number,
patch: ManualStatusPatch,
dateKeys?: { startedOn?: string | null; endedOn?: string | null },
resolvedTimeZone?: string,
): Promise<{ state: UserBookStatus; statusChanged: boolean }> {
const existing = await this.repo.findOne(userId, bookId);
const now = new Date();
@@ -68,13 +74,14 @@ export class UserBookStatusService {
if (this.attempts) {
const nextStatus = patch.status ?? existing?.status ?? 'unread';
const today = new Date().toISOString().slice(0, 10);
const timeZone = resolvedTimeZone ?? (await this.repo.findUserTimeZone(userId));
const today = toDateKeyInTimeZone(now, timeZone);
const updated = await this.attempts.applyManualStatus(
userId,
bookId,
nextStatus,
hasStartedAt ? (dateKeys?.startedOn ?? patch.startedAt?.toISOString().slice(0, 10) ?? null) : undefined,
hasFinishedAt ? (dateKeys?.endedOn ?? patch.finishedAt?.toISOString().slice(0, 10) ?? null) : undefined,
hasStartedAt ? (dateKeys?.startedOn ?? (patch.startedAt ? toDateKeyInTimeZone(patch.startedAt, timeZone) : null)) : undefined,
hasFinishedAt ? (dateKeys?.endedOn ?? (patch.finishedAt ? toDateKeyInTimeZone(patch.finishedAt, timeZone) : null)) : undefined,
today,
{ statusWasExplicit: hasStatus },
);
@@ -164,9 +171,10 @@ export class UserBookStatusService {
if (this.attempts) {
const batchSize = 50;
const timeZone = await this.repo.findUserTimeZone(userId);
for (let offset = 0; offset < bookIds.length; offset += batchSize) {
const batch = bookIds.slice(offset, offset + batchSize);
const results = await Promise.all(batch.map((bookId) => this.applyManualStatus(userId, bookId, { status })));
const results = await Promise.all(batch.map((bookId) => this.applyManualStatus(userId, bookId, { status }, undefined, timeZone)));
results.forEach((result, index) => {
if (result.statusChanged) recordChange(result.state.status, batch[index]!);
});
@@ -246,10 +254,11 @@ export class UserBookStatusService {
normalizedPercentage >= normalizedFinishThreshold ? 'read' : normalizedPercentage >= readThreshold ? 'reading' : 'unread';
if (this.attempts) {
const timeZone = activity.timeZone ?? (await this.repo.findUserTimeZone(userId));
const updated = await this.attempts.recordActivity({
userId,
bookId,
occurredOn: activity.occurredOn ?? new Date().toISOString().slice(0, 10),
occurredOn: this.dayKeyFor(activity.occurredAt, timeZone),
origin: activity.origin ?? 'bookorbit',
progress: normalizedPercentage,
finishThreshold: normalizedFinishThreshold,
@@ -282,6 +291,15 @@ export class UserBookStatusService {
});
}
/**
* A device can push an unparseable or absent timestamp. Falling back to now keeps the status
* update alive; throwing here would abandon it for a date we never needed to be exact about.
*/
private dayKeyFor(occurredAt: Date | undefined, timeZone: string): string {
const instant = occurredAt && !Number.isNaN(occurredAt.getTime()) ? occurredAt : new Date();
return toDateKeyInTimeZone(instant, timeZone);
}
async findOne(userId: number, bookId: number): Promise<UserBookStatus | null> {
const row = await this.repo.findOne(userId, bookId);
return row ? this.toDto(row) : null;