MyKindlebox/memory-bank/architecture.md

64 KiB
Raw Blame History

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 NavLinks 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 (Buttons 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 AncestorSlots 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 RabbitAutocompletes) 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.