From 9e247b6f06f4a07afd78cf2ee71b306d0de13b97 Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 14:27:48 -0500 Subject: [PATCH] feat: add reading journal to book detail Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X --- src/books/BookDetail.tsx | 2 + src/notes/NotesJournal.test.tsx | 80 ++++++++++++++ src/notes/NotesJournal.tsx | 179 ++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/notes/NotesJournal.test.tsx create mode 100644 src/notes/NotesJournal.tsx diff --git a/src/books/BookDetail.tsx b/src/books/BookDetail.tsx index 8875c2a..a923019 100644 --- a/src/books/BookDetail.tsx +++ b/src/books/BookDetail.tsx @@ -2,6 +2,7 @@ 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 { NotesJournal } from '../notes/NotesJournal' import { ProgressBar, StatusBadge } from './Progress' export function BookDetail() { @@ -155,6 +156,7 @@ export function BookDetail() { )} + ) } diff --git a/src/notes/NotesJournal.test.tsx b/src/notes/NotesJournal.test.tsx new file mode 100644 index 0000000..67b31c8 --- /dev/null +++ b/src/notes/NotesJournal.test.tsx @@ -0,0 +1,80 @@ +import { screen, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Note } from '../api' +import { dune, renderApp, signedIn } from '../test/helpers' + +const older: Note = { + id: 11, + book_id: 7, + body: 'Started reading', + created_at: '2026-10-01T10:00:00+00:00', + updated_at: '2026-10-01T10:00:00+00:00', +} + +describe('NotesJournal', () => { + it('lists entries and adds a new one at the top', async () => { + const calls = signedIn({ + 'GET /api/books/7': () => [200, dune], + 'GET /api/books/7/notes': () => [200, [older]], + 'POST /api/books/7/notes': ({ body }) => [ + 201, + { ...older, id: 12, body: (body as { body: string }).body, created_at: '2026-10-02T10:00:00+00:00', updated_at: '2026-10-02T10:00:00+00:00' }, + ], + }) + const user = renderApp('/books/7') + expect(await screen.findByText('Started reading')).toBeInTheDocument() + await user.type(screen.getByLabelText('New journal entry'), 'Loved chapter 3') + await user.click(screen.getByRole('button', { name: 'Add entry' })) + + await screen.findByText('Loved chapter 3') + const items = screen.getAllByRole('listitem') + expect(items[0]).toHaveTextContent('Loved chapter 3') + expect(items[1]).toHaveTextContent('Started reading') + expect(screen.getByLabelText('New journal entry')).toHaveValue('') + expect(calls).toContainEqual({ method: 'POST', path: '/api/books/7/notes', body: { body: 'Loved chapter 3' } }) + }) + + it('edits an entry and marks it edited', async () => { + signedIn({ + 'GET /api/books/7': () => [200, dune], + 'GET /api/books/7/notes': () => [200, [older]], + 'PATCH /api/notes/11': ({ body }) => [ + 200, + { ...older, body: (body as { body: string }).body, updated_at: '2026-10-02T10:00:00+00:00' }, + ], + }) + const user = renderApp('/books/7') + const item = (await screen.findByText('Started reading')).closest('li')! + await user.click(within(item).getByRole('button', { name: 'Edit entry' })) + const editor = within(item).getByLabelText('Edit entry') + await user.clear(editor) + await user.type(editor, 'Finished part one') + await user.click(within(item).getByRole('button', { name: 'Save' })) + + expect(await within(item).findByText('Finished part one')).toBeInTheDocument() + expect(within(item).getByText(/edited/)).toBeInTheDocument() + }) + + it('deletes an entry', async () => { + signedIn({ + 'GET /api/books/7': () => [200, dune], + 'GET /api/books/7/notes': () => [200, [older]], + 'DELETE /api/notes/11': () => [204], + }) + const user = renderApp('/books/7') + await user.click(await screen.findByRole('button', { name: 'Delete entry' })) + expect(await screen.findByText('No entries yet.')).toBeInTheDocument() + }) + + it('shows the server error when adding fails', async () => { + signedIn({ + 'GET /api/books/7': () => [200, dune], + 'GET /api/books/7/notes': () => [200, []], + 'POST /api/books/7/notes': () => [400, { error: 'body must be at most 10000 characters', field: 'body' }], + }) + const user = renderApp('/books/7') + await user.type(await screen.findByLabelText('New journal entry'), 'x') + await user.click(screen.getByRole('button', { name: 'Add entry' })) + expect(await screen.findByRole('alert')).toHaveTextContent('body must be at most 10000 characters') + }) +}) diff --git a/src/notes/NotesJournal.tsx b/src/notes/NotesJournal.tsx new file mode 100644 index 0000000..9ddf9ea --- /dev/null +++ b/src/notes/NotesJournal.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { api, errorMessage, type Note } from '../api' +import { card, dangerButton, inputClass, labelText, primaryButton, secondaryButton } from '../ui' + +const MAX_NOTE = 10000 + +export function NotesJournal({ bookId }: { bookId: number }) { + const [notes, setNotes] = useState(null) + const [loadError, setLoadError] = useState(null) + const [attempt, setAttempt] = useState(0) + const [draft, setDraft] = useState('') + const [addError, setAddError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoadError(null) + api('GET', `/books/${bookId}/notes`).then( + (found) => { + if (!cancelled) setNotes(found) + }, + (err) => { + if (!cancelled) setLoadError(errorMessage(err)) + }, + ) + return () => { + cancelled = true + } + }, [bookId, attempt]) + + async function addNote(event: FormEvent) { + event.preventDefault() + setAddError(null) + try { + const note = await api('POST', `/books/${bookId}/notes`, { body: draft }) + setNotes((prev) => [note, ...(prev ?? [])]) + setDraft('') + } catch (err) { + setAddError(errorMessage(err)) + } + } + + return ( +
+

+ Reading journal +

+
+