feat: add sign-in flow, routing shell, and searchable book list

Pin @testing-library/jest-dom to ~6.9.1 (6.10.0 is npm-deprecated).

Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X
This commit is contained in:
2026-10-02 14:23:42 -05:00
parent e1096da1a7
commit f0efa25f23
14 changed files with 625 additions and 11 deletions
+58
View File
@@ -0,0 +1,58 @@
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dune, renderApp, signedIn } from '../test/helpers'
describe('BookList', () => {
it('shows each book with author, genre, status, and progress', async () => {
signedIn({ 'GET /api/books': () => [200, [dune]] })
renderApp('/books')
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument()
expect(screen.getByText('Reading')).toBeInTheDocument()
expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7')
})
it('distinguishes an empty library from no matches', async () => {
signedIn({ 'GET /api/books': () => [200, []] })
const user = renderApp('/books')
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
await user.type(screen.getByLabelText('Search title or author'), 'zzz')
expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument()
})
it('debounces search into the URL and the request', async () => {
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
const user = renderApp('/books')
await screen.findByRole('heading', { name: 'Dune' })
await user.type(screen.getByLabelText('Search title or author'), 'herbert')
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert'))
expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert'])
})
it('filters by genre', async () => {
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
const user = renderApp('/books')
await screen.findByRole('option', { name: 'Science Fiction' })
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5'))
})
it('restores search and genre from the URL', async () => {
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
renderApp('/books?q=dune&genre=5')
await screen.findByRole('option', { name: 'Science Fiction' })
expect(screen.getByLabelText('Search title or author')).toHaveValue('dune')
expect(screen.getByLabelText('Genre')).toHaveValue('5')
expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5')
})
it('offers retry when loading fails', async () => {
let attempts = 0
signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) })
const user = renderApp('/books')
expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error')
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
})
})
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { api, errorMessage, type Book } from '../api'
import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
import { ProgressBar, StatusBadge } from './Progress'
import { useGenres } from './useGenres'
const SEARCH_DEBOUNCE_MS = 250
export function BookList() {
const [params, setParams] = useSearchParams()
const q = params.get('q') ?? ''
const genre = params.get('genre') ?? ''
const [search, setSearch] = useState(q)
const [books, setBooks] = useState<Book[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const { genres, error: genresError } = useGenres()
function updateParam(key: string, value: string) {
setParams(
(prev) => {
const next = new URLSearchParams(prev)
if (value) next.set(key, value)
else next.delete(key)
return next
},
{ replace: true },
)
}
// Keep the input in sync when the URL changes from outside (back/forward navigation).
useEffect(() => setSearch(q), [q])
useEffect(() => {
if (search === q) return
const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [search, q])
useEffect(() => {
let cancelled = false
const query = new URLSearchParams()
if (q.trim()) query.set('q', q.trim())
if (genre) query.set('genre_id', genre)
api<Book[]>('GET', `/books?${query}`).then(
(found) => {
if (cancelled) return
setBooks(found)
setError(null)
},
(err) => {
if (!cancelled) setError(errorMessage(err))
},
)
return () => {
cancelled = true
}
}, [q, genre, attempt])
return (
<section>
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="min-w-48 grow">
<span className={labelText}>Search title or author</span>
<input type="search" className={inputClass} value={search} onChange={(e) => setSearch(e.target.value)} />
</label>
<label>
<span className={labelText}>Genre</span>
<select className={inputClass} value={genre} onChange={(e) => updateParam('genre', e.target.value)}>
<option value="">All genres</option>
{genres.map((g) => (
<option key={g.id} value={String(g.id)}>
{g.name}
</option>
))}
</select>
</label>
<Link to="/books/new" className={primaryButton}>
Add book
</Link>
</div>
{genresError && (
<p role="alert" className="mb-3 text-sm text-red-700">
{`Could not load genres: ${genresError}`}
</p>
)}
{error ? (
<div role="alert" className="flex items-center gap-3 text-red-700">
<span>{error}</span>
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</button>
</div>
) : books === null ? (
<p className="text-slate-500">Loading…</p>
) : books.length === 0 ? (
<p className="text-slate-600">
{q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{books.map((book) => (
<li key={book.id}>
<Link
to={`/books/${book.id}`}
className={`block ${card} hover:ring-2 hover:ring-indigo-300 focus-visible:outline-2 focus-visible:outline-indigo-600`}
>
<div className="flex items-start justify-between gap-2">
<h2 className="font-semibold">{book.title}</h2>
<StatusBadge status={book.status} />
</div>
<p className="text-sm text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
<ProgressBar book={book} />
</Link>
</li>
))}
</ul>
)}
</section>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { Book, ReadingStatus } from '../api'
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' },
reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' },
finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' },
}
export function StatusBadge({ status }: { status: ReadingStatus }) {
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}>
{STATUS[status].label}
</span>
)
}
export function ProgressBar({ book }: { book: Book }) {
const percent = Math.round((book.current_page / book.total_pages) * 100)
return (
<div className="mt-3">
<progress
className="h-2 w-full accent-indigo-600"
value={book.current_page}
max={book.total_pages}
aria-label={`Reading progress for ${book.title}`}
/>
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react'
import { api, errorMessage, type Genre } from '../api'
export function useGenres(): { genres: Genre[]; error: string | null } {
const [genres, setGenres] = useState<Genre[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api<Genre[]>('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
}, [])
return { genres, error }
}