feat: add book detail with progress updates and delete

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
2026-10-02 14:25:17 -05:00
parent f0efa25f23
commit da6c5c2de5
3 changed files with 234 additions and 0 deletions
+72
View File
@@ -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))
})
})
+160
View File
@@ -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<Book | null>(null)
const [loadError, setLoadError] = useState<{ notFound: boolean; message: string } | null>(null)
const [attempt, setAttempt] = useState(0)
const [page, setPage] = useState('')
const [progressError, setProgressError] = useState<string | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoadError(null)
api<Book>('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 (
<div className={card}>
<h1 className="text-xl font-semibold">Book not found</h1>
<Link to="/books" className="mt-2 inline-block text-indigo-700 underline">
← Back to library
</Link>
</div>
)
}
if (loadError) {
return (
<div role="alert" className="flex items-center gap-3 text-red-700">
<span>{loadError.message}</span>
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</button>
</div>
)
}
if (!book) return <p className="text-slate-500">Loading…</p>
const current = book
async function saveProgress(currentPage: number) {
setProgressError(null)
try {
const updated = await api<Book>('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 (
<article className="space-y-6">
<Link to="/books" className="text-sm text-indigo-700 underline">
← Back to library
</Link>
<header className={card}>
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">{book.title}</h1>
<p className="text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
</div>
<StatusBadge status={book.status} />
</div>
<ProgressBar book={book} />
<form onSubmit={submitProgress} className="mt-4 flex flex-wrap items-end gap-2">
<label>
<span className={labelText}>Current page</span>
<input
type="number"
className={`${inputClass} w-32`}
min={0}
max={book.total_pages}
value={page}
onChange={(e) => setPage(e.target.value)}
required
aria-invalid={progressError ? true : undefined}
aria-describedby={progressError ? 'progress-error' : undefined}
/>
</label>
<button type="submit" className={primaryButton}>
Save progress
</button>
<button
type="button"
className={secondaryButton}
disabled={book.current_page === book.total_pages}
onClick={() => void saveProgress(book.total_pages)}
>
Mark finished
</button>
</form>
{progressError && (
<p id="progress-error" role="alert" className="mt-2 text-sm text-red-700">
{progressError}
</p>
)}
<div className="mt-4 flex gap-2">
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
Edit
</Link>
<button type="button" className={dangerButton} onClick={() => setConfirmingDelete(true)}>
Delete book
</button>
</div>
{confirmingDelete && (
<dialog open aria-labelledby="delete-prompt" className={`relative mt-4 ${card} ring-2 ring-red-200`}>
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
<div className="mt-3 flex gap-2">
<button type="button" className={dangerButton} onClick={() => void deleteBook()}>
Yes, delete
</button>
<button type="button" className={secondaryButton} onClick={() => setConfirmingDelete(false)}>
Cancel
</button>
</div>
{deleteError && (
<p role="alert" className="mt-2 text-sm text-red-700">
{deleteError}
</p>
)}
</dialog>
)}
</header>
</article>
)
}