MyKindlebox/memory-bank/architecture.md

114 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MyKindlebox — Architecture
**Audience:** AI developers. This file explains what each file/directory does and why, as the codebase grows. Keep it current — update after each major feature or milestone per `CLAUDE.md`.
---
## Repo layout
```
mykindlebox/
server/ — Express + TypeScript REST API, SQLite via Drizzle ORM
client/ — React + Vite frontend
docker-compose.yml — orchestrates both services for self-hosted deployment
```
## `mykindlebox/server`
| File | Purpose |
|---|---|
| `index.ts` | Entry point. Creates the Express app, applies `cors()` (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and `express.json()`, imports `db/client` (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001. |
| `db/client.ts` | Opens the SQLite connection and exports the Drizzle `db` instance used by all query code. Ensures `server/data/` exists before opening the file — `better-sqlite3` will not create missing parent directories on its own. The DB file lives at `server/data/mykindlebox.db`. On startup it now (Step 2.5) calls `drizzle-orm/better-sqlite3/migrator`'s `migrate()` to apply any pending migrations, then seeds the two default `settings` rows (`rabbitry_name`, `weight_unit`) via raw `INSERT OR IGNORE` keyed on the `key` primary key — idempotent across restarts. Also exports `dataDir` and `uploadsDir` (`data/uploads/`, created here) so other modules (`routes/rabbits.ts`'s multer config, `index.ts`'s static file serving) share one canonical path instead of each recomputing it. |
| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields incl. `acquired_date`/`death_date` — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), `rabbit_events` (`rabbit_id` FK with `ON DELETE cascade`, `type` (`show`/`sale`/`other`, CHECK-constrained), `event_date`/`title` notNull, `notes`, plus `show_class`/`placement` which are only meaningful for `type='show'` — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). The `rabbits` table also carries a nullable `breed` column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). `arba_shows_cache` (`id`, `fetched_at`, `data` — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see `lib/arba.ts` + `routes/shows.ts`). `show_events` (`name` notNull, `notes`, `created_at`/`updated_at` — a user-created grouping of ARBA shows; **no `user_id`**, single-user app) and `show_event_shows` (`show_event_id` FK with `ON DELETE cascade`, plus a **snapshot** of the ARBA row at add-time: `sanction_number`/`club_name` notNull, `show_date`/`show_type`/`specialty_breed`/`city`/`state`/`address`/`secretary`/`secretary_email`/`catalog_url`/`maps_url`/`display_order` — copied rather than referencing the cache because ARBA listings are transient). `show_event_entries` (a herd rabbit entered into a specific show: `show_event_show_id` FK `ON DELETE cascade`, `rabbit_id` FK to `rabbits` **without** cascade, `entry_class`/`entry_number`/`notes` free text, `created_at`). `show_event_judges` (`show_event_show_id` cascade FK, `judge_name` notNull, `judge_arba_number`/`notes`). `show_event_placements` (one per entry — `entry_id` is a **UNIQUE** cascade FK, making the PUT endpoint a clean upsert; `placement` free text, `legs_earned` int 0–3, `bob`/`bos`/`bis`/`ris` boolean-mode integers, `notes`). The Shows feature's data model is now complete (Phases 1–4). **User accounts** (`FEATURE_User_Accounts.md`): `users` (`username` notNull UNIQUE — case-sensitive index, case-insensitive enforcement lives in `lower()` queries; `password_hash` notNull `"scrypt:<salt>:<hash>"`; nullable per-user `theme`/`weight_unit` prefs — NULL inherits the global settings value; `created_at`) and `auth_sessions` (`token` text PK, `user_id` FK with a **declarative-only** cascade — the app runs with SQLite `foreign_keys` OFF, so `routes/users.ts` deletes sessions explicitly; `expires_at` ISO, purged lazily on session checks). |
| `db/migrations/` | Generated by `drizzle-kit generate`, applied either manually via `drizzle-kit migrate` (CLI) or automatically on server startup via `db/client.ts`'s in-code `migrate()` call. Both share the same `__drizzle_migrations` tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (`0000_modern_power_pack.sql` — rabbits, `0001_military_lord_tyger.sql` — litters, `0002_luxuriant_the_stranger.sql` — settings) — these are committed and intentionally **not** excluded by `.dockerignore` (only `node_modules` and `data` are). |
| `drizzle.config.ts` | Points `drizzle-kit` at `./db/schema.ts` for migration generation and `./data/mykindlebox.db` as the target DB. |
| `tsconfig.json` | TypeScript config: CommonJS module output (matches `package.json`'s `"type": "commonjs"`), strict mode on, compiles to `dist/`. |
| `package.json` scripts | `dev` — `tsx watch index.ts` (hot-reload local dev, no separate build step needed). `build` — `tsc` compiles to `dist/`. `start` — runs the compiled output, used in production/Docker. |
| `Dockerfile` | Builds a Node 20 Alpine image, installs deps, runs `npm run dev` (tsx) inside the container — not yet switched to a production build for the container; revisit when deploying for real. |
| `.dockerignore` | Excludes `node_modules` and `data/` from the Docker build context. |
| `routes/rabbits.ts` | Express `Router` mounted at `/api/rabbits` in `index.ts`. `GET /` (list, with `sex`/`status`/`search`/`sort` filters — `sex=kits` is a special case matching any sex with `dob` in the last 56 days), `GET /search` (autocomplete by *substring* on name only, must be registered before `GET /:id`), `GET /quick-search` (the global top-of-page quick-search bar — distinct from `/search`: a **prefix** match — "wildcard at the end" — across a chooseable subset of `farm`/`name`/`ear_number`/`color` via `?fields=` comma-list, defaulting to all four; returns each hit plus a `matched_fields` array re-derived in JS so the dropdown can label which filter the row flagged on. Empty `q` or no valid fields → `[]`. Also must be registered before `GET /:id`), `GET /:id` (404 if missing), `POST /` (validates `name`, 201 on success), `PATCH /:id` (partial update, refreshes `updated_at`), `DELETE /:id` (nulls referencing `sire_id`/`dam_id` before deleting), `GET /:id/ancestors` (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries `id`/`name`/`farm`/`sex`/`ear_number`/`weight_lbs`/`dob`/`color_name` — `ear_number`/`weight_lbs`/`dob` were added so the printable pedigree slip can show full per-ancestor detail), `POST /:id/photo` (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so `fileFilter` rejections become this route's own 400 rather than the global error handler's 500). |
| `routes/litters.ts` | Express `Router` mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice (via `alias()`) to embed sire/dam `{ id, name }`. `GET /:id` derives its `kits` list from `rabbits` matching `sire_id`/`dam_id`/`dob` (the same no-Kit-table derivation the design doc specifies elsewhere). `POST /` (requires a `breed_date` or `kindle_date`; only generates kits when a `kindle_date` is present), `PATCH /:id`, `DELETE /:id`. Recording a breeding (`POST` with `breed_date`, no `kindle_date`) creates a litter-in-progress with no kits yet. |
| `routes/events.ts` | Express `Router` mounted at `/api/events` (behind `requireAuth`). Manually-logged timeline events (`show`/`sale`/`other`). `GET /?rabbit_id=X` (required filter — no all-events view; ordered oldest-first so the client can splice them straight into the timeline), `POST /` (requires `rabbit_id`/`type`/`event_date`/`title`; a `sale` also flips the rabbit's `status` to `sold` in the **same transaction** as the insert), `PATCH /:id` (drops `rabbit_id` **and** `type` from the patch — an event keeps its rabbit and kind), `DELETE /:id`. Same mass-assignment guard + snake/camel boundary conversion as the other routers. |
| `routes/shows.ts` | Express `Router` mounted at `/api/shows` (behind `requireAuth`) — Phase 1 of the Shows feature (browse-only). `GET /arba` returns the cached ARBA listing as `{ fetched_at, stale, shows: [...] }`, re-scraping first if the cache is missing or older than 24h; if a needed scrape fails but a cache exists, it serves the **stale** cache (flagged `stale: true`) rather than erroring (only a cold miss returns 502). `POST /arba/refresh` forces a re-scrape, leaving the old cache intact on failure. The cache is a single row in `arba_shows_cache` (delete-then-insert in a transaction). Show objects are stored camelCase (from `lib/arba.ts`) and converted to snake_case at the boundary like every other route. |
| `routes/showEvents.ts` | Express `Router` mounted at `/api/shows/events` (behind `requireAuth`) — Phase 2 of the Shows feature. Event CRUD (`GET /` list with derived `show_count`/`first_show_date`/`last_show_date` from one grouped aggregate query, newest-`updated_at` first; `GET /:id` returns the event + its snapshotted `shows` in date order; `POST /`, `PATCH /:id`, `DELETE /:id`) plus `POST /:id/shows` (snapshots an ARBA show into the event — accepts the snake_case ARBA show object `GET /api/shows/arba` returns and maps it: `secretary_email`←`email`, `show_type`←`show_type_full`, `show_date`←`date`‖`raw_date`, `specialty_breed`←`special_breed`; **idempotent** per (event, `sanction_number`) — re-adding returns the existing row with 200) and `DELETE /:id/shows/:showId`. Add/remove also bump the event's `updated_at`. **Mounted before `/api/shows`** in `index.ts` so `/api/shows/events/*` resolves here, not to the ARBA browser router. **Phase 3 (entries)** adds: `GET /:id` now embeds an `entries` array per show (each entry left-joined to its rabbit's `name`/`ear_number`/`breed`/`color_name`/`sex` via `entriesByShow`); `POST`/`PATCH`/`DELETE /:id/shows/:showId/entries[/:entryId]` (PATCH only touches `entry_class`/`entry_number`/`notes` — never the rabbit or which show); and `GET /:id/report`, which builds the plain-text entries report (`{ report }`) — one section per show that has entries, owner line from `settings.rabbitry_name`, ARBA membership # deliberately omitted (resolved decision), dates rendered MM/DD/YYYY. **Phase 4 (judges + placements)** adds: `GET /:id` also embeds a `judges` array per show and a `placement` object (or null) per entry; `POST`/`PATCH`/`DELETE /:id/shows/:showId/judges[/:judgeId]`; and `PUT /:id/shows/:showId/entries/:entryId/placement` — a **full-object upsert** keyed on the entry's UNIQUE placement row (legs clamped 0–3, the four flags coerced to booleans). |
| `lib/arba.ts` | The ARBA scraper/normalizer. `fetchArbaShows()` fetches `https://arba.net/show-search/` and parses it with **cheerio**. Two non-obvious constraints, both load-bearing (see convention note below): (1) ARBA sits behind **Cloudflare** — the request MUST send browser-like `User-Agent` + `Accept` headers or it gets a block page (we deliberately don't set `Accept-Encoding`, letting global-fetch own decompression); (2) the page is a WordPress **Ninja Tables** table whose ~2,000 rows are server-rendered as plain **positional `<td>` cells** (the cells carry no column classes — only the `<thead>` does), so rows are read by cell index 0–10. `parseShowInformation()` splits the `showInformation` string (`CLUB, (OPEN\|YOUTH) (ALL BREED\|SPECIALTY) [FAIR] SHOW[, BREED]`) by anchoring on the show-type phrase (club names contain commas) into `clubName`/`showType` bucket/`showTypeFull`/`specialBreed`. Cavy shows (`CAVY` anywhere in `showInformation`) and non-US rows (state not in the 50+DC allowlist — excludes Canadian provinces and blanks) are dropped at normalization. Throws on a Cloudflare block / non-OK / missing table so the route can fall back to cache. |
| `routes/settings.ts` | Express `Router` mounted at `/api/settings`. `GET /` flattens the `settings` table into a single object; `PATCH /` rejects any request containing a key outside the `rabbitry_name`/`weight_unit` allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly. |
| `routes/stats.ts` | Express `Router` mounted at `/api/stats`. Returns dashboard counts (`herd`, `bucks`, `does`, `kits`, `for_sale`, `on_hold`, `sold`, `archived`) computed live from `rabbits` on every request — no cached counters. `kits` reuses the same 56-day `dob` window as the rabbits list's `sex=kits` filter; `archived` sums `status IN ('other', 'deceased')`. |
| `routes/import.ts` | Express `Router` mounted at `/api/import` (behind `requireAuth`). `POST /` accepts a single multipart `file` (multer **memory** storage, 10 MB cap) and **replaces the entire herd**: it wipes `litters` then `rabbits` and inserts the file's rabbits, all inside one `db.transaction` so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: **legacy CSV** (the old PHP Kindlebox export — header has `ID`/`GENDER`/`SIRE_ID`), **native CSV** (snake_case headers matching the API), and **JSON** (a rabbit array or `{ rabbits: [...] }`). Legacy mapping: `GENDER` 1→buck/0→doe; numeric `STATUS` via `LEGACY_STATUS_MAP` (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original `editrabbit.php` status `<select>`); `"0"`/blank sentinels for ear/registration numbers → null; `WEIGHT` of 0 → null (unweighed); gene columns → `locus_*`. Parentage is preserved by importing **original ids** and a **two-pass insert** (insert all rows with null `sire_id`/`dam_id`, then set the FK only when it points at an id actually present in the file — legacy `9999` "unknown parent" sentinels and references to non-imported rabbits become null). Includes a hand-rolled RFC-4180-ish `parseCsv` (quoted fields, `""` escapes, CR/LF, BOM strip) rather than adding a CSV dependency, matching the project's dependency-light ethos. Returns `{ format, rabbits_imported }`. **Scope is rabbits only** (per user decision) — litters are wiped but not reconstructed from the legacy `LITTER_ID` column. Rejects non-UTF-8 input early (`file.buffer.includes(0)` — a NUL byte means a UTF-16 Excel "Unicode Text" export or a binary `.xlsx`/image) with an actionable message, since decoding such a file as UTF-8 would otherwise import garbage/phantom rows. The Settings page also pre-validates extension + 10 MB size **before** uploading, because an over-limit file is rejected by multer mid-stream and the browser surfaces that as an opaque "network error" rather than the server's 400. |
| `utils/case.ts` | `toSnakeCase`/`toCamelCase` — generic recursive key-casing converters used at the API boundary. See casing convention below. |
**Why TypeScript:** the implementation plan specifies `db/client.ts` for the Drizzle setup. The server started as plain CommonJS JS in Step 1.2; it was converted to TypeScript at Step 2.1 specifically so the schema/DB layer has type safety, per explicit user decision (plain JS was the lower-effort alternative but not chosen).
## `mykindlebox/client`
| File | Purpose |
|---|---|
| `src/App.jsx` | Root component. Wraps the app in `QueryClientProvider` (React Query) and `BrowserRouter`, defining every IA route nested under a single `Layout` layout-route (`<Route element={<Layout />}>`) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all `*` → `NotFound` route. |
| `src/api.js` | Plain JS (not `.ts` — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, events CRUD (`getEvents`/`createEvent`/`updateEvent`/`deleteEvent`), settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`, shows (`getArbaShows`/`refreshArbaShows`), show events (`getShowEvents`/`getShowEvent`/`createShowEvent`/`updateShowEvent`/`deleteShowEvent`/`addShowToEvent`/`removeShowFromEvent`), show entries + report (`addShowEntry`/`updateShowEntry`/`removeShowEntry`/`getEventReport`), judges + placements (`addShowJudge`/`updateShowJudge`/`removeShowJudge`/`setShowEntryPlacement`)), all targeting `http://localhost:3001/api`. Shared `request()` helper throws on non-2xx (surfacing the JSON `{ error }` body's message) and only sets `Content-Type: application/json` when the body is a JSON string, so the multipart `FormData` photo-upload call isn't given the wrong header. |
| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's `<Outlet />`. Desktop: 240px left sidebar (`hidden md:flex`) with `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`) with Dashboard/Herd/Litters/Add (→ `/herd/add`)/Settings. Active-route styling comes from `NavLink`'s `isActive` callback, not manual path matching. Also renders `<QuickSearch />` at the top of the `<main>` content area (above `<Outlet />`), so the quick-search bar appears on every page. |
| `src/components/QuickSearch.jsx` | Global quick-search bar (top of every page via `Layout`). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), **all selected by default**, controlling which fields `GET /api/rabbits/quick-search` prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per `matched_fields` entry (value-bearing chips for ear/color, e.g. `Ear #: A15`). Selecting a result navigates to `/herd/:id`. Reuses `lib/keyboardNav.js`'s `handleSearchInputKeyDown`/`handleResultKeyDown` for the same `<ul><li><button>` roving-focus dropdown shape as `RabbitAutocomplete`/`PedigreeTree`. The debounce effect deliberately does **not** call `setState` synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show. |
| `src/components/Button.jsx` | `variant` (`primary`/`secondary`/`danger`) × `size` (`sm`/`md`) class-map component. |
| `src/components/Badge.jsx` | Status → color/label class map covering all six Rabbit `status` values; unrecognized values fall back to the `other` style rather than crashing. |
| `src/components/Card.jsx` | Bordered `surface-raised` container with shadow; just forwards `className`/props. |
| `src/components/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. |
| `src/components/EmptyState.jsx` | Centered title + optional description + optional action, used by list pages with no results. |
| `src/pages/*.jsx` | One file per IA route (`Dashboard`, `Herd`, `AddRabbit`, `RabbitDetail`, `EditRabbit`, `Litters`, `AddLitter`, `LitterDetail`, `Genetics`, `Settings`, `NotFound`). `Dashboard.jsx` is built out (Phase 5): a stats bar (8 `Card` tiles from `GET /api/stats`, each linking to `/herd` with a sex or status query param), a quick-actions row (`Button`s linking to `/herd/add`, `/litters/add`, `/herd`), and a two-column (`md:grid-cols-2`) widget row with Recent Litters (top 3 by `kindle_date` from `GET /api/litters`) and Upcoming Birthdays (a user-requested addition not in the original plan — computed client-side from `GET /api/rabbits?status=active`'s `dob` field, no backend support; see the "Upcoming Birthdays" convention note below). `Herd.jsx` is built out (Phase 6): a sex/sort/search/view filter bar synced to URL query params (`?sex=&sort=&q=&view=`), and a `RabbitGrid`/`RabbitListView` pair sharing one `useQuery` over `GET /api/rabbits` — see the Herd filtering convention note below. `AddRabbit.jsx` (Phase 7) is a 3-step wizard (Pedigree → Details+Genetics → Review) composing the Step 7.1–7.4 components, holding `details`/`genetics`/`pedigreeFields` state and calling `createRabbit` then (if a photo was picked) `uploadRabbitPhoto` on save. `EditRabbit.jsx` (Phase 7) is a single page with all three sections visible, pre-populated from `GET /api/rabbits/:id` + `GET /api/rabbits/:id/ancestors`, sending only the changed fields via `PATCH` — see the pedigree convention note below for why it must wait on the ancestors fetch before mounting `PedigreeTree`. All other pages remain placeholders rendering just a heading — real content for them starts in Phase 8+. |
| `src/pages/Shows.jsx` | The `/shows` ARBA show browser (Shows feature Phase 1 — browse-only, no event/entry actions yet). One `useQuery(['arba-shows'])` over `getArbaShows`; a "Refresh Data" button mutates via `refreshArbaShows` and writes the result straight into the query cache (`setQueryData`). All filtering/pagination is **client-side** over the loaded list: State/District/Show Type multi-selects (an inline `MultiSelect` checkbox-popover component with outside-click close; State/District options derived from the data, Show Type a fixed 5-bucket list matching `lib/arba.ts`'s `ShowType`), Date From (defaults to today) / Date To, and a keyword box (club/city/secretary). 50 rows/page. Reads the snake_case show fields the API returns (`raw_date`, `club_name`, `show_type`/`show_type_full`, `special_breed`, `maps_url`, `catalog_url`, etc.). |
| `src/pages/ShowEvents.jsx` + `src/pages/ShowEventDetail.jsx` | The Shows feature Phase 2 event UI. `ShowEvents.jsx` (`/shows/events`) lists events as cards (name, date range, show count) over `getShowEvents`, with an inline `NewEventForm` toggled by "New Event". `ShowEventDetail.jsx` (`/shows/events/:id`) shows the event header (inline `EditEventForm` for rename/notes, Delete with a `window.confirm`, "Add Shows" link back to `/shows`) and the **Shows tab** (the only tab in Phase 2 — judges/entries/placements come later): each `ShowCard` renders the snapshot (club, date, type, sanction #, location, secretary, mailto/map/catalog links) with a Remove button. Mutations invalidate `['show-events']` and `['show-event', id]`. The "Add to Event" flow lives in `components/AddToEventModal.jsx` (launched per-row from `Shows.jsx`): pick an existing event or create one inline, then `addShowToEvent`; shows a success state with a link to the event. **Phase 3** adds a Shows/Entries tab switch to `ShowEventDetail.jsx`: the **Entries tab** groups entries per show, with an "Add Rabbit" picker (`components/RabbitPicker.jsx` — a modal that client-side searches the herd by name/tattoo/breed/variety and disables rabbits already entered in that show), inline-editable `entry_class`/`notes` (saved on blur via `updateShowEntry`), a remove action, and an "in multiple shows" badge for a rabbit entered across more than one show in the event. "Generate Entries Report" opens `components/ReportModal.jsx`, which fetches `getEventReport` and offers Copy to Clipboard. **Phase 4** adds a third **Results** tab plus: a `JudgesSection` on each Shows-tab card (list + inline add/remove of judges), and a per-entry `PlacementEditor` (toggled by an "Add/Edit results" link in the Entries tab) — placement text, a legs +/- counter (0–3), and BOB/BOS/BIS/RIS checkboxes that persist immediately via `setShowEntryPlacement` (the full-object PUT; legs/checkboxes save on change, text on blur). The **Results tab** (`ResultsTab`) is a read-only rollup aggregated client-side from the embedded placements — one card per rabbit, sorted by total legs, with leg/flag badges. |
| `src/pages/PedigreePrint.jsx` | Standalone print-optimized 4-generation pedigree slip at `/herd/:id/pedigree`, deliberately routed **outside** `<Layout>` (in `App.jsx`) so the sidebar/bottom-nav don't render or print. Reuses `GET /api/rabbits/:id/ancestors` + `GET /api/settings` (rabbitry name) + `getRabbit`. Modeled on the original Kindlebox PHP slip. Renders a rabbitry header band (rabbitry name, subject farm/name, printed-on date) above a CSS-grid bracket layout (subject column + parents + grandparents + great-grandparents, each generation's row-spans aligning it against the next). The leftmost column stacks a fill-in **Sold To** block, the **subject box** (color, ear number, DOB, weight, sex), and a **certification + Signed** line — mirroring the old slip. Each ancestor box shows farm+name, color, ear, weight, and DOB via a `nodeInfo()` helper that normalizes linked DB nodes, manual-JSON nodes (parsed via `parseManualAncestor`), and missing nodes (→ "Unknown"); `fmtDob` renders MM-DD-YYYY (or "Unknown"), `fmtWeight` omits unset/zero weights. The user produces the PDF via the browser's Print → Save as PDF — **browser print was the chosen method over a bundled PDF library** to keep the self-hosted footprint dependency-free. A component-scoped `<style>` block sets `@page { size: landscape }` (scoped to this mounted page so it doesn't leak to other routes), hides the on-screen Back/Print buttons (`.no-print`) when printing, and forces `print-color-adjust: exact` so the accent header band prints. A "Print Pedigree" button on `RabbitDetail.jsx`'s action bar links here. |
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
| `src/lib/ocrPreprocess.js` | OCR Pedigree Scanning, Phase 2. Canvas-only (no `jimp`) image normalization for OCR. Exports `preprocessImage(file)` — decode → downscale to `MAX_WIDTH` (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG `Blob`. Also exports the tunable `MAX_WIDTH` and `BINARIZE_THRESHOLD` (binarization cutoff, tuned to **103** during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
| `src/components/RabbitDetailsForm.jsx` | Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a `weightUnit` prop; photo selection is reported via a separate `onPhotoChange(file)` callback since it's always a second request (`uploadRabbitPhoto`) after the rabbit row exists. |
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. |
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared `dash()` helper for empty values. |
| `src/lib/theme.js` | Theme registry (post-Phase-11 feature) — the single source of truth for the *list* of selectable themes (`classic`/`midnight`/`ocean`/`berry`/`sunset`/`carnival`), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome `theme-color`. The actual palettes are CSS variables in `index.css` (one `[data-theme='…']` block per theme); this module only knows ids + preview metadata. `applyTheme(id)` sets `document.documentElement.dataset.theme` (which swaps the variables), updates the `theme-color` meta, and mirrors the id to `localStorage` (key `mykindlebox-theme`). `normalizeTheme` falls back to `classic` for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below. |
| `src/lib/breeds.js` | The ARBA-recognized rabbit breed list (`ARBA_BREEDS`) powering the breed picker, plus `isCustomBreed(breed)` (true for a non-empty value not on the list). Suggestion list only — `BreedSelect` keeps a free-text fallback, so a breed not on this list still saves. Add new ARBA breeds here. |
| `src/components/BreedSelect.jsx` | The breed picker (combobox): a text input bound **directly** to `value` (so anything typed is a valid free-text breed) with a filtered dropdown of `ARBA_BREEDS` underneath for guided selection. Reuses `lib/keyboardNav.js` for the same arrow-key roving focus as `RabbitAutocomplete`/`PedigreeTree`. Shows a "Custom breed" hint when the value isn't an ARBA breed. Used by `RabbitDetailsForm.jsx` (the rabbit Breed field) and `Settings.jsx` (the Default Breed setting). |
| `src/lib/keyboardNav.js` | `handleSearchInputKeyDown`/`handleResultKeyDown` (Phase 11 Step 11.4) — arrow-key roving focus for the `<ul><li><button>` search-results dropdown shape shared by `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx`. ArrowDown/ArrowUp move between the input and result buttons, Escape closes the dropdown and refocuses its trigger button. |
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `<link>` tags, applies `font-body` to `<body>` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. **OCR addition:** the five vendored `/tesseract/` assets (see `public/tesseract/` below) are precached on `install` (a `TESSERACT_URLS` list alongside the existing `SHELL_URLS`, same cache) and served **cache-first** via a dedicated `fetch`-handler branch — they're large/static/versioned, so once stored they're served straight from cache with no network round-trip (the rest of the shell stays network-first). See the OCR-assets convention note below. |
| `public/tesseract/` | Vendored Tesseract OCR runtime (OCR Phase 1 Step 1.2), served from the app's own origin so the pedigree scanner makes **no CDN calls** and works offline. Five files: `worker.min.js` (the tesseract.js web-worker script); `eng.traineddata` (the **uncompressed** English LSTM language data); and three feature-detect WASM core builds — `tesseract-core-lstm.wasm.js`, `tesseract-core-simd-lstm.wasm.js`, `tesseract-core-relaxedsimd-lstm.wasm.js` (each ~3.9 MB, self-contained — the WASM is base64-embedded in the `.wasm.js`). At runtime tesseract.js (default OEM `LSTM_ONLY`) feature-detects and loads exactly **one** of the three cores; all three are vendored so a directory-based `corePath` auto-selects correctly on any browser. See the OCR-assets convention note below. |
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
| `Dockerfile` | Multi-stage production build: a Node 20 Alpine stage runs `npm ci && npm run build`, then an nginx 1.27 Alpine stage serves the built `dist/` with `nginx.conf` (which reverse-proxies `/api` + `/uploads` to the server container and serves the SPA fallback + `sw.js` no-cache header). The Vite dev server is never run in Docker. |
## Root
| File | Purpose |
|---|---|
| `docker-compose.yml` | Two services, `server` (port 3001) and `client` (port 5173), each built from their own Dockerfile. A named volume `server-data` is mounted at `/app/data` in the server container so the SQLite file persists across container rebuilds. **Not yet validated** — Docker isn't installed on the dev machine; correctness here is unconfirmed until someone runs `docker compose up`. |
---
## Key conventions to remember when extending this code
- **Chain-authority rule** (pedigree ancestors): a `_name_text` freeform column is only ever written/read when the immediate parent slot has no FK. Once a chain resolves through a real DB record, all deeper data comes from that ancestor's own columns, never the subject's flat columns. This is encoded in the `rabbits` schema as 14 plain nullable text columns (Step 2.2) — the DB schema itself doesn't enforce the rule, application code must. Implemented in `routes/rabbits.ts`'s `resolveAncestorNode(record, path)`: it walks a direction path (`['sire','dam',...]`) one step at a time, recursing into the FK'd DB row whenever one exists for the current step, and only consulting the *current* record's flat column when the path still has remaining depth but no FK to recurse through. Because recursion always switches the "current record" to the fetched ancestor, the original root's flat columns become structurally unreachable as soon as any FK in the path resolves — there's no separate "is broken" flag to get out of sync. Any future write path (the rabbit details/pedigree form, Phase 7) must preserve this: write a `_name_text` column only when the immediate parent FK for that slot is null, and clear deeper-generation flat columns for a branch once a FK is set for an ancestor in that branch (or at minimum, never read them — the read path already ignores them correctly).
- **Migrations are file-based via `drizzle-kit`**, not auto-generated at runtime. After editing `db/schema.ts`, run `npx drizzle-kit generate` (writes a new SQL file to `db/migrations/`). Applying them happens automatically on server startup (`db/client.ts` calls `migrate()`) — running `npx drizzle-kit migrate` by hand also works and shares the same tracking table, but is no longer required day-to-day.
- **No separate Kit table** — kits are full `Rabbit` rows; a litter's kit list is derived by matching `sire_id`/`dam_id`/`dob` against the litter, not via a join table.
- Settings is a simple key-value table (`settings`), not a typed config object in code. Seeded with `rabbitry_name`/`weight_unit`/`kit_prefix`/`theme`/`default_breed` defaults (plus the non-editable `kit_counter`) on every startup via `INSERT OR IGNORE`. The editable allowlist lives in `routes/settings.ts`'s `ALLOWED_KEYS`. `default_breed` pre-fills the Breed field on **new** rabbits only — `AddRabbit.jsx` seeds `details.breed` from it via the same one-shot `detailsDefaulted` ref guard that defaults `farm` from `rabbitry_name` (Edit never overrides an existing breed).
- **API JSON casing**: Drizzle schema field names are camelCase (idiomatic JS/TS), but the REST API's request/response JSON bodies are snake_case, matching the design doc and implementation plan's literal examples (`weight_lbs`, `sire_id`, etc.). Every route handler must convert: incoming bodies via `toCamelCase` before passing to Drizzle, outgoing rows via `toSnakeCase` before `res.json()`. Don't `res.json()` a raw Drizzle row.
- **Upcoming Birthdays** (Dashboard widget, added post-Phase-5, not in the implementation plan or design doc): there is intentionally no `/api/birthdays` endpoint. `Dashboard.jsx`'s `getUpcomingBirthdays()` fetches active rabbits and computes the next occurrence of each `dob`'s month/day client-side, windowed to 30 days. If this logic is ever needed elsewhere (e.g. a notifications feature), lift it into `api.js` or a backend endpoint rather than duplicating the date math.
- **Herd "Archived" filter**: `?status=archived` is a frontend-only convenience value, not a real `status` column value — it mirrors the same `other`+`deceased` grouping `routes/stats.ts` uses for the Dashboard's Archived count. `Herd.jsx` strips `status=archived` before calling the backend (which only does exact-match status filtering) and instead filters the response client-side to `status IN ('other', 'deceased')`. Any other status value passed in the URL goes straight to the backend unmodified.
- **URL-synced debounced inputs**: don't sync a prop into local state via a `useEffect` that calls `setState` — this project's ESLint config (`react-hooks/set-state-in-effect`) treats that as an error. `Herd.jsx`'s `SearchBox` is the established pattern: give the input component `key={externalValue}` from the parent so an external change (e.g. browser back/forward updating the URL) remounts the component and re-initializes its local state from props, instead of reconciling it via an effect.
- **One-shot default-fill from async data**: when a query result should seed local state exactly once (e.g. `AddRabbit.jsx` defaulting `details.farm` from `settings.rabbitry_name`, or `EditRabbit.jsx` seeding `details`/`genetics` from the loaded rabbit), call `setState` directly in the render body guarded by a condition that becomes false after the first call (a `useRef` flag, or `details === null`) — this is the React-docs-sanctioned "adjusting state during render" pattern, not the banned `useEffect`-based prop-sync. It's distinct from the `SearchBox` case above because there's nothing to *re*-sync on every external change, only a single seed.
- **Pedigree chain-authority on the client** (`lib/pedigree.js`, used by `PedigreeTree.jsx`/`AddRabbit.jsx`/`EditRabbit.jsx`): mirrors the backend's `resolveAncestorNode` (see the server-side note above) via `resolveEffectiveNode(path, state, cascadeCache)` — a node is "locked" (non-interactive, cascade-derived) whenever its immediate parent's effective node is `mode: 'linked'`, sourcing its value from that ancestor's own `AncestorNode` subtree (fetched via `GET /api/rabbits/:id/ancestors`, cached by id) instead of any state on the subject. Because the `rabbits` table has no FK column below `sire_id`/`dam_id`, an *editable* gen2/gen3 node's "Search existing" option can only ever save freeform text (the matched rabbit's name) — there is no column to link it to. When seeding `PedigreeTree` for editing, `EditRabbit.jsx` must wait for the ancestors fetch to resolve before mounting it: a linked gen1 node's display name comes from `ancestors.sire.name`/`ancestors.dam.name`, not from the raw rabbit record (which only has `sire_id`, no name) — mounting `PedigreeTree` early renders a blank name for an otherwise-correctly-linked Sire/Dam (this was caught by Playwright during Step 7.6 validation, see `progress.md`).
- **Manually-entered ancestors store structured data, not a bare name** (per explicit user request, post-Phase-7): each `_name_text` column is still a single text column (no schema migration), but "Enter manually" now collects Farm Name, Rabbit Name, and Ear Number (all required — the Save button is disabled until all three are non-empty) plus Weight (defaults to `0`) and DOB (defaults to the literal string `"Unknown"`, since a real ancestor record's actual DOB is virtually never known). These five fields are JSON-encoded into the `_name_text` column via `serializeManualAncestor`. Every place that reads a manual node's `name` for *display* (the `PedigreeTree` slot button/locked-node text, `buildPedigreeSummary`'s `display` field used by `ReviewStep`) must run it through `formatManualAncestorDisplay` first, which renders `"Name (Farm) • Ear #X"` — never render `node.name` raw for a manual node, it's JSON. `parseManualAncestor` is defensive: a plain non-JSON string (a legacy entry, or hand-edited in the DB) is treated as just the `name` field with the other four defaulted, so old data doesn't break. Re-opening an already-manual node's editor (`PedigreeTree.jsx`'s button `onClick`) calls `parseManualAncestor` to restore all five fields for further editing, rather than starting blank.
- **DOB is a date picker + separate "Unknown" checkbox in the form, but one string in storage**: the manual-entry form's local state keeps `dob` (a real date-input value) and `dobUnknown` (the checkbox, checked by default) as two separate fields — they only collapse into the single stored string (`"Unknown"` or the real date) at save time. `manualFormFromParsed(parsed)` is the inverse: given a parsed ancestor record, it sets `dobUnknown: true` and blanks the date input whenever the stored `dob` is `"Unknown"` (or missing), otherwise populates the date input and unchecks the box. Don't bind the date `<input type="date">` directly to a value that might be the string `"Unknown"` — it's not a valid date and the input will silently show blank, which is why the split exists.
- **Weight and DOB need real `<label>` elements, not placeholders**: every other manual-entry field (Farm/Name/Ear Number) relies on `placeholder` text since they start empty, but Weight always has a default value (`0`) and `type="date"` inputs don't reliably render placeholders at all — a `placeholder` attribute on either is effectively invisible. Use a `<label>` above the input instead.
- **Focus ring is a global CSS rule, not per-component classes** (Phase 11 Step 11.4): `index.css`'s `:focus-visible { outline: 2px solid theme('colors.accent') !important; ... }` is the single source of the accent-colored keyboard focus ring across the whole app. The `!important` is required — many inputs/buttons already carry `focus:outline-none` (paired with their own `focus:ring-2`), which has higher CSS specificity than a bare `:focus-visible` selector and would otherwise win, leaving keyboard focus invisible on exactly those elements. Don't try to remove this rule or "clean it up" into per-component `focus-visible:` utility classes without re-auditing every interactive element for a visible ring first.
- **Search-dropdown keyboard nav lives in `lib/keyboardNav.js`, not duplicated per component**: `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx` both render an identical `<input>` + `<ul><li><button>` results dropdown. Both wire their `onKeyDown` handlers to the same `handleSearchInputKeyDown`/`handleResultKeyDown` exports rather than each implementing their own arrow-key logic. Any third component with this same dropdown shape should reuse these instead of re-deriving roving focus.
- **Theming is CSS-variable swaps driven by `data-theme`, not a JS theme object** (post-Phase-11 feature): the ten design tokens in `tailwind.config.js` resolve to CSS variables (`var(--color-surface)` etc.) rather than literal hex. `src/index.css` defines each theme as a `[data-theme='…']` block overriding those ten variables (`:root` = the default Classic palette); setting `document.documentElement.dataset.theme` re-themes every existing utility class with **no markup changes**, which is why adding a theme is purely: a CSS block in `index.css` + an entry in `src/lib/theme.js`'s `THEMES` array (keep the two id lists in sync, including the inline allowlist array in `index.html`). No alpha modifiers (`bg-accent/20`) are used on theme tokens, so the simple `var(--x)` format suffices — if you ever need one, the variables must switch to space-separated RGB channels + the `rgb(var(--x) / <alpha-value>)` format. **Persistence is dual**: the backend `settings.theme` row (added to `routes/settings.ts`'s `ALLOWED_KEYS` and seeded `'classic'` in `db/client.ts`) is the cross-device source of truth; `localStorage` is only a fast-path cache so the inline `<script>` in `index.html` can apply the last-known theme **before first paint** (avoiding a flash of the default palette). `App.jsx`'s `ThemeSync` (a render-nothing child of the providers) reconciles the document with `settings.theme` once that query resolves. `Settings.jsx`'s picker calls `applyTheme` on click for instant live preview, but only **Save** persists it to the backend — an un-saved preview lingers in `localStorage`/the current tab until the next backend reconcile, which is acceptable. The `:focus-visible` rule's `theme('colors.accent')` now compiles to `var(--color-accent)`, so the focus ring themes correctly too.
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
- **OCR/Tesseract assets are vendored, not CDN-fetched, and the `eng.traineddata` is stored uncompressed** (OCR Pedigree Scanning feature, `public/tesseract/`): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives under `public/tesseract/` and the worker must be configured with explicit local paths. Two load-bearing facts when wiring up `ocrRun.js` (OCR Phase 3): (1) **`gzip: false` is required** alongside `langPath: '/tesseract/'`, because the vendored `eng.traineddata` is decompressed — the default `gzip: true` makes the worker request `eng.traineddata.gz`, which 404s. (2) **`corePath` should point at the directory `'/tesseract/'`, not a specific file**, so tesseract.js's `wasm-feature-detect` step picks the matching core (`relaxedsimd-lstm` → `simd-lstm` → `lstm`) for the running browser; all three `-lstm` `.wasm.js` cores are vendored for exactly this reason (the app uses the default `LSTM_ONLY` OEM, so the non-`-lstm`/`.asm` cores are never requested and aren't vendored). `workerPath` is `'/tesseract/worker.min.js'`. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch in `tesseract.js/src/worker-script/browser/getCore.js`. The service worker precaches + cache-firsts these (see `public/sw.js` above).
- **Auth is account-based and always on** (`FEATURE_User_Accounts.md`, replacing the original optional shared-password mode): accounts live in `users`, sessions in `auth_sessions` (so restarts don't sign anyone out), passwords are scrypt-hashed via `node:crypto` (**deliberately not bcrypt/argon2** — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created through `POST /api/auth/setup`, which only works while the `users` table is empty — the client's `AuthProvider` shows a `SetupScreen` when `GET /api/auth/status` reports `configured: false`. Any signed-in user manages users at `/api/users` (Settings → Users card); you can't delete yourself, which transitively protects the last account. **`/api/settings` kept its exact single-object shape** but storage split: `theme`/`weight_unit` read/write the caller's `users` row (NULL falls back to the global settings value), the rest stay in the `settings` table — client consumers (ThemeSync, `useWeightUnit`, the Settings page) required no changes. The old `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` env var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping.
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.