MyKindlebox/memory-bank/architecture.md

130 lines
64 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 — `search` is a substring match across name, ear number and color — `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`. `PATCH` keeps kits consistent with the lifecycle: recording the first `kindle_date` on a breeding (with `kit_count > 0`) generates kits exactly as `POST` does, and changing `kindle_date`/`sire_id`/`dam_id` on an already-kindled litter re-points the kits that matched the old triple so they aren't orphaned. Generated kits get `farm` = `settings.rabbitry_name` and `breed` = the dam's (else sire's) breed. 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. `GET /entries?rabbit_id=X` (registered before `/:id`) returns one rabbit's entries across all events with event/show info + placement — feeds the rabbit timeline. 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. |
| `lib/pedigreeScan.ts` | Pedigree scanning via Claude vision (`claude-opus-5-5`, effort `high`, `fallbacks: "default"` under beta `server-side-fallback-2026-07-01`). `scanPedigreeImage(buffer, mediaType)` sends the photo with a system prompt describing pedigree conventions (horizontal/vertical/boxed layouts, labels, color coding, farm-prefix splitting, unit/date normalization, "never invent; mark uncertain") and a nested Zod schema (subject → sire/dam → grandparents → great-grandparents, each `{name, farm, ear_number, dob, weight_lbs, uncertain[]}`, subject also `sex/breed/color`) parsed via `client.beta.messages.parse` + `betaZodOutputFormat`. Output is cleaned (trim, ISO-date validation, weight sanity range, uppercase ear #) and flattened to the client's 14 slot names. Typed SDK errors, refusals and non-pedigree images map to `PedigreeScanError(message, status)`. Enabled only when `ANTHROPIC_API_KEY` is set. |
| `routes/pedigreeScan.ts` | `GET /api/pedigree-scan` → `{ enabled }`; `POST /api/pedigree-scan` (multipart `image`, in-memory multer, 10 MB, JPEG/PNG/WebP) → scan result plus `matches: {sire, dam}` from `findHerdMatch` (exact ear number, else exactly one exact case-insensitive name; otherwise null). The image is never written to disk or stored. |
| `lib/ancestors.ts` | Ancestor materialization + "add again = top up". `POST /api/rabbits` runs in one transaction: `findExistingSubject` (same ear number, else same name + farm; never when ear #, sex or DOB contradict) → if found, `mergeIntoExisting` fills only EMPTY columns (never overwrites), promotes a status-`other` reference record to the submitted status, links a herd-picked parent if it had none, and `attachMissingParents` walks the entered pedigree adding/topping-up ancestors (an existing parent that is a different animal wins and is reported in `conflicts`); response is the row + `merged` report (200). Otherwise `materializeAncestors` turns each text branch into linked status-`other` rabbits (parents first, reusing matches via `findExisting`, which prefers a non-`other` rabbit when ear numbers are shared) and clears that branch's text columns (201, `merged: null`). `POST /api/rabbits/find-existing` exposes the same subject match for the Review step banner. Tests: `lib/ancestors.test.ts` (temp DATA_DIR). PATCH and import don't materialize. |
| `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`, icon + label, 56px tall, padded by `env(safe-area-inset-bottom)`) with Dashboard/Herd/Add (→ `/herd/add`)/Litters/More — "More" opens a small menu for Genetics/Shows/Settings so every page is reachable on a phone. The sidebar is `sticky` and the content column is `min-w-0` + `max-w-6xl`, so wide children (tables, the 900px pedigree tree) scroll inside their own containers instead of stretching the page. `<main>` owns the page gutter (`px-4 md:px-8`); pages must not add their own horizontal padding. 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 />`) on every page **except** `/herd` (which has its own search box) and the add/edit form routes (`showsQuickSearch`), where jumping to a result would silently discard the unsaved form. |
| `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`. Results outside the active herd show a status `Badge` (pedigree-materialized ancestors are status `other`). 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. With a `to` prop it renders a router `<Link>` with the same classes — **use `<Button to=…>` for navigation, never `<Link><Button>`** (a button inside a link is invalid HTML and adds a second tab stop). |
| `src/components/LoadError.jsx` | Failed-fetch state for a page's main record (`title`, `error`, `backTo`, `backLabel`); says "not found" for 404s. Detail/edit pages render it on `isError` so a stale link never sits on "Loading…" forever. |
| `src/hooks/useElementWidth.js` | `[ref, width]` via `ResizeObserver` — for layouts that depend on a column's real width rather than the viewport (used by `PedigreeTreeReadOnly`). |
| `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. No horizontal padding (the Layout gutter supplies it); stacks the action row under the title below `sm`. Pages render it as the first child of a `flex flex-col gap-6` root, and loading states render `<PageHeader title=… subtitle="Loading…" />`. |
| `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/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. **Responsive layout:** at `≥1280px` (`useMediaQuery`, `hooks/useMediaQuery.js`) it renders the 3-column tree; below that the same `AncestorSlot`s render as a stacked, indented list (`StackedBranch`) in "Sire's side"/"Dam's side" cards, so phones never scroll sideways. Popup drop-direction accounts for the bottom chrome (action bar + mobile nav). |
| `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. Primary fields (Name, Farm, Ear #, Sex, DOB, Breed, Color, Weight, Photo) come first; Status/Location/Acquired/Death sit in a "More details" section that `collapseSecondary` (Add flow only) starts closed. The name error only shows when `showValidation` is set (Add sets it after a failed Next). |
| `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. Optional `onUseColor(color)` adds a "Use as color name" button (Add + Edit wire it to `details.color_name`). |
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save. Formats values for display (sex/status labels, `formatDate`, `formatWeight` in the user's unit), hides empty rows (Sire/Dam always shown; deeper ancestors only when set) and shows genetics as "Not set" when empty. Each section has an Edit button calling `onEdit(step)`. Action buttons live in `AddRabbit`'s `StickyActionBar`, not here. |
| `src/components/StickyActionBar.jsx` | Bottom-pinned form action row (sticky above the mobile bottom nav, `bottom-0` on desktop). Used by the Add Rabbit flow for Back/Next/Save. |
| `src/components/PedigreeScan.jsx` | Step 1 "Scan a pedigree" card (shown only when the server reports scanning enabled). Downscales the photo (`lib/imageResize.js`), posts it, shows a 20–60s progress state, then a review card: herd links, a "Double-check these" list built from each slot's `uncertain` fields, model notes, "Scan again", and a collapsible view of the photo (object URL only). |
| `src/lib/imageResize.js` | `downscaleImage(file, maxLongEdge=2400)` → JPEG Blob via `createImageBitmap(..., {imageOrientation: "from-image"})` + canvas. |
| `src/lib/pedigree.js` (scan helpers) | `pedigreeStateFromScan(result)` → PedigreeTree state (matched sire/dam `linked`, other named ancestors `manual` JSON) and `uncertainScanSlots(result)` → Set of slots to highlight. `AddRabbit` remounts the tree with `key={pedigreeKey}` + `initialState={pedigreeSeed}` on each scan, merges the subject into `details` (non-null values only), and passes `uncertainSlots`; `PedigreeTree` shows a "Check" badge on each until the user edits that slot. |
| `src/pages/AddRabbit.jsx` (flow notes) | The current step is a `?step=` URL param (pushed, so the Back gesture moves between steps; step 3 falls back to 2 without a name). All steps stay mounted and are hidden with CSS so `PedigreeTree`'s internal state survives going back / Review "Edit". Step 1 has an optional name field feeding the tree's root label. A `beforeunload` guard warns on reload/close with unsaved input (in-app link navigation is not guarded — `BrowserRouter` has no `useBlocker`). On save, a failed photo upload still navigates to the created rabbit with a `location.state.notice` (shown by `RabbitDetail`) instead of leaving the user on Review, where re-saving would create a duplicate. |
| `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-v2`) 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. The cache name was bumped v1→v2 when the vendored Tesseract assets were removed (2026-10-28) so `activate` deletes installed clients' old ~16 MB precache. |
| `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.
- **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.
- **Deleting a rabbit** (`DELETE /api/rabbits/:id`, Delete button on Rabbit Detail): refused with 409 while it has show entries (history the user would lose; Archive is the alternative). Otherwise, in one transaction: offspring get a manual-ancestor JSON snapshot of it and the two generations above (full name/farm/ear #/weight/DOB, so it can be re-matched later), litters null that parent id, and its `rabbit_events` are deleted — SQLite FK enforcement is off, so nothing cascades by itself.
- **Cohesion pass (2026-10-30)** — app-wide UX consistency decisions to preserve:
- **Litter lifecycle**: `/litters` lists "Expecting" (no kindle date) and "Kindled" sections; whole `LitterCard` links to the litter. Litter Detail carries Record Kindle (→ `/litters/:id/edit?kindle=1`, which requires the kindle date and creates kits) or Add Kit, plus Edit and Delete. `EditLitter` edits an unkindled litter as a breeding (`mode="breeding"`, no kindle date required) and a kindled one as a litter; `LitterForm`'s `createsKits` flag picks the kit-count hint. Gestation/wean constants + a timezone-safe `addDays` live in `lib/format.js` (`GESTATION_DAYS` 31, `WEAN_DAYS` 56). "Record Breeding" is offered everywhere "Record Litter" is (Dashboard, Litters, Rabbit Detail).
- **Rabbit Detail actions**: Edit · Record Breeding · Record Litter (bucks/does only) · Print Pedigree · a **Status select** (all six statuses). The old "Mark For Sale" toggle and "Archive" button (which silently set `deceased`) are gone; Delete sits at the bottom of the page. Pedigree and Litters have section headings. `PedigreeTreeReadOnly` switches to a stacked Sire/Dam layout whenever its container is narrower than the bracket layout (`useElementWidth`), so it never scrolls sideways.
- **Edit Rabbit** has a **Parents** section (two `RabbitAutocomplete`s) that re-links `sire_id`/`dam_id`; changing a parent also nulls that side's 7 `*_name_text` columns (the old line's freeform ancestors), since the new parent's own record now supplies them. A typed-in (unlinked) parent is shown as a hint until replaced. Deeper per-slot editing of freeform ancestors is still Add-flow only. Uses `StickyActionBar` like Add.
- **Timeline ↔ Shows**: entries made under Shows → My Events appear on the rabbit timeline as derived `show` rows (club, class, placement/legs/BOB…) linking to the event; the manual "Show" event type remains for shows tracked elsewhere.
- **Settings** is split into a shared **Rabbitry** card (name, kit prefix, default breed) and a per-user **Your Preferences** card (weight display, theme), each saving only its own keys. Export is labelled **Back Up** and states that Import can't read it (Import = CSV/JSON herd replacement).
- **Genetics is hidden from the sidebar and mobile More menu** until the calculator is built; the `/genetics` placeholder route still exists. Grandparents are corrected by editing the parent rabbit (Edit Rabbit only re-links sire/dam), by user decision.
- Herd's sex tab for `sex='unknown'` is labelled **Unknown** (was "Other", which collided with the `other` status).
- **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.