From da6c5c2de501bcf95109d86c5d37b86694bbd10a Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 14:25:17 -0500 Subject: [PATCH] feat: add book detail with progress updates and delete Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X --- src/App.tsx | 2 + src/books/BookDetail.test.tsx | 72 +++++++++++++++ src/books/BookDetail.tsx | 160 ++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/books/BookDetail.test.tsx create mode 100644 src/books/BookDetail.tsx diff --git a/src/App.tsx b/src/App.tsx index 3c91ae6..f982dbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AuthProvider, useAuth } from './auth/AuthContext' import { LoginForm } from './auth/LoginForm' import { RequireAuth } from './auth/RequireAuth' import { BookList } from './books/BookList' +import { BookDetail } from './books/BookDetail' import { secondaryButton } from './ui' export function App() { @@ -24,6 +25,7 @@ export function AppRoutes() { }> }> } /> + } /> } /> diff --git a/src/books/BookDetail.test.tsx b/src/books/BookDetail.test.tsx new file mode 100644 index 0000000..a4c63db --- /dev/null +++ b/src/books/BookDetail.test.tsx @@ -0,0 +1,72 @@ +import { screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Book } from '../api' +import { dune, renderApp, signedIn } from '../test/helpers' + +function withProgress(current_page: number): Book { + const status = current_page === 0 ? 'not_started' : current_page === dune.total_pages ? 'finished' : 'reading' + return { ...dune, current_page, status } +} + +describe('BookDetail', () => { + it('updates progress and reflects it in the bar', async () => { + const calls = signedIn({ + 'GET /api/books/7': () => [200, dune], + 'PATCH /api/books/7': ({ body }) => [200, withProgress((body as { current_page: number }).current_page)], + }) + const user = renderApp('/books/7') + expect(await screen.findByText('142 / 380 (37%)')).toBeInTheDocument() + + const input = screen.getByLabelText('Current page') + await user.clear(input) + await user.type(input, '200') + await user.click(screen.getByRole('button', { name: 'Save progress' })) + + expect(await screen.findByText('200 / 380 (53%)')).toBeInTheDocument() + expect(calls).toContainEqual({ method: 'PATCH', path: '/api/books/7', body: { current_page: 200 } }) + }) + + it('marks a book finished', async () => { + signedIn({ + 'GET /api/books/7': () => [200, dune], + 'PATCH /api/books/7': () => [200, withProgress(380)], + }) + const user = renderApp('/books/7') + await user.click(await screen.findByRole('button', { name: 'Mark finished' })) + expect(await screen.findByText('380 / 380 (100%)')).toBeInTheDocument() + expect(screen.getByText('Finished')).toBeInTheDocument() + }) + + it('shows the server message when a progress update is rejected', async () => { + signedIn({ + 'GET /api/books/7': () => [200, dune], + 'PATCH /api/books/7': () => [400, { error: 'current_page must be between 0 and 100000', field: 'current_page' }], + }) + const user = renderApp('/books/7') + await user.click(await screen.findByRole('button', { name: 'Save progress' })) + // findByText, not findByRole('alert'): once Task 11 mounts the journal, the page may show more than one alert. + expect(await screen.findByText('current_page must be between 0 and 100000')).toHaveAttribute('role', 'alert') + expect(screen.getByLabelText('Current page')).toHaveAttribute('aria-invalid', 'true') + }) + + it('shows not found for a missing or foreign book', async () => { + signedIn({ 'GET /api/books/999': () => [404, { error: 'Book not found' }] }) + renderApp('/books/999') + expect(await screen.findByRole('heading', { name: 'Book not found' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /back to library/i })).toHaveAttribute('href', '/books') + }) + + it('deletes after confirmation and returns to the list', async () => { + const calls = signedIn({ + 'GET /api/books/7': () => [200, dune], + 'DELETE /api/books/7': () => [204], + 'GET /api/books': () => [200, []], + }) + const user = renderApp('/books/7') + await user.click(await screen.findByRole('button', { name: 'Delete book' })) + expect(screen.getByRole('dialog')).toHaveTextContent('Delete “Dune”?') + await user.click(screen.getByRole('button', { name: 'Yes, delete' })) + expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument() + await waitFor(() => expect(calls.some((c) => c.method === 'DELETE' && c.path === '/api/books/7')).toBe(true)) + }) +}) diff --git a/src/books/BookDetail.tsx b/src/books/BookDetail.tsx new file mode 100644 index 0000000..8875c2a --- /dev/null +++ b/src/books/BookDetail.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Link, useNavigate, useParams } from 'react-router' +import { api, ApiError, errorMessage, type Book } from '../api' +import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui' +import { ProgressBar, StatusBadge } from './Progress' + +export function BookDetail() { + const { id } = useParams() + const navigate = useNavigate() + const [book, setBook] = useState(null) + const [loadError, setLoadError] = useState<{ notFound: boolean; message: string } | null>(null) + const [attempt, setAttempt] = useState(0) + const [page, setPage] = useState('') + const [progressError, setProgressError] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [deleteError, setDeleteError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoadError(null) + api('GET', `/books/${id}`).then( + (found) => { + if (cancelled) return + setBook(found) + setPage(String(found.current_page)) + }, + (err) => { + if (!cancelled) setLoadError({ notFound: err instanceof ApiError && err.status === 404, message: errorMessage(err) }) + }, + ) + return () => { + cancelled = true + } + }, [id, attempt]) + + if (loadError?.notFound) { + return ( +
+

Book not found

+ + ← Back to library + +
+ ) + } + if (loadError) { + return ( +
+ {loadError.message} + +
+ ) + } + if (!book) return

Loading…

+ const current = book + + async function saveProgress(currentPage: number) { + setProgressError(null) + try { + const updated = await api('PATCH', `/books/${current.id}`, { current_page: currentPage }) + setBook(updated) + setPage(String(updated.current_page)) + } catch (err) { + setProgressError(errorMessage(err)) + } + } + + async function deleteBook() { + setDeleteError(null) + try { + await api('DELETE', `/books/${current.id}`) + navigate('/books', { replace: true }) + } catch (err) { + setDeleteError(errorMessage(err)) + } + } + + function submitProgress(event: FormEvent) { + event.preventDefault() + void saveProgress(Number(page)) + } + + return ( +
+ + ← Back to library + +
+
+
+

{book.title}

+

{`${book.author} · ${book.genre.name}`}

+
+ +
+ +
+ + + +
+ {progressError && ( + + )} +
+ + Edit + + +
+ {confirmingDelete && ( + +

{`Delete “${book.title}”? This also deletes its reading journal.`}

+
+ + +
+ {deleteError && ( +

+ {deleteError} +

+ )} +
+ )} +
+
+ ) +}