diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index 197a565e0..3e9b5ee06 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -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 () => { diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 42218e4cd..95ca7d639 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -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( diff --git a/server/src/modules/kobo/services/kobo-reading-state.service.test.ts b/server/src/modules/kobo/services/kobo-reading-state.service.test.ts index b53f8f6ae..da9b958e9 100644 --- a/server/src/modules/kobo/services/kobo-reading-state.service.test.ts +++ b/server/src/modules/kobo/services/kobo-reading-state.service.test.ts @@ -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, diff --git a/server/src/modules/kobo/services/kobo-reading-state.service.ts b/server/src/modules/kobo/services/kobo-reading-state.service.ts index aa457841d..739604d87 100644 --- a/server/src/modules/kobo/services/kobo-reading-state.service.ts +++ b/server/src/modules/kobo/services/kobo-reading-state.service.ts @@ -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 { 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) { diff --git a/server/src/modules/koreader/koreader-plugin.service.test.ts b/server/src/modules/koreader/koreader-plugin.service.test.ts index 872f21995..3a0169951 100644 --- a/server/src/modules/koreader/koreader-plugin.service.test.ts +++ b/server/src/modules/koreader/koreader-plugin.service.test.ts @@ -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 = {}): 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; diff --git a/server/src/modules/koreader/koreader-plugin.service.ts b/server/src/modules/koreader/koreader-plugin.service.ts index 2460ed803..40ebf7f2d 100644 --- a/server/src/modules/koreader/koreader-plugin.service.ts +++ b/server/src/modules/koreader/koreader-plugin.service.ts @@ -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; noteWrites: Map; 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(), noteWrites: new Map(), 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; } diff --git a/server/src/modules/koreader/koreader.service.ts b/server/src/modules/koreader/koreader.service.ts index 15d31ad0a..cb190f13b 100644 --- a/server/src/modules/koreader/koreader.service.ts +++ b/server/src/modules/koreader/koreader.service.ts @@ -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 { 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, { diff --git a/server/src/modules/reading-session/reading-session.service.test.ts b/server/src/modules/reading-session/reading-session.service.test.ts index 6e22c1da6..6d31d458d 100644 --- a/server/src/modules/reading-session/reading-session.service.test.ts +++ b/server/src/modules/reading-session/reading-session.service.test.ts @@ -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'); + }); +}); diff --git a/server/src/modules/reading-session/reading-session.service.ts b/server/src/modules/reading-session/reading-session.service.ts index dc790b7f2..1c3e7ec05 100644 --- a/server/src/modules/reading-session/reading-session.service.ts +++ b/server/src/modules/reading-session/reading-session.service.ts @@ -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, }); } diff --git a/server/src/modules/user-book-status/user-book-status.repository.test.ts b/server/src/modules/user-book-status/user-book-status.repository.test.ts index d156c4206..af24a3f74 100644 --- a/server/src/modules/user-book-status/user-book-status.repository.test.ts +++ b/server/src/modules/user-book-status/user-book-status.repository.test.ts @@ -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 { @@ -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'); + }); +}); diff --git a/server/src/modules/user-book-status/user-book-status.repository.ts b/server/src/modules/user-book-status/user-book-status.repository.ts index 4c95a85c8..830f36e25 100644 --- a/server/src/modules/user-book-status/user-book-status.repository.ts +++ b/server/src/modules/user-book-status/user-book-status.repository.ts @@ -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 { + 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 diff --git a/server/src/modules/user-book-status/user-book-status.service.test.ts b/server/src/modules/user-book-status/user-book-status.service.test.ts index 4d321a289..95b7355f6 100644 --- a/server/src/modules/user-book-status/user-book-status.service.test.ts +++ b/server/src/modules/user-book-status/user-book-status.service.test.ts @@ -32,6 +32,7 @@ const mockRepo = { ) => Promise >(), findSessionBoundariesForBook: vi.fn<(...args: [number, number]) => Promise>(), + findUserTimeZone: vi.fn<(...args: [number]) => Promise>(), }; 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); + }); +}); diff --git a/server/src/modules/user-book-status/user-book-status.service.ts b/server/src/modules/user-book-status/user-book-status.service.ts index 69df42bdf..d73c5d24f 100644 --- a/server/src/modules/user-book-status/user-book-status.service.ts +++ b/server/src/modules/user-book-status/user-book-status.service.ts @@ -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 { - await this.updateManual(userId, bookId, { status }); + async setManual(userId: number, bookId: number, status: ReadStatus, timeZone?: string): Promise { + 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 { - 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 { const row = await this.repo.findOne(userId, bookId); return row ? this.toDto(row) : null;