diff --git a/server/src/modules/user-book-status/reading-attempt.service.test.ts b/server/src/modules/user-book-status/reading-attempt.service.test.ts index 504411711..35bca1437 100644 --- a/server/src/modules/user-book-status/reading-attempt.service.test.ts +++ b/server/src/modules/user-book-status/reading-attempt.service.test.ts @@ -50,6 +50,64 @@ describe('ReadingAttemptService', () => { expect(fake.rows).toHaveLength(1); }); + describe('sessions a device delivers after its finished state closed the read', () => { + const lateActivity = (occurredOn: string, progress = 64, strongRereadEvidence = false) => + service.recordActivity({ + userId: 1, + bookId: 10, + occurredOn, + origin: 'bookorbit', + progress, + finishThreshold: 98, + strongRereadEvidence, + meaningfulActivity: true, + timeZone: 'America/Chicago', + }); + const finished = (startedOn: string | null) => + fake.repo.create({}, { userId: 1, bookId: 10, startedOn, endedOn: '2026-10-17', outcome: 'completed', origin: 'kobo' }); + + it('files a session from within the read into it instead of opening another', async () => { + const read = await finished('2026-10-11'); + + await expect(lateActivity('2026-10-16')).resolves.toBeNull(); + + expect(fake.rows).toHaveLength(1); + expect(fake.repo.attachBackfilledSessions).toHaveBeenCalledWith({}, 1, 10, read.id, 'America/Chicago', '2026-10-11', '2026-10-17'); + }); + + it('does not open a second completed read when the late session is already at the finish', async () => { + await finished('2026-10-11'); + + await expect(lateActivity('2026-10-17', 100)).resolves.toBeNull(); + + expect(fake.rows).toHaveLength(1); + }); + + it('bounds the files it adopts by the late session itself when the read has no start', async () => { + const read = await finished(null); + + await lateActivity('2026-10-16'); + + expect(fake.repo.attachBackfilledSessions).toHaveBeenCalledWith({}, 1, 10, read.id, 'America/Chicago', '2026-10-16', '2026-10-17'); + }); + + it('leaves a session from before the read began unassigned without opening a read', async () => { + await finished('2026-10-11'); + + await expect(lateActivity('2026-10-02')).resolves.toBeNull(); + + expect(fake.rows).toHaveLength(1); + expect(fake.repo.attachBackfilledSessions).not.toHaveBeenCalled(); + }); + + it('still opens a reread from activity after the finish day', async () => { + await finished('2026-10-11'); + + await expect(lateActivity('2026-10-18')).resolves.toMatchObject({ status: 'rereading' }); + expect(fake.rows).toHaveLength(2); + }); + }); + it('opens a reread from a strong Kobo transition', async () => { await fake.repo.create( {}, diff --git a/server/src/modules/user-book-status/reading-attempt.service.ts b/server/src/modules/user-book-status/reading-attempt.service.ts index abe75da72..dd6c38c1b 100644 --- a/server/src/modules/user-book-status/reading-attempt.service.ts +++ b/server/src/modules/user-book-status/reading-attempt.service.ts @@ -276,6 +276,8 @@ export class ReadingAttemptService { finishThreshold: number; strongRereadEvidence: boolean; meaningfulActivity: boolean; + /** The reader's timezone, which files late sessions into the read they belong to. */ + timeZone?: string; }): Promise { let changed = false; const result = await this.repo.transaction(async (tx) => { @@ -284,6 +286,29 @@ export class ReadingAttemptService { const hasCompleted = await this.repo.hasCompleted(tx, input.userId, input.bookId); const isFinished = hasReachedProgressThreshold(input.progress, input.finishThreshold); + // A device that went without syncing delivers the sessions of a read only after its finished state + // has closed the attempt. Activity dated no later than that close is backlog, not a new read: it + // joins the read it falls within, and activity from before that read began joins nothing. + if ( + !active && + !input.strongRereadEvidence && + latest?.outcome === 'completed' && + latest.endedOn !== null && + input.occurredOn <= latest.endedOn + ) { + if (input.timeZone && (latest.startedOn === null || input.occurredOn >= latest.startedOn)) { + await this.repo.attachBackfilledSessions( + tx, + input.userId, + input.bookId, + latest.id, + input.timeZone, + latest.startedOn ?? input.occurredOn, + latest.endedOn, + ); + } + return null; + } if (!active && hasCompleted && !input.strongRereadEvidence && !input.meaningfulActivity) return null; if (!active && isFinished && !hasCompleted) { latest = await this.repo.create(tx, { 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 216030804..15eab9744 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 @@ -273,6 +273,7 @@ export class UserBookStatusService { finishThreshold: normalizedFinishThreshold, strongRereadEvidence: activity.strongRereadEvidence === true, meaningfulActivity: activity.meaningfulActivity === true, + timeZone, }); if (updated && updated.status !== (existing?.status ?? null)) { this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { diff --git a/server/test/helpers/reading-attempt-repository.fake.ts b/server/test/helpers/reading-attempt-repository.fake.ts index b778a272b..9e6976f38 100644 --- a/server/test/helpers/reading-attempt-repository.fake.ts +++ b/server/test/helpers/reading-attempt-repository.fake.ts @@ -44,6 +44,7 @@ export function makeFakeRepo() { findLatest: vi.fn((_tx: object, userId: number, bookId: number) => Promise.resolve([...rows].reverse().find((row) => row.userId === userId && row.bookId === bookId && row.deletedAt === null)), ), + attachBackfilledSessions: vi.fn(() => Promise.resolve()), hasCompleted: vi.fn((_tx: object, userId: number, bookId: number) => Promise.resolve(rows.some((row) => row.userId === userId && row.bookId === bookId && row.outcome === 'completed' && row.deletedAt === null)), ), diff --git a/server/test/reading-attempts.e2e-spec.ts b/server/test/reading-attempts.e2e-spec.ts index 4b000a157..1079c1692 100644 --- a/server/test/reading-attempts.e2e-spec.ts +++ b/server/test/reading-attempts.e2e-spec.ts @@ -346,6 +346,34 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU const book = await createBook(); await patchStatus(book.bookId, { status: 'read', startedAt: '2024-01-01', finishedAt: '2024-01-10' }); const kobo = ctx.app.get(KoboReadingStateService); + const device = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/kobo/devices', + headers: auth(ctx.adminToken), + payload: { name: 'Attempt simulation Kobo' }, + }); + expect(device.statusCode).toBe(201); + const deviceId = (device.json() as { id: number }).id; + // The device holds this copy, so the positions it reports are its reader's, not an unknown copy's. + await ctx.db.insert(schema.koboDeviceDeliveries).values({ + userId: adminUserId, + deviceId, + bookId: book.bookId, + sourceFileId: book.fileId, + metadata: { + artifactId: 'simulated', + sourceRevision: 'simulated', + requestedVariant: null, + actualVariant: 'original', + format: 'epub', + sizeBytes: 1024, + sha256: 'simulated', + fileHash: 'simulated', + outcome: 'original', + }, + options: { hyphenate: false, converterVersion: null }, + state: 'transfer_observed', + }); await kobo.upsertState( adminUserId, book.bookId, @@ -357,7 +385,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU 1, 98, false, - 1, + deviceId, ); await kobo.upsertState( adminUserId, @@ -370,7 +398,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU 1, 98, false, - 1, + deviceId, ); const history = await listAttempts(book.bookId); expect(history.total).toBe(2);