diff --git a/app/components/BookReadingProgress.vue b/app/components/BookReadingProgress.vue new file mode 100644 index 0000000..4334c6e --- /dev/null +++ b/app/components/BookReadingProgress.vue @@ -0,0 +1,85 @@ + + + diff --git a/app/components/BookReadingProgressModal.vue b/app/components/BookReadingProgressModal.vue new file mode 100644 index 0000000..fc798b6 --- /dev/null +++ b/app/components/BookReadingProgressModal.vue @@ -0,0 +1,324 @@ + + + diff --git a/app/pages/library/[id].vue b/app/pages/library/[id].vue index 5fe330e..021eb5a 100644 --- a/app/pages/library/[id].vue +++ b/app/pages/library/[id].vue @@ -7,6 +7,8 @@ const { removeBooks, getLoadedPages, markNeedsSync } = dashboardStore const userBookId = route.params.id as string const isDeleting = ref(false) const isTagModalOpen = ref(false) +const isReadingModalOpen = ref(false) +const isSavingReadingProgress = ref(false) // Fetch book details const { data: book, status, refresh } = await useFetch(`/api/books/${userBookId}`, { @@ -109,6 +111,7 @@ async function onTagsSaved() { // Per-field request sequencing tokens to prevent stale rollbacks let ratingRequestId = 0 let noteRequestId = 0 +let readingRequestId = 0 let lastConfirmedRating: number | null = book.value?.rating ?? null let lastConfirmedNote: string | null = book.value?.note ?? null @@ -178,6 +181,65 @@ async function saveNote(note: string | null) { }) } } + +async function saveReadingProgress(progress: { + status: ReadingStatus + currentPage: number | null + progressPercent: number | null + startedAt: string | null + finishedAt: string | null +}) { + const currentRequestId = ++readingRequestId + isSavingReadingProgress.value = true + + if (book.value) { + book.value = { ...book.value, readingProgress: progress } + } + + try { + const result = await $fetch<{ readingProgress: ReadingProgress }>(`/api/books/${userBookId}/reading`, { + method: 'PUT', + body: progress + }) + + if (currentRequestId === readingRequestId) { + if (book.value) { + book.value = { ...book.value, readingProgress: result.readingProgress } + } + toast.add({ + title: 'Progress saved', + description: 'Your reading progress has been updated.', + color: 'success' + }) + isReadingModalOpen.value = false + } + } catch (err: unknown) { + if (currentRequestId !== readingRequestId) { + return + } + + try { + const details = await $fetch(`/api/books/${userBookId}`) + if (book.value) { + book.value = { ...book.value, readingProgress: details.readingProgress } + } + } catch { + await refresh() + } + + const message = (err as { data?: { message?: string } })?.data?.message + ?? (err instanceof Error ? err.message : 'An error occurred') + toast.add({ + title: 'Failed to save progress', + description: message, + color: 'error' + }) + } finally { + if (currentRequestId === readingRequestId) { + isSavingReadingProgress.value = false + } + } +} diff --git a/server/api/books/[id]/reading.put.ts b/server/api/books/[id]/reading.put.ts new file mode 100644 index 0000000..1e8ac7a --- /dev/null +++ b/server/api/books/[id]/reading.put.ts @@ -0,0 +1,26 @@ +import { Effect } from 'effect' +import { bookReadingProgressSchema } from '../../../../shared/utils/schemas' + +export default effectHandler((event, user) => + Effect.gen(function* () { + const userBookId = getRouterParam(event, 'id') + + if (!userBookId) { + return yield* Effect.fail( + createError({ + statusCode: 400, + message: 'Book ID is required' + }) + ) + } + + const body = yield* Effect.tryPromise({ + try: () => readValidatedBody(event, bookReadingProgressSchema.parse), + catch: () => createError({ statusCode: 400, message: 'Invalid reading progress' }) + }) + + const readingProgress = yield* updateReadingProgress(userBookId, user.id, body) + + return { success: true, readingProgress } + }) +) diff --git a/server/db/migrations/sqlite/0009_add_reading_progress.sql b/server/db/migrations/sqlite/0009_add_reading_progress.sql new file mode 100644 index 0000000..a5942f8 --- /dev/null +++ b/server/db/migrations/sqlite/0009_add_reading_progress.sql @@ -0,0 +1,50 @@ +-- Track per-user reading state and progress for each owned book. + +PRAGMA foreign_keys=OFF; + +CREATE TABLE user_books_new ( + id text PRIMARY KEY NOT NULL, + user_id text NOT NULL REFERENCES user(id) ON DELETE CASCADE, + book_id text NOT NULL REFERENCES books(id) ON DELETE CASCADE, + rating integer CONSTRAINT user_books_rating_check CHECK (rating IS NULL OR rating BETWEEN 1 AND 5), + note text, + reading_status text DEFAULT 'unread' NOT NULL CONSTRAINT user_books_reading_status_check CHECK (reading_status IN ('unread', 'reading', 'read')), + current_page integer CONSTRAINT user_books_current_page_check CHECK (current_page IS NULL OR current_page >= 0), + progress_percent integer CONSTRAINT user_books_progress_percent_check CHECK (progress_percent IS NULL OR progress_percent BETWEEN 0 AND 100), + started_at integer, + finished_at integer, + added_at integer NOT NULL +); + +INSERT INTO user_books_new ( + id, + user_id, + book_id, + rating, + note, + reading_status, + current_page, + progress_percent, + started_at, + finished_at, + added_at +) +SELECT + id, + user_id, + book_id, + rating, + note, + 'unread', + NULL, + NULL, + NULL, + NULL, + added_at +FROM user_books; + +DROP TABLE user_books; + +ALTER TABLE user_books_new RENAME TO user_books; + +PRAGMA foreign_keys=ON; diff --git a/server/db/migrations/sqlite/meta/_journal.json b/server/db/migrations/sqlite/meta/_journal.json index d101743..6e3bfb1 100644 --- a/server/db/migrations/sqlite/meta/_journal.json +++ b/server/db/migrations/sqlite/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1776258000000, "tag": "0008_normalized_authors", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1778763600000, + "tag": "0009_add_reading_progress", + "breakpoints": true } ] } diff --git a/server/db/schema/domain.ts b/server/db/schema/domain.ts index 597935b..6ff36df 100644 --- a/server/db/schema/domain.ts +++ b/server/db/schema/domain.ts @@ -55,9 +55,17 @@ export const userBooks = sqliteTable('user_books', { bookId: text('book_id').notNull().references(() => books.id, { onDelete: 'cascade' }), rating: integer('rating'), // 1–5 star rating, null = unrated note: text('note'), // Private user note + readingStatus: text('reading_status', { enum: ['unread', 'reading', 'read'] }).notNull().default('unread'), + currentPage: integer('current_page'), + progressPercent: integer('progress_percent'), + startedAt: integer('started_at', { mode: 'timestamp' }), + finishedAt: integer('finished_at', { mode: 'timestamp' }), addedAt: integer('added_at', { mode: 'timestamp' }).notNull() }, table => [ - check('user_books_rating_check', sql`${table.rating} IS NULL OR ${table.rating} BETWEEN 1 AND 5`) + check('user_books_rating_check', sql`${table.rating} IS NULL OR ${table.rating} BETWEEN 1 AND 5`), + check('user_books_reading_status_check', sql`${table.readingStatus} IN ('unread', 'reading', 'read')`), + check('user_books_current_page_check', sql`${table.currentPage} IS NULL OR ${table.currentPage} >= 0`), + check('user_books_progress_percent_check', sql`${table.progressPercent} IS NULL OR ${table.progressPercent} BETWEEN 0 AND 100`) ]) // Global tag dictionary with case-insensitive uniqueness enforced in migration diff --git a/server/repositories/book.repository.ts b/server/repositories/book.repository.ts index e6c6d5e..ac96a23 100644 --- a/server/repositories/book.repository.ts +++ b/server/repositories/book.repository.ts @@ -155,6 +155,12 @@ export interface BookRepositoryInterface { userId: string, note: string | null ) => Effect.Effect + + updateReadingProgress: ( + userBookId: string, + userId: string, + progress: ReadingProgress + ) => Effect.Effect } // Service tag @@ -962,6 +968,13 @@ export const BookRepositoryLive = Layer.effect( description: bookData.description ?? null, rating: row.user_books.rating ?? null, note: row.user_books.note ?? null, + readingProgress: { + status: row.user_books.readingStatus, + currentPage: row.user_books.currentPage ?? null, + progressPercent: row.user_books.progressPercent ?? null, + startedAt: row.user_books.startedAt ?? null, + finishedAt: row.user_books.finishedAt ?? null + }, userTags: userTagRows, suggestedTags: suggestedRows, publishDate: bookData.publishDate ?? null, @@ -1228,6 +1241,31 @@ export const BookRepositoryLive = Layer.effect( }) }) + if (result.length === 0) { + return yield* Effect.fail(new BookNotFoundError({ bookId: userBookId })) + } + }), + + updateReadingProgress: (userBookId, userId, progress) => + Effect.gen(function* () { + const result = yield* Effect.tryPromise({ + try: () => dbService.db + .update(userBooks) + .set({ + readingStatus: progress.status, + currentPage: progress.currentPage, + progressPercent: progress.progressPercent, + startedAt: progress.startedAt ? new Date(progress.startedAt) : null, + finishedAt: progress.finishedAt ? new Date(progress.finishedAt) : null + }) + .where(and(eq(userBooks.id, userBookId), eq(userBooks.userId, userId))) + .returning({ id: userBooks.id }), + catch: error => new DatabaseError({ + message: `Failed to update reading progress: ${error}`, + operation: 'updateReadingProgress' + }) + }) + if (result.length === 0) { return yield* Effect.fail(new BookNotFoundError({ bookId: userBookId })) } diff --git a/server/services/book.service.ts b/server/services/book.service.ts index d41a6bb..b6f4966 100644 --- a/server/services/book.service.ts +++ b/server/services/book.service.ts @@ -1,5 +1,6 @@ -import { Context, Effect, Layer, Either } from 'effect' +import { Context, Effect, Layer, Either, Data } from 'effect' import type { HttpClient } from '@effect/platform' +import { normalizeReadingProgress } from '../../shared/utils/reading-progress' interface UserBookViewModel { id: string @@ -15,6 +16,10 @@ interface UserBookViewModel { addedAt: Date } +export class InvalidReadingProgressError extends Data.TaggedError('InvalidReadingProgressError')<{ + message: string +}> { } + export const toLibraryBook = (userBook: UserBookViewModel): LibraryBook => ({ id: userBook.id, bookId: userBook.bookId, @@ -111,6 +116,12 @@ export interface BookServiceInterface { userId: string, note: string | null ) => Effect.Effect + + updateReadingProgress: ( + userBookId: string, + userId: string, + progress: BookReadingProgressSchema + ) => Effect.Effect } // ===== Service Tag ===== @@ -124,6 +135,17 @@ export const BookServiceLive = Layer.effect( Effect.gen(function* () { const bookRepo = yield* BookRepository + const normalizeProgress = ( + details: BookDetails, + input: BookReadingProgressSchema + ): Effect.Effect => + Effect.try({ + try: () => normalizeReadingProgress(details, input), + catch: error => new InvalidReadingProgressError({ + message: error instanceof Error ? error.message : 'Invalid reading progress' + }) + }) + return { getUserLibrary: (userId, pagination) => Effect.gen(function* () { @@ -262,7 +284,15 @@ export const BookServiceLive = Layer.effect( bookRepo.updateRating(userBookId, userId, rating), updateNote: (userBookId, userId, note) => - bookRepo.updateNote(userBookId, userId, note) + bookRepo.updateNote(userBookId, userId, note), + + updateReadingProgress: (userBookId, userId, progress) => + Effect.gen(function* () { + const details = yield* bookRepo.getUserBookWithDetails(userBookId, userId) + const normalized = yield* normalizeProgress(details, progress) + yield* bookRepo.updateReadingProgress(userBookId, userId, normalized) + return normalized + }) } }) ) @@ -313,3 +343,10 @@ export const updateRating = (userBookId: string, userId: string, rating: number export const updateNote = (userBookId: string, userId: string, note: string | null) => Effect.flatMap(BookService, service => service.updateNote(userBookId, userId, note)) + +export const updateReadingProgress = ( + userBookId: string, + userId: string, + progress: BookReadingProgressSchema +) => + Effect.flatMap(BookService, service => service.updateReadingProgress(userBookId, userId, progress)) diff --git a/server/utils/effect.ts b/server/utils/effect.ts index d942422..d278b10 100644 --- a/server/utils/effect.ts +++ b/server/utils/effect.ts @@ -61,6 +61,7 @@ const errorStatusCodes: Record = { OpenLibraryApiError: 502, BookCreateError: 500, InvalidTagError: 400, + InvalidReadingProgressError: 400, DatabaseError: 500, StorageError: 500 } diff --git a/shared/types/book.ts b/shared/types/book.ts index 95609d7..80c84e6 100644 --- a/shared/types/book.ts +++ b/shared/types/book.ts @@ -20,6 +20,16 @@ export interface BookTag { name: string } +export type ReadingStatus = 'unread' | 'reading' | 'read' + +export interface ReadingProgress { + status: ReadingStatus + currentPage: number | null + progressPercent: number | null + startedAt: Date | string | null + finishedAt: Date | string | null +} + export interface BookLookupResult { found: boolean isbn: string @@ -52,6 +62,7 @@ export interface BookDetails { description: string | null rating: number | null note: string | null + readingProgress: ReadingProgress userTags: BookTag[] suggestedTags: BookTag[] publishDate: string | null diff --git a/shared/utils/reading-progress.ts b/shared/utils/reading-progress.ts new file mode 100644 index 0000000..8a6b8b2 --- /dev/null +++ b/shared/utils/reading-progress.ts @@ -0,0 +1,89 @@ +import type { ReadingProgress, ReadingStatus } from '../types/book' + +interface ReadingProgressInput { + status?: ReadingStatus + currentPage?: number | null + progressPercent?: number | null + startedAt?: Date | string | null + finishedAt?: Date | string | null +} + +interface ReadingProgressDetails { + numberOfPages: number | null + readingProgress: ReadingProgress +} + +export function normalizeReadingProgress( + details: ReadingProgressDetails, + input: ReadingProgressInput, + now = new Date() +): ReadingProgress { + const totalPages = details.numberOfPages + const existing = details.readingProgress + + const inputCurrentPage = input.currentPage !== undefined ? input.currentPage : existing.currentPage + const inputPercent = input.progressPercent !== undefined ? input.progressPercent : existing.progressPercent + let currentPage = inputCurrentPage + let progressPercent = inputPercent + let status = input.status ?? existing.status + let startedAt = input.startedAt !== undefined ? input.startedAt : existing.startedAt + let finishedAt = input.finishedAt !== undefined ? input.finishedAt : existing.finishedAt + const hasExplicitCurrentPage = input.currentPage !== undefined && input.currentPage !== null + const hasExplicitPercent = input.progressPercent !== undefined && input.progressPercent !== null + + if (currentPage !== null && totalPages !== null && currentPage > totalPages) { + throw new Error(`Current page cannot exceed ${totalPages}`) + } + + if (hasExplicitPercent && progressPercent !== null) { + progressPercent = Math.min(100, Math.max(0, Math.round(progressPercent))) + } else if (currentPage !== null && totalPages !== null && totalPages > 0) { + progressPercent = Math.min(100, Math.round((currentPage / totalPages) * 100)) + } + + if (currentPage !== null && currentPage > 0 && status === 'unread') { + status = 'reading' + } + + if (progressPercent !== null && progressPercent > 0 && status === 'unread') { + status = 'reading' + } + + if ( + status === 'read' + && ( + (hasExplicitPercent && progressPercent !== null && progressPercent < 100) + || (hasExplicitCurrentPage && totalPages !== null && totalPages > 0 && currentPage !== null && currentPage < totalPages) + ) + ) { + status = 'reading' + } + + if ( + progressPercent === 100 + || (currentPage !== null && totalPages !== null && totalPages > 0 && currentPage >= totalPages) + || status === 'read' + ) { + status = 'read' + progressPercent = 100 + currentPage = totalPages ?? currentPage + startedAt = startedAt ?? now + finishedAt = finishedAt ?? now + } else if (status === 'reading') { + startedAt = startedAt ?? now + finishedAt = null + } else if (status === 'unread') { + currentPage = null + progressPercent = null + startedAt = null + finishedAt = null + } + + return { + status, + currentPage, + progressPercent, + startedAt, + finishedAt + } +} diff --git a/shared/utils/schemas.ts b/shared/utils/schemas.ts index 3b6d9f9..845bd3b 100644 --- a/shared/utils/schemas.ts +++ b/shared/utils/schemas.ts @@ -164,3 +164,38 @@ export const bookNoteSchema = z.object({ }) export type BookNoteSchema = z.infer + +const nullableDateSchema = z.preprocess( + (val) => { + if (val === '' || val === undefined) return val + if (val === null || val instanceof Date) return val + if (typeof val === 'string') { + const date = new Date(val) + return Number.isNaN(date.getTime()) ? val : date + } + return val + }, + z.date({ error: 'Date must be valid' }).nullable().optional() +) + +export const bookReadingProgressSchema = z.object({ + status: z.enum(['unread', 'reading', 'read'], { error: 'Reading status is invalid' }).optional(), + currentPage: z.number({ error: 'Current page must be a number' }) + .int({ error: 'Current page must be a whole number' }) + .min(0, { error: 'Current page cannot be negative' }) + .nullable() + .optional(), + progressPercent: z.number({ error: 'Progress must be a number' }) + .int({ error: 'Progress must be a whole number' }) + .min(0, { error: 'Progress cannot be negative' }) + .max(100, { error: 'Progress cannot be greater than 100' }) + .nullable() + .optional(), + startedAt: nullableDateSchema, + finishedAt: nullableDateSchema +}).refine( + value => Object.values(value).some(item => item !== undefined), + { error: 'At least one reading progress field is required' } +) + +export type BookReadingProgressSchema = z.infer diff --git a/test/unit/reading-progress.test.ts b/test/unit/reading-progress.test.ts new file mode 100644 index 0000000..6216aa0 --- /dev/null +++ b/test/unit/reading-progress.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { normalizeReadingProgress } from '../../shared/utils/reading-progress' +import type { ReadingProgress } from '../../shared/types/book' + +const now = new Date('2026-05-14T12:00:00.000Z') + +const readProgress: ReadingProgress = { + status: 'read', + currentPage: 200, + progressPercent: 100, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: '2026-05-10T00:00:00.000Z' +} + +describe('normalizeReadingProgress', () => { + it('moves a completed book back to reading when page progress is lowered', () => { + expect(normalizeReadingProgress( + { + numberOfPages: 200, + readingProgress: readProgress + }, + { + status: 'read', + currentPage: 120, + progressPercent: null + }, + now + )).toEqual({ + status: 'reading', + currentPage: 120, + progressPercent: 60, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: null + }) + }) + + it('moves a completed book back to reading when percent progress is lowered', () => { + expect(normalizeReadingProgress( + { + numberOfPages: 200, + readingProgress: readProgress + }, + { + status: 'read', + currentPage: null, + progressPercent: 50 + }, + now + )).toEqual({ + status: 'reading', + currentPage: null, + progressPercent: 50, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: null + }) + }) + + it('keeps a book completed when page progress reaches the total', () => { + expect(normalizeReadingProgress( + { + numberOfPages: 200, + readingProgress: { + status: 'reading', + currentPage: 120, + progressPercent: 60, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: null + } + }, + { + status: 'reading', + currentPage: 200, + progressPercent: null + }, + now + )).toEqual({ + status: 'read', + currentPage: 200, + progressPercent: 100, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: now + }) + }) + + it('preserves explicit percent-only updates when current page already exists', () => { + const readingProgress: ReadingProgress = { + status: 'reading', + currentPage: 120, + progressPercent: 60, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: null + } + + expect(normalizeReadingProgress( + { + numberOfPages: 200, + readingProgress + }, + { + progressPercent: 75 + }, + now + )).toEqual({ + status: 'reading', + currentPage: 120, + progressPercent: 75, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: null + }) + }) +}) diff --git a/test/unit/server/api/_helpers/api-route.ts b/test/unit/server/api/_helpers/api-route.ts index 06a8073..d673bc8 100644 --- a/test/unit/server/api/_helpers/api-route.ts +++ b/test/unit/server/api/_helpers/api-route.ts @@ -92,6 +92,7 @@ interface ApiRouteTestGlobals { getBookDetails: (...args: unknown[]) => unknown updateNote: (...args: unknown[]) => unknown updateRating: (...args: unknown[]) => unknown + updateReadingProgress: (...args: unknown[]) => unknown addUserTag: (...args: unknown[]) => unknown batchUpdateTags: (...args: unknown[]) => unknown deleteTag: (...args: unknown[]) => unknown @@ -120,6 +121,7 @@ export const serviceMocks = { getBookDetails: vi.fn(), updateNote: vi.fn(), updateRating: vi.fn(), + updateReadingProgress: vi.fn(), addUserTag: vi.fn(), batchUpdateTags: vi.fn(), deleteTag: vi.fn(), @@ -150,6 +152,7 @@ const originalGlobals = { getBookDetails: testGlobal.getBookDetails, updateNote: testGlobal.updateNote, updateRating: testGlobal.updateRating, + updateReadingProgress: testGlobal.updateReadingProgress, addUserTag: testGlobal.addUserTag, batchUpdateTags: testGlobal.batchUpdateTags, deleteTag: testGlobal.deleteTag, @@ -218,6 +221,7 @@ export async function setupApiRouteTest() { testGlobal.getBookDetails = (...args: unknown[]) => serviceMocks.getBookDetails(...args) testGlobal.updateNote = (...args: unknown[]) => serviceMocks.updateNote(...args) testGlobal.updateRating = (...args: unknown[]) => serviceMocks.updateRating(...args) + testGlobal.updateReadingProgress = (...args: unknown[]) => serviceMocks.updateReadingProgress(...args) testGlobal.addUserTag = (...args: unknown[]) => serviceMocks.addUserTag(...args) testGlobal.batchUpdateTags = (...args: unknown[]) => serviceMocks.batchUpdateTags(...args) testGlobal.deleteTag = (...args: unknown[]) => serviceMocks.deleteTag(...args) diff --git a/test/unit/server/api/books/[id]/index.get.test.ts b/test/unit/server/api/books/[id]/index.get.test.ts index 3082419..e828718 100644 --- a/test/unit/server/api/books/[id]/index.get.test.ts +++ b/test/unit/server/api/books/[id]/index.get.test.ts @@ -21,7 +21,17 @@ describe('server/api/books/[id]/index.get', () => { it('gets one book by id', async () => { mockLoggedInUser() - const book = { id: 'ub-1', title: 'A Book' } + const book = { + id: 'ub-1', + title: 'A Book', + readingProgress: { + status: 'reading', + currentPage: 50, + progressPercent: 25, + startedAt: '2026-05-01T00:00:00.000Z', + finishedAt: null + } + } serviceMocks.getBookDetails.mockReturnValueOnce(Effect.succeed(book)) const handler = await importRoute(route) diff --git a/test/unit/server/api/books/[id]/reading.put.test.ts b/test/unit/server/api/books/[id]/reading.put.test.ts new file mode 100644 index 0000000..ccf1df4 --- /dev/null +++ b/test/unit/server/api/books/[id]/reading.put.test.ts @@ -0,0 +1,111 @@ +import { Effect } from 'effect' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + cleanupApiRouteTest, + importRoute, + itRequiresAuth, + makeEvent, + mockLoggedInUser, + routePath, + serviceMocks, + setupApiRouteTest +} from '../../_helpers/api-route' + +const route = routePath('books/[id]/reading.put') + +describe('server/api/books/[id]/reading.put', () => { + beforeEach(setupApiRouteTest) + afterEach(cleanupApiRouteTest) + + itRequiresAuth(route, { params: { id: 'ub-1' }, body: { status: 'reading' } }) + + it('updates reading progress', async () => { + mockLoggedInUser() + const readingProgress = { + status: 'reading', + currentPage: 50, + progressPercent: 25, + startedAt: new Date('2026-05-01T00:00:00.000Z'), + finishedAt: null + } + serviceMocks.updateReadingProgress.mockReturnValueOnce(Effect.succeed(readingProgress)) + const handler = await importRoute(route) + + await expect(handler(makeEvent({ + params: { id: 'ub-1' }, + body: { status: 'reading', currentPage: 50 } + }))).resolves.toEqual({ success: true, readingProgress }) + expect(serviceMocks.updateReadingProgress).toHaveBeenCalledWith('ub-1', 'user-1', { + status: 'reading', + currentPage: 50 + }) + }) + + it('accepts percent updates while clearing page progress', async () => { + mockLoggedInUser() + const readingProgress = { + status: 'reading', + currentPage: null, + progressPercent: 50, + startedAt: new Date('2026-05-02T00:00:00.000Z'), + finishedAt: null + } + serviceMocks.updateReadingProgress.mockReturnValueOnce(Effect.succeed(readingProgress)) + const handler = await importRoute(route) + + await expect(handler(makeEvent({ + params: { id: 'ub-1' }, + body: { status: 'reading', currentPage: null, progressPercent: 50, startedAt: '2026-05-02' } + }))).resolves.toEqual({ success: true, readingProgress }) + expect(serviceMocks.updateReadingProgress).toHaveBeenCalledWith('ub-1', 'user-1', { + status: 'reading', + currentPage: null, + progressPercent: 50, + startedAt: new Date('2026-05-02T00:00:00.000Z') + }) + }) + + it('accepts page updates while clearing percent progress', async () => { + mockLoggedInUser() + const readingProgress = { + status: 'reading', + currentPage: 120, + progressPercent: 60, + startedAt: new Date('2026-05-03T00:00:00.000Z'), + finishedAt: null + } + serviceMocks.updateReadingProgress.mockReturnValueOnce(Effect.succeed(readingProgress)) + const handler = await importRoute(route) + + await expect(handler(makeEvent({ + params: { id: 'ub-1' }, + body: { status: 'reading', currentPage: 120, progressPercent: null, startedAt: '2026-05-03' } + }))).resolves.toEqual({ success: true, readingProgress }) + expect(serviceMocks.updateReadingProgress).toHaveBeenCalledWith('ub-1', 'user-1', { + status: 'reading', + currentPage: 120, + progressPercent: null, + startedAt: new Date('2026-05-03T00:00:00.000Z') + }) + }) + + it('rejects missing book ids', async () => { + mockLoggedInUser() + const handler = await importRoute(route) + + await expect(handler(makeEvent({ body: { status: 'reading' } }))).rejects.toMatchObject({ + statusCode: 400, + message: 'Book ID is required' + }) + }) + + it('rejects invalid reading progress', async () => { + mockLoggedInUser() + const handler = await importRoute(route) + + await expect(handler(makeEvent({ params: { id: 'ub-1' }, body: { progressPercent: 110 } }))).rejects.toMatchObject({ + statusCode: 400, + message: 'Invalid reading progress' + }) + }) +})