diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md
index 219553b..3bb01fa 100644
--- a/memory-bank/architecture.md
+++ b/memory-bank/architecture.md
@@ -26,11 +26,11 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `package.json` scripts | `dev` — `tsx watch index.ts` (hot-reload local dev, no separate build step needed). `build` — `tsc` compiles to `dist/`. `start` — runs the compiled output, used in production/Docker. |
| `Dockerfile` | Builds a Node 20 Alpine image, installs deps, runs `npm run dev` (tsx) inside the container — not yet switched to a production build for the container; revisit when deploying for real. |
| `.dockerignore` | Excludes `node_modules` and `data/` from the Docker build context. |
-| `routes/rabbits.ts` | Express `Router` mounted at `/api/rabbits` in `index.ts`. `GET /` (list, with `sex`/`status`/`search`/`sort` filters — `sex=kits` is a special case matching any sex with `dob` in the last 56 days), `GET /search` (autocomplete by *substring* on name only, must be registered before `GET /:id`), `GET /quick-search` (the global top-of-page quick-search bar — distinct from `/search`: a **prefix** match — "wildcard at the end" — across a chooseable subset of `farm`/`name`/`ear_number`/`color` via `?fields=` comma-list, defaulting to all four; returns each hit plus a `matched_fields` array re-derived in JS so the dropdown can label which filter the row flagged on. Empty `q` or no valid fields → `[]`. Also must be registered before `GET /:id`), `GET /:id` (404 if missing), `POST /` (validates `name`, 201 on success), `PATCH /:id` (partial update, refreshes `updated_at`), `DELETE /:id` (nulls referencing `sire_id`/`dam_id` before deleting), `GET /:id/ancestors` (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries `id`/`name`/`farm`/`sex`/`ear_number`/`weight_lbs`/`dob`/`color_name` — `ear_number`/`weight_lbs`/`dob` were added so the printable pedigree slip can show full per-ancestor detail), `POST /:id/photo` (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so `fileFilter` rejections become this route's own 400 rather than the global error handler's 500). |
-| `routes/litters.ts` | Express `Router` mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice (via `alias()`) to embed sire/dam `{ id, name }`. `GET /:id` derives its `kits` list from `rabbits` matching `sire_id`/`dam_id`/`dob` (the same no-Kit-table derivation the design doc specifies elsewhere). `POST /` (requires a `breed_date` or `kindle_date`; only generates kits when a `kindle_date` is present), `PATCH /:id`, `DELETE /:id`. Recording a breeding (`POST` with `breed_date`, no `kindle_date`) creates a litter-in-progress with no kits yet. |
+| `routes/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. 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). |
+| `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 `
` cells** (the cells carry no column classes — only the `` 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')`. |
@@ -48,9 +48,11 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|---|---|
| `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 (` }>`) 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 ` `. 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. `` 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 ` ` at the top of the `` content area (above ` `), so the quick-search bar appears on every page. |
-| `src/components/QuickSearch.jsx` | Global quick-search bar (top of every page via `Layout`). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), **all selected by default**, controlling which fields `GET /api/rabbits/quick-search` prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per `matched_fields` entry (value-bearing chips for ear/color, e.g. `Ear #: A15`). Selecting a result navigates to `/herd/:id`. Reuses `lib/keyboardNav.js`'s `handleSearchInputKeyDown`/`handleResultKeyDown` for the same `` roving-focus dropdown shape as `RabbitAutocomplete`/`PedigreeTree`. The debounce effect deliberately does **not** call `setState` synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show. |
-| `src/components/Button.jsx` | `variant` (`primary`/`secondary`/`danger`) × `size` (`sm`/`md`) class-map component. |
+| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's ` `. 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. `` 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 ` ` at the top of the `` content area (above ` `) 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 `` 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 ` ` with the same classes — **use `` for navigation, never ``** (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 ` `. |
@@ -116,4 +118,12 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
- **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 `` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-``, secretary, email, catalog-` `). 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.
diff --git a/mykindlebox/client/src/api.js b/mykindlebox/client/src/api.js
index 7cc6733..d21ec80 100644
--- a/mykindlebox/client/src/api.js
+++ b/mykindlebox/client/src/api.js
@@ -105,6 +105,9 @@ export const refreshArbaShows = () => request('/shows/arba/refresh', { method: '
// Show events — user-created groupings of ARBA shows. A `show` added to an event
// is a snapshot of the ARBA listing row (see routes/showEvents.ts).
export const getShowEvents = () => request('/shows/events')
+// One rabbit's entries across every event, with placements (rabbit timeline).
+export const getRabbitShowEntries = (rabbitId) =>
+ request(withQuery('/shows/events/entries', { rabbit_id: rabbitId }))
export const getShowEvent = (id) => request(`/shows/events/${id}`)
export const createShowEvent = (data) =>
request('/shows/events', { method: 'POST', body: JSON.stringify(data) })
diff --git a/mykindlebox/client/src/components/AddToEventModal.jsx b/mykindlebox/client/src/components/AddToEventModal.jsx
index 4c5dd60..48f72dd 100644
--- a/mykindlebox/client/src/components/AddToEventModal.jsx
+++ b/mykindlebox/client/src/components/AddToEventModal.jsx
@@ -1,5 +1,4 @@
import { useState } from 'react'
-import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getShowEvents, createShowEvent, addShowToEvent } from '../api'
import Button from './Button'
@@ -65,9 +64,9 @@ function AddToEventModal({ show, onClose }) {
Close
-
- View Event
-
+
+ View Event
+
) : (
diff --git a/mykindlebox/client/src/components/Button.jsx b/mykindlebox/client/src/components/Button.jsx
index 001c633..5f05457 100644
--- a/mykindlebox/client/src/components/Button.jsx
+++ b/mykindlebox/client/src/components/Button.jsx
@@ -1,3 +1,5 @@
+import { Link } from 'react-router-dom'
+
const VARIANT_CLASSES = {
primary: 'bg-accent text-surface hover:bg-accent-hover',
secondary: 'border border-border bg-transparent text-text-primary hover:bg-surface-sunken',
@@ -11,13 +13,13 @@ const SIZE_CLASSES = {
md: 'min-h-[44px] px-4 py-2 text-base md:min-h-[40px]',
}
-function Button({ variant = 'primary', size = 'md', className = '', ...props }) {
- return (
-
- )
+// With `to`, renders a router styled as a button — use this for
+// navigation instead of wrapping a in a (a button nested in a
+// link is invalid HTML and adds a second tab stop).
+function Button({ variant = 'primary', size = 'md', className = '', to, ...props }) {
+ const classes = `inline-flex items-center justify-center rounded-md font-body font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`
+ if (to) return
+ return
}
export default Button
diff --git a/mykindlebox/client/src/components/Layout.jsx b/mykindlebox/client/src/components/Layout.jsx
index 126a60c..e1713cd 100644
--- a/mykindlebox/client/src/components/Layout.jsx
+++ b/mykindlebox/client/src/components/Layout.jsx
@@ -71,7 +71,8 @@ const NAV_ITEMS = [
{ to: '/', label: 'Dashboard', icon: 'dashboard', end: true },
{ to: '/herd', label: 'Herd', icon: 'herd' },
{ to: '/litters', label: 'Litters', icon: 'litters' },
- { to: '/genetics', label: 'Genetics', icon: 'genetics' },
+ // Genetics is hidden until the pairing calculator is built (the /genetics
+ // route and its icon remain); add it back here and to MORE_ITEMS then.
{ to: '/shows', label: 'Shows', icon: 'shows' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
]
@@ -79,7 +80,7 @@ const NAV_ITEMS = [
// Mobile has room for five tabs. Dashboard/Herd/Add/Litters are the barn-side
// essentials; everything else lives behind "More" so no page is unreachable on
// a phone (Shows and Genetics previously had no mobile entry point at all).
-const MORE_ITEMS = NAV_ITEMS.filter((item) => ['/genetics', '/shows', '/settings'].includes(item.to))
+const MORE_ITEMS = NAV_ITEMS.filter((item) => ['/shows', '/settings'].includes(item.to))
const navLinkClass = ({ isActive }) =>
`flex items-center gap-3 rounded-md px-3 py-2 font-body text-sm ${
@@ -178,6 +179,16 @@ function MoreMenu() {
)
}
+// The global quick search is left off the Herd list (which has its own search
+// box) and off entry forms, where jumping to a search result would silently
+// discard the unsaved form.
+function showsQuickSearch(pathname) {
+ if (pathname === '/herd') return false
+ if (/^\/(herd|litters|breedings)\/add$/.test(pathname)) return false
+ if (/\/edit$/.test(pathname)) return false
+ return true
+}
+
function Layout() {
const { pathname } = useLocation()
// Herd tab covers the herd list and rabbit pages, but not /herd/add, which
@@ -204,7 +215,7 @@ function Layout() {
MyKindlebox
-
+ {showsQuickSearch(pathname) && }
diff --git a/mykindlebox/client/src/components/LitterCard.jsx b/mykindlebox/client/src/components/LitterCard.jsx
index 2187af9..623ad66 100644
--- a/mykindlebox/client/src/components/LitterCard.jsx
+++ b/mykindlebox/client/src/components/LitterCard.jsx
@@ -1,8 +1,8 @@
import { Link } from 'react-router-dom'
import Card from './Card'
-import Button from './Button'
-import { formatDate } from '../lib/format'
+import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
+// The whole card links to the litter; editing lives on the litter page.
function LitterCard({ litter }) {
const sireName = litter.sire?.name ?? 'Unknown'
const damName = litter.dam?.name ?? 'Unknown'
@@ -10,32 +10,25 @@ function LitterCard({ litter }) {
const kindled = Boolean(litter.kindle_date)
// Once kindled, show the kindle date + kit count; an in-progress breeding shows
- // its breeding date instead so it doesn't render a blank date line.
+ // when it was bred and when it's due instead.
const meta = kindled
- ? `${formatDate(litter.kindle_date)} • ${kitCount} ${kitCount === 1 ? 'kit' : 'kits'}`
- : `Bred ${formatDate(litter.breed_date)} • Not yet kindled`
+ ? `Kindled ${formatDate(litter.kindle_date)} • ${kitCount} ${kitCount === 1 ? 'kit' : 'kits'}`
+ : `Bred ${formatDate(litter.breed_date)} • Due ${formatDate(addDays(litter.breed_date, GESTATION_DAYS))}`
return (
-
-
-
- {sireName} × {damName}
-
-
{meta}
-
-
-
-
- {kindled ? 'View Kits' : 'View'}
-
-
-
-
- Edit
-
-
-
-
+
+
+
+
+ {sireName} × {damName}
+
+
{meta}
+
+
+ ›
+
+
+
)
}
diff --git a/mykindlebox/client/src/components/LitterForm.jsx b/mykindlebox/client/src/components/LitterForm.jsx
index a4918f3..226538d 100644
--- a/mykindlebox/client/src/components/LitterForm.jsx
+++ b/mykindlebox/client/src/components/LitterForm.jsx
@@ -16,6 +16,9 @@ function LitterForm({
error,
submitLabel = 'Save Litter',
mode = 'litter',
+ // False when editing an already-kindled litter: its kits exist, and the
+ // count is just a number from then on.
+ createsKits = true,
}) {
const isBreeding = mode === 'breeding'
const [sire, setSire] = useState(initialValues.sire ?? null)
@@ -111,6 +114,11 @@ function LitterForm({
onChange={(e) => setKitCount(e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
+
+ {createsKits
+ ? 'Saving creates this many kits in your herd, named with the Kit Prefix from Settings. Leave blank to add kits one at a time.'
+ : 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'}
+
>
)}
diff --git a/mykindlebox/client/src/components/LoadError.jsx b/mykindlebox/client/src/components/LoadError.jsx
new file mode 100644
index 0000000..dc51b9a
--- /dev/null
+++ b/mykindlebox/client/src/components/LoadError.jsx
@@ -0,0 +1,29 @@
+import Button from './Button'
+import Card from './Card'
+import EmptyState from './EmptyState'
+import PageHeader from './PageHeader'
+
+// The failed-fetch state for a page whose main record didn't load — most often
+// a stale link to a rabbit or litter that has since been deleted. Without it a
+// page would sit on "Loading…" forever.
+function LoadError({ title, error, backTo, backLabel }) {
+ const notFound = /not found/i.test(error?.message ?? '')
+ return (
+
+
+
+ {backLabel} : null}
+ />
+
+
+ )
+}
+
+export default LoadError
diff --git a/mykindlebox/client/src/components/PedigreeTreeReadOnly.jsx b/mykindlebox/client/src/components/PedigreeTreeReadOnly.jsx
index 287d85b..b5a3770 100644
--- a/mykindlebox/client/src/components/PedigreeTreeReadOnly.jsx
+++ b/mykindlebox/client/src/components/PedigreeTreeReadOnly.jsx
@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom'
import { GEN1, GEN2, GEN3, getAncestorAtPath, formatManualAncestorDisplay } from '../lib/pedigree'
+import { useElementWidth } from '../hooks/useElementWidth'
+
+const LABEL_BY_PATH = new Map([...GEN1, ...GEN2, ...GEN3].map((entry) => [entry.path.join('.'), entry.label]))
function AncestorNodeView({ path, label, ancestors }) {
const node = getAncestorAtPath(ancestors, path)
@@ -51,6 +54,22 @@ function Generation({ entries, ancestors }) {
)
}
+// Phone layout: each parent with its ancestors indented beneath it — the same
+// shape PedigreeTree uses below its wide breakpoint, so nothing scrolls sideways.
+function StackedBranch({ path, ancestors, depth }) {
+ return (
+
+
+ {path.length < depth && (
+
+
+
+
+ )}
+
+ )
+}
+
// `generations` controls how many ancestor columns to render. The detail page
// uses 2 (parents + grandparents) to keep the inline tree compact; the full
// 3-generation tree lives on the dedicated print page.
@@ -58,20 +77,28 @@ function PedigreeTreeReadOnly({ subjectName, ancestors, generations = 3 }) {
// Each generation column is ~176px (incl. gap); narrower min-width for fewer
// generations lets the tree fit its column without horizontal scrolling.
const minWidth = generations >= 3 ? 720 : 544
+ const [ref, width] = useElementWidth()
+ const wide = width >= minWidth
return (
- // The scroll container fills (and shrinks with) its column; the tree scrolls
- // horizontally inside it rather than forcing the page wider and pushing the
- // timeline column off-screen.
-
-
-
-
{subjectName}
+ // The scroll container fills (and shrinks with) its column. When the column
+ // is too narrow for the bracket layout, the stacked layout is used instead.
+
+ {wide ? (
+
+
+ {subjectName}
+
+
+ {generations >= 2 &&
}
+ {generations >= 3 &&
}
-
- {generations >= 2 &&
}
- {generations >= 3 &&
}
-
+ ) : (
+
+
+
+
+ )}
)
}
diff --git a/mykindlebox/client/src/components/QuickSearch.jsx b/mykindlebox/client/src/components/QuickSearch.jsx
index 034bbf1..e697b73 100644
--- a/mykindlebox/client/src/components/QuickSearch.jsx
+++ b/mykindlebox/client/src/components/QuickSearch.jsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { quickSearchRabbits } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
+import Badge from './Badge'
// The fields the quick-search can target. Keys match the backend's
// `?fields=` values and the `matched_fields` it returns; `label` is the
@@ -194,6 +195,13 @@ function QuickSearch() {
· {result.farm}
) : null}
+ {/* Ancestors added from pedigrees are status Other — flag
+ anything outside the active herd. */}
+ {result.status && result.status !== 'active' && (
+
+
+
+ )}
{(result.matched_fields ?? []).map((field) => {
const value = matchedValue(result, field)
diff --git a/mykindlebox/client/src/components/RabbitAutocomplete.jsx b/mykindlebox/client/src/components/RabbitAutocomplete.jsx
index 2429313..0d44e0a 100644
--- a/mykindlebox/client/src/components/RabbitAutocomplete.jsx
+++ b/mykindlebox/client/src/components/RabbitAutocomplete.jsx
@@ -2,6 +2,15 @@ import { useEffect, useRef, useState } from 'react'
import { searchRabbits } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
+// Popup height: input + results list (max-h-40) + Mark as Unknown.
+const POPUP_HEIGHT = 260
+
+// Viewport space at the bottom covered by the sticky action bar (plus the
+// bottom nav on phones) — same allowance as PedigreeTree's popups.
+function bottomChromeHeight() {
+ return window.matchMedia('(max-width: 767px)').matches ? 136 : 72
+}
+
// Controlled sire/dam picker for the litter form. `value` is `{ id, name }`
// or `null` (Unknown) — mirrors PedigreeTree's search dropdown, but as a
// standalone reusable field rather than embedded in a pedigree slot.
@@ -9,6 +18,9 @@ function RabbitAutocomplete({ sex, value, onChange, placeholder = 'Search by nam
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
+ // Opens upward when there isn't room above the sticky bar, so the popup is
+ // neither hidden behind it nor stretching the page past its content.
+ const [dropUp, setDropUp] = useState(false)
const containerRef = useRef(null)
const inputRef = useRef(null)
const triggerRef = useRef(null)
@@ -48,14 +60,25 @@ function RabbitAutocomplete({ sex, value, onChange, placeholder = 'Search by nam
setOpen((v) => !v)}
+ onClick={() => {
+ if (!open) {
+ const rect = triggerRef.current.getBoundingClientRect()
+ const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom
+ setDropUp(spaceBelow < POPUP_HEIGHT + 8 && rect.top > spaceBelow)
+ }
+ setOpen((v) => !v)
+ }}
className="w-full rounded border border-border bg-surface-raised px-2 py-1.5 text-left font-body text-sm text-text-primary hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
>
{value ? value.name : 'Unknown'}
{open && (
-
+
0) parts.push(`${placement.legs_earned} leg${placement.legs_earned === 1 ? '' : 's'}`)
+ for (const flag of ['bob', 'bos', 'bis', 'ris']) if (placement[flag]) parts.push(flag.toUpperCase())
+ return parts.filter(Boolean).join(' • ') || null
+}
+
+// Builds the chronological event list from the rabbit record, its litters,
+// its show entries and its logged events. Births/breedings/show entries are
+// derived (no storage of their own here); acquired/death come off the rabbit
+// row; show/sale/other are logged event records.
+function buildEvents(rabbit, litters, showEntries, logged) {
const events = []
if (rabbit.dob) {
@@ -81,6 +91,20 @@ function buildEvents(rabbit, litters, logged) {
}
}
+ // Entries made through Shows → My Events. Undated shows can't be placed on
+ // the timeline, so they're skipped.
+ for (const entry of showEntries) {
+ if (!entry.show_date) continue
+ events.push({
+ key: `entry-${entry.entry_id}`,
+ type: 'show',
+ date: entry.show_date,
+ title: entry.club_name,
+ detail: [entry.entry_class, placementSummary(entry.placement)].filter(Boolean).join(' • ') || null,
+ to: `/shows/events/${entry.event_id}`,
+ })
+ }
+
for (const ev of logged) {
// Class/placement only apply to shows; notes apply to everything.
const showResult = ev.type === 'show' ? [ev.show_class, ev.placement].filter(Boolean).join(' • ') : ''
@@ -212,6 +236,13 @@ function EventForm({ editingEvent, onSave, onCancel, saving }) {
/>
+ {isShow && !editingEvent && (
+
+ Shows you enter this rabbit in under Shows → My Events appear here automatically. Use this for shows
+ tracked elsewhere.
+
+ )}
+
{form.type === 'sale' && (
Saving a sale also marks this rabbit as Sold.
@@ -295,11 +326,15 @@ function RabbitTimeline({ rabbit }) {
queryKey: ['events', rabbit.id],
queryFn: () => getEvents(rabbit.id),
})
+ const { data: showEntries } = useQuery({
+ queryKey: ['show-entries', rabbit.id],
+ queryFn: () => getRabbitShowEntries(rabbit.id),
+ })
const rabbitLitters = (litters ?? []).filter(
(litter) => litter.sire?.id === rabbit.id || litter.dam?.id === rabbit.id
)
- const events = buildEvents(rabbit, rabbitLitters, logged ?? [])
+ const events = buildEvents(rabbit, rabbitLitters, showEntries ?? [], logged ?? [])
async function handleSave(form) {
setSaving(true)
diff --git a/mykindlebox/client/src/hooks/useElementWidth.js b/mykindlebox/client/src/hooks/useElementWidth.js
new file mode 100644
index 0000000..af07c13
--- /dev/null
+++ b/mykindlebox/client/src/hooks/useElementWidth.js
@@ -0,0 +1,19 @@
+import { useEffect, useRef, useState } from 'react'
+
+// Live width of an element, for layouts that depend on the space a component
+// actually gets (e.g. a column) rather than the viewport. Returns [ref, width];
+// width is 0 until the first measurement.
+export function useElementWidth() {
+ const ref = useRef(null)
+ const [width, setWidth] = useState(0)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ return [ref, width]
+}
diff --git a/mykindlebox/client/src/lib/format.js b/mykindlebox/client/src/lib/format.js
index c46662c..ea9f34c 100644
--- a/mykindlebox/client/src/lib/format.js
+++ b/mykindlebox/client/src/lib/format.js
@@ -62,3 +62,13 @@ export function formatDate(isoDate) {
year: 'numeric',
})
}
+
+export function addDays(isoDate, days) {
+ const date = new Date(`${isoDate}T00:00:00`)
+ date.setDate(date.getDate() + days)
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+}
+
+// Rabbit gestation is ~31 days; kits wean at ~8 weeks.
+export const GESTATION_DAYS = 31
+export const WEAN_DAYS = 56
diff --git a/mykindlebox/client/src/pages/Dashboard.jsx b/mykindlebox/client/src/pages/Dashboard.jsx
index 2894d1a..7826bb0 100644
--- a/mykindlebox/client/src/pages/Dashboard.jsx
+++ b/mykindlebox/client/src/pages/Dashboard.jsx
@@ -5,6 +5,7 @@ import Card from '../components/Card'
import Button from '../components/Button'
import EmptyState from '../components/EmptyState'
import PageHeader from '../components/PageHeader'
+import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
const BIRTHDAY_WINDOW_DAYS = 30
@@ -24,14 +25,6 @@ const STAT_CARDS = [
{ key: 'archived', label: 'Archived', href: '/herd?status=archived', tile: 'bg-[var(--stat-8)] hover:bg-[var(--stat-8-hover)]' },
]
-function formatDate(isoDate) {
- return new Date(`${isoDate}T00:00:00`).toLocaleDateString('en-US', {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- })
-}
-
function StatsBar() {
const { data, isLoading, isError } = useQuery({ queryKey: ['stats'], queryFn: getStats })
@@ -73,15 +66,10 @@ function StatsBar() {
function QuickActions() {
return (
-
- Add Rabbit
-
-
- Record Litter
-
-
- View Herd
-
+ Add Rabbit
+ Record Breeding
+ Record Litter
+ View Herd
)
}
@@ -91,16 +79,15 @@ function RecentLitters() {
if (isLoading || isError) return null
- const recent = [...data]
- .sort((a, b) => new Date(b.kindle_date) - new Date(a.kindle_date))
- .slice(0, 3)
+ // The API already returns newest first (by kindle date, else breeding date).
+ const recent = data.slice(0, 3)
if (recent.length === 0) {
return (
)
@@ -119,11 +106,15 @@ function RecentLitters() {
{litter.sire?.name ?? 'Unknown'} × {litter.dam?.name ?? 'Unknown'}
- {formatDate(litter.kindle_date)}
+ {litter.kindle_date
+ ? formatDate(litter.kindle_date)
+ : `Due ${formatDate(addDays(litter.breed_date, GESTATION_DAYS))}`}
- {litter.kit_count} {litter.kit_count === 1 ? 'kit' : 'kits'}
+ {litter.kindle_date
+ ? `${litter.kit_count ?? 0} ${litter.kit_count === 1 ? 'kit' : 'kits'}`
+ : 'Expecting'}
))}
diff --git a/mykindlebox/client/src/pages/EditLitter.jsx b/mykindlebox/client/src/pages/EditLitter.jsx
index ff38ff5..00ed4b4 100644
--- a/mykindlebox/client/src/pages/EditLitter.jsx
+++ b/mykindlebox/client/src/pages/EditLitter.jsx
@@ -1,31 +1,47 @@
import { useState } from 'react'
-import { useNavigate, useParams } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import LitterForm from '../components/LitterForm'
+import LoadError from '../components/LoadError'
import { getLitter, updateLitter } from '../api'
+// Edits a litter in whichever stage it's in. A breeding that hasn't kindled is
+// edited as a breeding (kindle date not required); `?kindle=1` (the litter
+// page's "Record Kindle" button) asks for the kindle date and kit count, which
+// creates the kits server-side.
function EditLitter() {
const { id } = useParams()
+ const [searchParams] = useSearchParams()
const navigate = useNavigate()
+ const queryClient = useQueryClient()
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
- const { data: litter, isLoading } = useQuery({
+ const { data: litter, isLoading, isError, error: loadError } = useQuery({
queryKey: ['litter', id],
queryFn: () => getLitter(id),
})
+ if (isError) {
+ return
+ }
if (isLoading || !litter) {
return
}
+ const kindled = Boolean(litter.kindle_date)
+ const recordingKindle = !kindled && searchParams.get('kindle') === '1'
+ const title = recordingKindle ? 'Record Kindle' : kindled ? 'Edit Litter' : 'Edit Breeding'
+
async function handleSubmit(payload) {
setSaving(true)
setError(null)
try {
await updateLitter(id, payload)
- navigate(`/litters/${id}`)
+ // Kindling creates kits and edits can move them, so herd views are stale too.
+ queryClient.invalidateQueries()
+ navigate(`/litters/${id}`, { replace: true })
} catch (err) {
setError(err.message)
setSaving(false)
@@ -34,9 +50,11 @@ function EditLitter() {
return (
-
+
navigate(`/litters/${id}`)}
saving={saving}
error={error}
- submitLabel="Save Changes"
+ submitLabel={recordingKindle ? 'Save Kindle' : 'Save Changes'}
/>
diff --git a/mykindlebox/client/src/pages/EditRabbit.jsx b/mykindlebox/client/src/pages/EditRabbit.jsx
index 4c1d2e2..162ab7d 100644
--- a/mykindlebox/client/src/pages/EditRabbit.jsx
+++ b/mykindlebox/client/src/pages/EditRabbit.jsx
@@ -1,12 +1,16 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import RabbitDetailsForm from '../components/RabbitDetailsForm'
import GeneticsForm from '../components/GeneticsForm'
+import RabbitAutocomplete from '../components/RabbitAutocomplete'
+import StickyActionBar from '../components/StickyActionBar'
+import LoadError from '../components/LoadError'
import Button from '../components/Button'
-import { getRabbit, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
+import { getRabbit, getRabbitAncestors, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
import { LOCI, hasAnyLocusSet } from '../lib/genetics'
+import { GEN1, GEN2, GEN3, formatManualAncestorDisplay } from '../lib/pedigree'
const DETAIL_KEYS = [
'name',
@@ -24,20 +28,58 @@ const DETAIL_KEYS = [
]
const GENETICS_KEYS = LOCI.map((locus) => locus.key)
+const NAME_INPUT_ID = 'edit-rabbit-name'
+
+// The freeform ancestor columns on one side of the pedigree (e.g. every slot
+// whose path starts with 'sire'). Once that parent changes, those columns
+// describe the old parent's line, so they're cleared — the new parent's own
+// record supplies the ancestors from then on (chain authority).
+function branchTextColumns(side) {
+ return [...GEN1, ...GEN2, ...GEN3]
+ .filter((entry) => entry.path[0] === side)
+ .map((entry) => `${entry.slot}_name_text`)
+}
+
+// Sire/dam can be re-linked to herd records here; deeper ancestors follow from
+// whichever rabbits are picked. A parent that was only typed in (no herd
+// record) is shown so the user knows what's there before replacing it.
+function ParentField({ label, sex, value, onChange, manualName }) {
+ return (
+
+
{label}
+
+
+
+ {!value && manualName && (
+
+ Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record.
+
+ )}
+
+ )
+}
function EditRabbit() {
const { id } = useParams()
const navigate = useNavigate()
+ const queryClient = useQueryClient()
const [details, setDetails] = useState(null)
const [genetics, setGenetics] = useState(null)
+ const [parents, setParents] = useState(null)
const [photoFile, setPhotoFile] = useState(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
+ const [triedSave, setTriedSave] = useState(false)
- const { data: rabbit, isLoading: rabbitLoading } = useQuery({
+ const { data: rabbit, isError, error: loadError } = useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
})
+ const { data: ancestors } = useQuery({
+ queryKey: ['rabbit', id, 'ancestors'],
+ queryFn: () => getRabbitAncestors(id),
+ enabled: Boolean(rabbit),
+ })
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
if (rabbit && details === null) {
@@ -50,7 +92,16 @@ function EditRabbit() {
setGenetics(initialGenetics)
}
- if (rabbitLoading || !rabbit || details === null || genetics === null) {
+ // Parent names come from the ancestors tree (the rabbit row only has ids).
+ if (ancestors && parents === null) {
+ const linked = (node) => (node?.id != null ? { id: node.id, name: node.name } : null)
+ setParents({ sire: linked(ancestors.sire), dam: linked(ancestors.dam) })
+ }
+
+ if (isError) {
+ return
+ }
+ if (details === null || genetics === null || parents === null) {
return
}
@@ -63,6 +114,16 @@ function EditRabbit() {
}
async function handleSave() {
+ if (!details.name?.trim()) {
+ setTriedSave(true)
+ document.getElementById(NAME_INPUT_ID)?.focus()
+ return
+ }
+ if (parents.sire?.id === rabbit.id || parents.dam?.id === rabbit.id) {
+ setError('A rabbit can’t be its own parent.')
+ return
+ }
+
setSaving(true)
setError(null)
@@ -73,6 +134,13 @@ function EditRabbit() {
for (const key of GENETICS_KEYS) {
if (genetics[key] !== rabbit[key]) changed[key] = genetics[key]
}
+ for (const side of ['sire', 'dam']) {
+ const newId = parents[side]?.id ?? null
+ if (newId !== rabbit[`${side}_id`]) {
+ changed[`${side}_id`] = newId
+ for (const column of branchTextColumns(side)) changed[column] = null
+ }
+ }
try {
if (Object.keys(changed).length > 0) {
@@ -81,6 +149,9 @@ function EditRabbit() {
if (photoFile) {
await uploadRabbitPhoto(id, photoFile)
}
+ queryClient.invalidateQueries({ queryKey: ['rabbit', id] })
+ queryClient.invalidateQueries({ queryKey: ['rabbits'] })
+ queryClient.invalidateQueries({ queryKey: ['stats'] })
navigate(`/herd/${id}`)
} catch (err) {
setError(err.message)
@@ -98,8 +169,33 @@ function EditRabbit() {
onChange={updateDetails}
onPhotoChange={setPhotoFile}
weightUnit={settings?.weight_unit}
+ showValidation={triedSave}
+ nameInputId={NAME_INPUT_ID}
/>
+
+
Parents
+
+ Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
+
+
+
setParents((prev) => ({ ...prev, sire }))}
+ manualName={rabbit.sire_id == null ? ancestors?.sire?.name : null}
+ />
+ setParents((prev) => ({ ...prev, dam }))}
+ manualName={rabbit.dam_id == null ? ancestors?.dam?.name : null}
+ />
+
+
+
updateDetails({ color_name: color })}
/>
- {error && {error}
}
-
-
- navigate(`/herd/${id}`)} disabled={saving}>
- Cancel
-
-
- {saving ? 'Saving…' : 'Save Changes'}
-
-
+ {error && (
+
+ {error}
+
+ )}
+
+
+ navigate(`/herd/${id}`)} disabled={saving}>
+ Cancel
+
+
+ {saving ? 'Saving…' : 'Save Changes'}
+
+
)
}
diff --git a/mykindlebox/client/src/pages/Herd.jsx b/mykindlebox/client/src/pages/Herd.jsx
index 2068e2a..e7cb867 100644
--- a/mykindlebox/client/src/pages/Herd.jsx
+++ b/mykindlebox/client/src/pages/Herd.jsx
@@ -16,7 +16,9 @@ const SEX_TABS = [
{ value: 'buck', label: 'Bucks' },
{ value: 'doe', label: 'Does' },
{ value: 'kits', label: 'Kits' },
- { value: 'unknown', label: 'Other' },
+ // Sex not recorded yet — named 'Unknown' so it isn't confused with the
+ // 'Other' status (which the Status filter groups under Archived).
+ { value: 'unknown', label: 'Unknown' },
]
const SORT_OPTIONS = [
@@ -90,9 +92,9 @@ function SearchBox({ q, onSearchChange }) {
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
- placeholder="Search by name"
- aria-label="Search herd by name"
- className="w-full min-w-0 bg-transparent md:w-40 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
+ placeholder="Name, ear #, color"
+ aria-label="Search herd by name, ear number or color"
+ className="w-full min-w-0 bg-transparent md:w-48 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
)
@@ -303,9 +305,7 @@ function Herd() {
- Add Rabbit
-
+ Add Rabbit
}
/>
@@ -332,9 +332,7 @@ function Herd() {
title="No rabbits found"
description="Try adjusting your filters or add a new rabbit to get started."
action={
-
- Add Rabbit
-
+ Add Rabbit
}
/>
diff --git a/mykindlebox/client/src/pages/LitterDetail.jsx b/mykindlebox/client/src/pages/LitterDetail.jsx
index f5915d5..dadee4d 100644
--- a/mykindlebox/client/src/pages/LitterDetail.jsx
+++ b/mykindlebox/client/src/pages/LitterDetail.jsx
@@ -1,19 +1,15 @@
-import { Link, useParams } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
+import { useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Badge from '../components/Badge'
import Button from '../components/Button'
-import { getLitter } from '../api'
-import { SEX_LABELS, formatDate, formatWeight } from '../lib/format'
+import LoadError from '../components/LoadError'
+import { getLitter, deleteLitter } from '../api'
+import { GESTATION_DAYS, SEX_LABELS, WEAN_DAYS, addDays, formatDate, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
-function addDays(isoDate, days) {
- const date = new Date(`${isoDate}T00:00:00`)
- date.setDate(date.getDate() + days)
- return date.toISOString().slice(0, 10)
-}
-
function ParentLink({ parent }) {
if (!parent) return Unknown
return (
@@ -25,12 +21,19 @@ function ParentLink({ parent }) {
function LitterDetail() {
const { id } = useParams()
- const { data: litter, isLoading } = useQuery({
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const [deleting, setDeleting] = useState(false)
+ const [error, setError] = useState(null)
+ const { data: litter, isLoading, isError, error: loadError } = useQuery({
queryKey: ['litter', id],
queryFn: () => getLitter(id),
})
const weightUnit = useWeightUnit()
+ if (isError) {
+ return
+ }
if (isLoading || !litter) {
return
}
@@ -43,8 +46,23 @@ function LitterDetail() {
// ~31-day gestation to the expected kindle, then weaning ~56 days after kindle.
const kindled = Boolean(litter.kindle_date)
const expectedKindle =
- !kindled && litter.breed_date ? formatDate(addDays(litter.breed_date, 31)) : null
- const weanDate = kindled ? formatDate(addDays(litter.kindle_date, 56)) : null
+ !kindled && litter.breed_date ? formatDate(addDays(litter.breed_date, GESTATION_DAYS)) : null
+ const weanDate = kindled ? formatDate(addDays(litter.kindle_date, WEAN_DAYS)) : null
+
+ async function handleDelete() {
+ const kitNote = kits.length ? ` Its ${kits.length} kit${kits.length === 1 ? '' : 's'} stay in your herd.` : ''
+ if (!window.confirm(`Delete this ${kindled ? 'litter' : 'breeding'} record?${kitNote}`)) return
+ setDeleting(true)
+ setError(null)
+ try {
+ await deleteLitter(litter.id)
+ queryClient.invalidateQueries({ queryKey: ['litters'] })
+ navigate('/litters', { replace: true })
+ } catch (err) {
+ setError(err.message)
+ setDeleting(false)
+ }
+ }
const addKitParams = new URLSearchParams()
if (litter.sire?.id != null) addKitParams.set('sire', litter.sire.id)
@@ -63,19 +81,28 @@ function LitterDetail() {
: undefined
}
action={
- kindled ? (
-
- Add Kit
-
- ) : (
-
- Record Kindle
-
- )
+
+ {kindled ? (
+
+ Add Kit
+
+ ) : (
+
+ Record Kindle
+
+ )}
+
+ Edit
+
+
+ Delete
+
+
}
/>
+ {error &&
{error}
}
Sire
diff --git a/mykindlebox/client/src/pages/Litters.jsx b/mykindlebox/client/src/pages/Litters.jsx
index 9f14bd7..343c43b 100644
--- a/mykindlebox/client/src/pages/Litters.jsx
+++ b/mykindlebox/client/src/pages/Litters.jsx
@@ -1,44 +1,66 @@
-import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Button from '../components/Button'
+import Card from '../components/Card'
import EmptyState from '../components/EmptyState'
import LitterCard from '../components/LitterCard'
import { getLitters } from '../api'
+function LitterSection({ title, litters }) {
+ if (litters.length === 0) return null
+ return (
+
+
+ {title} ({litters.length})
+
+ {litters.map((litter) => (
+
+ ))}
+
+ )
+}
+
+// A litter row spans breeding → kindle, so breedings that haven't kindled yet
+// are listed apart from kindled litters.
function Litters() {
- const { data: litters, isLoading } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
+ const { data: litters, isLoading, isError } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
+
+ const expecting = (litters ?? []).filter((litter) => !litter.kindle_date)
+ const kindled = (litters ?? []).filter((litter) => litter.kindle_date)
return (
- Record Litter
-
+
+
+ Record Breeding
+
+ Record Litter
+
}
/>
-
- {isLoading ? null : litters.length === 0 ? (
+ {isError ? (
+
Failed to load litters.
+ ) : isLoading ? (
+
Loading…
+ ) : litters.length === 0 ? (
+
- Record Litter
-
- }
+ description="Record a breeding when you pair a buck and doe, or record a litter once it's kindled."
+ action={Record Breeding }
/>
- ) : (
-
- {litters.map((litter) => (
-
- ))}
-
- )}
-
+
+ ) : (
+ <>
+
+
+ >
+ )}
)
}
diff --git a/mykindlebox/client/src/pages/NotFound.jsx b/mykindlebox/client/src/pages/NotFound.jsx
index e6601f0..b577922 100644
--- a/mykindlebox/client/src/pages/NotFound.jsx
+++ b/mykindlebox/client/src/pages/NotFound.jsx
@@ -1,4 +1,3 @@
-import { Link } from 'react-router-dom'
import Button from '../components/Button'
import Card from '../components/Card'
import EmptyState from '../components/EmptyState'
@@ -10,9 +9,7 @@ function NotFound() {
title="Page not found"
description="That page doesn't exist — it may have been moved or deleted."
action={
-
-
Back to Dashboard
-
+
Back to Dashboard
}
/>
diff --git a/mykindlebox/client/src/pages/RabbitDetail.jsx b/mykindlebox/client/src/pages/RabbitDetail.jsx
index 5b233ff..f210c21 100644
--- a/mykindlebox/client/src/pages/RabbitDetail.jsx
+++ b/mykindlebox/client/src/pages/RabbitDetail.jsx
@@ -5,11 +5,12 @@ import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Badge from '../components/Badge'
import Button from '../components/Button'
+import LoadError from '../components/LoadError'
import Silhouette from '../components/Silhouette'
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
import RabbitTimeline from '../components/RabbitTimeline'
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
-import { SEX_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
+import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
import { LOCI } from '../lib/genetics'
@@ -89,6 +90,18 @@ function DetailColumn({ rabbit }) {
)
}
+function SectionCard({ title, action, children }) {
+ return (
+
+
+
{title}
+ {action}
+
+ {children}
+
+ )
+}
+
function LitterHistory({ rabbitId }) {
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
@@ -99,41 +112,43 @@ function LitterHistory({ rabbitId }) {
if (history.length === 0) return null
return (
-
-
-
-
- Partner
- Kindle Date
- Kits
-
-
-
- {history.map((litter) => {
- const partner = litter.sire?.id === rabbitId ? litter.dam : litter.sire
- return (
-
-
-
- {partner?.name ?? 'Unknown'}
-
-
-
- {litter.kindle_date
- ? formatDate(litter.kindle_date)
- : litter.breed_date
- ? `Bred ${formatDate(litter.breed_date)}`
- : '—'}
-
-
- {litter.kindle_date ? (litter.kit_count ?? 0) : '—'}
-
-
- )
- })}
-
-
-
+
+
+
+
+
+ Partner
+ Kindle Date
+ Kits
+
+
+
+ {history.map((litter) => {
+ const partner = litter.sire?.id === rabbitId ? litter.dam : litter.sire
+ return (
+
+
+
+ {partner?.name ?? 'Unknown'}
+
+
+
+ {litter.kindle_date
+ ? formatDate(litter.kindle_date)
+ : litter.breed_date
+ ? `Bred ${formatDate(litter.breed_date)}`
+ : '—'}
+
+
+ {litter.kindle_date ? (litter.kit_count ?? 0) : '—'}
+
+
+ )
+ })}
+
+
+
+
)
}
@@ -148,7 +163,7 @@ function RabbitDetail() {
const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null)
- const { data: rabbit, isLoading } = useQuery({
+ const { data: rabbit, isLoading, isError, error: loadError } = useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
})
@@ -158,16 +173,21 @@ function RabbitDetail() {
enabled: Boolean(rabbit),
})
+ if (isError) {
+ return
+ }
if (isLoading || !rabbit) {
- return
+ return
}
- async function patchStatus(status) {
+ async function changeStatus(status) {
setUpdating(true)
setError(null)
try {
const updated = await updateRabbit(id, { status })
queryClient.setQueryData(['rabbit', id], updated)
+ queryClient.invalidateQueries({ queryKey: ['stats'] })
+ queryClient.invalidateQueries({ queryKey: ['rabbits'] })
} catch (err) {
setError(err.message)
} finally {
@@ -179,7 +199,7 @@ function RabbitDetail() {
const confirmed = window.confirm(
`Permanently delete ${rabbit.name}? This can’t be undone.\n\n` +
'Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as unknown. ' +
- 'Its timeline events are deleted. To keep its history instead, use Archive.'
+ 'Its timeline events are deleted. To keep its history instead, change its status to Deceased, Sold or Other.'
)
if (!confirmed) return
setUpdating(true)
@@ -194,22 +214,9 @@ function RabbitDetail() {
}
}
- function handleArchive() {
- if (window.confirm('Archive this rabbit? This marks it as deceased.')) {
- patchStatus('deceased')
- }
- }
-
- // Pre-fill the started-from rabbit as sire or dam based on its sex; an
- // unknown-sex rabbit opens the flow with neither slot filled.
- const parentQuery =
- rabbit.sex === 'buck'
- ? `?sire=${rabbit.id}`
- : rabbit.sex === 'doe'
- ? `?dam=${rabbit.id}`
- : ''
- const addBreedingHref = `/breedings/add${parentQuery}`
- const addLitterHref = `/litters/add${parentQuery}`
+ // Breeding/litter actions pre-fill this rabbit as sire or dam, so they only
+ // make sense once its sex is known.
+ const parentQuery = rabbit.sex === 'buck' ? `?sire=${rabbit.id}` : rabbit.sex === 'doe' ? `?dam=${rabbit.id}` : null
return (
@@ -217,41 +224,38 @@ function RabbitDetail() {
title={rabbit.name}
subtitle={rabbit.farm}
action={
-
-
-
- Edit
-
-
-
-
- Add Breeding
-
-
-
-
- Add Litter
-
-
-
-
- Print Pedigree
-
-
-
patchStatus(rabbit.status === 'for_sale' ? 'active' : 'for_sale')}
- >
- {rabbit.status === 'for_sale' ? 'Mark Active' : 'Mark For Sale'}
+
+
+ Edit
-
- Archive
-
-
- Delete
+ {parentQuery && (
+ <>
+
+ Record Breeding
+
+
+ Record Litter
+
+ >
+ )}
+
+ Print Pedigree
+
+ Status
+ changeStatus(e.target.value)}
+ className="bg-transparent py-1 font-medium text-text-primary focus:outline-none"
+ >
+ {Object.entries(STATUS_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
}
/>
@@ -269,23 +273,32 @@ function RabbitDetail() {
-
-
-
- {ancestors ? (
-
- ) : (
- Loading pedigree…
- )}
-
+
+
+ Full pedigree
+
+ }
+ >
+ {ancestors ? (
+
+ ) : (
+ Loading pedigree…
+ )}
+
-
-
+
-
-
-
-
+
+
+
+
+
+
+ Delete Rabbit
+
diff --git a/mykindlebox/client/src/pages/Settings.jsx b/mykindlebox/client/src/pages/Settings.jsx
index d5d7fbf..9b51108 100644
--- a/mykindlebox/client/src/pages/Settings.jsx
+++ b/mykindlebox/client/src/pages/Settings.jsx
@@ -53,10 +53,13 @@ function Settings() {
return () => clearTimeout(timer)
}, [toast])
- async function handleSave() {
+ // Each card saves only its own keys: rabbitry settings are shared by every
+ // user, while weight display and theme belong to whoever is signed in.
+ async function handleSave(keys) {
setSaving(true)
try {
- await updateSettings(form)
+ await updateSettings(Object.fromEntries(keys.map((key) => [key, form[key]])))
+ queryClient.invalidateQueries({ queryKey: ['settings'] })
setToast('Settings saved')
} catch (err) {
setToast(err.message || 'Failed to save settings')
@@ -115,7 +118,7 @@ function Settings() {
if (isLoading || !form) {
return (
)
}
@@ -125,6 +128,11 @@ function Settings() {
+
+
Rabbitry
+
Shared by everyone who signs in.
+
+
Rabbitry Name
-
-
Weight Display
-
- {WEIGHT_UNIT_OPTIONS.map((opt) => (
- setForm({ ...form, weight_unit: opt.value })}
- className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
- form.weight_unit === opt.value
- ? 'border-accent bg-accent text-surface'
- : 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
- }`}
- >
- {opt.label}
-
- ))}
-
-
-
Kit Prefix
+ handleSave(['rabbitry_name', 'kit_prefix', 'default_breed'])} disabled={saving}>
+ {saving ? 'Saving…' : 'Save Rabbitry Settings'}
+
+
+
+
+
+
Your Preferences
+
Only affect your account.
+
+
+
Weight Display
+
+ {WEIGHT_UNIT_OPTIONS.map((opt) => (
+ setForm({ ...form, weight_unit: opt.value })}
+ className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
+ form.weight_unit === opt.value
+ ? 'border-accent bg-accent text-surface'
+ : 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+
+
Theme
@@ -220,28 +238,29 @@ function Settings() {
-
- {saving ? 'Saving...' : 'Save Settings'}
+ handleSave(['weight_unit', 'theme'])} disabled={saving}>
+ {saving ? 'Saving…' : 'Save Preferences'}
-
Export Database
+
Back Up
- Download a full backup of the SQLite database file.
+ Download a complete copy of the database — rabbits, litters, shows, users and settings. To restore it,
+ replace the server’s data file (see “Data & backups” in the README); Import below can’t read it.
- Export Database
+ Download Backup
-
Import Data
+
Import Herd
- Load rabbits from a CSV or JSON file. This replaces your entire herd —{' '}
+ Load rabbits from a CSV or JSON file, such as an export from the old Kindlebox site. This replaces your entire herd —{' '}
all current rabbits and litters are deleted first.
diff --git a/mykindlebox/client/src/pages/ShowEventDetail.jsx b/mykindlebox/client/src/pages/ShowEventDetail.jsx
index 42c4e47..105fdf6 100644
--- a/mykindlebox/client/src/pages/ShowEventDetail.jsx
+++ b/mykindlebox/client/src/pages/ShowEventDetail.jsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { Link, useNavigate, useParams } from 'react-router-dom'
+import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getShowEvent,
@@ -489,9 +489,7 @@ function EntriesTab({ event }) {
title="Add shows first"
description="Entries are made per show. Add shows to this event, then enter rabbits."
action={
-
- Browse Shows
-
+ Browse Shows
}
/>
@@ -651,9 +649,7 @@ function ShowEventDetail() {
subtitle={`${shows.length} show${shows.length === 1 ? '' : 's'} · ${entryCount} entr${entryCount === 1 ? 'y' : 'ies'}`}
action={
-
-
Add Shows
-
+
Add Shows
setEditing((v) => !v)}>
Edit
@@ -695,9 +691,7 @@ function ShowEventDetail() {
title="No shows in this event"
description="Add shows from the browser to start planning."
action={
-
-
Browse Shows
-
+
Browse Shows
}
/>
diff --git a/mykindlebox/client/src/pages/ShowEvents.jsx b/mykindlebox/client/src/pages/ShowEvents.jsx
index 5c5179e..856250e 100644
--- a/mykindlebox/client/src/pages/ShowEvents.jsx
+++ b/mykindlebox/client/src/pages/ShowEvents.jsx
@@ -86,9 +86,7 @@ function ShowEvents() {
subtitle="Your planned show weekends and the rabbits you’ll enter"
action={
-
- Browse Shows
-
+ Browse Shows
setCreating((v) => !v)}>
New Event
@@ -110,9 +108,7 @@ function ShowEvents() {
title="No events yet"
description="Create an event, then add shows to it from the browser."
action={
-
- Browse Shows
-
+ Browse Shows
}
/>
diff --git a/mykindlebox/client/src/pages/Shows.jsx b/mykindlebox/client/src/pages/Shows.jsx
index 9cfbc32..0b42e58 100644
--- a/mykindlebox/client/src/pages/Shows.jsx
+++ b/mykindlebox/client/src/pages/Shows.jsx
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
-import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getArbaShows, refreshArbaShows } from '../api'
import Card from '../components/Card'
@@ -203,9 +202,7 @@ function Shows() {
Updated {formatTimestamp(data?.fetched_at)}
{data?.stale ? ' (stale)' : ''}
-
- My Events
-
+ My Events
refresh.mutate()}
diff --git a/mykindlebox/server/routes/litters.ts b/mykindlebox/server/routes/litters.ts
index e7ae23d..1773ef4 100644
--- a/mykindlebox/server/routes/litters.ts
+++ b/mykindlebox/server/routes/litters.ts
@@ -13,24 +13,29 @@ const LITTER_WRITABLE = Object.keys(getTableColumns(litters)).filter(
(key) => !LITTER_SYSTEM_FIELDS.has(key)
);
-// Auto-creates `count` kit Rabbit rows for a newly-recorded litter, named
+// Auto-creates `count` kit Rabbit rows for a newly-kindled litter, named
// from the `kit_prefix` setting (falling back to "Kit" if unset) plus a
// global, never-resetting `kit_counter` — so kit names stay unique across
-// every litter ever recorded, not just within one litter. Wrapped in a
-// transaction so the counter and the inserted rows can't drift apart if
-// something fails partway through.
+// every litter ever recorded, not just within one litter. Kits get the same
+// defaults a hand-added rabbit would: the rabbitry name as farm, and the
+// dam's (else sire's) breed. Wrapped in a transaction so the counter and the
+// inserted rows can't drift apart if something fails partway through.
function generateKits(sireId: number | null, damId: number | null, dob: string, count: number) {
db.transaction((tx) => {
- const prefixRow = tx.select().from(settings).where(eq(settings.key, 'kit_prefix')).get();
- const counterRow = tx.select().from(settings).where(eq(settings.key, 'kit_counter')).get();
+ const setting = (key: string) =>
+ tx.select().from(settings).where(eq(settings.key, key)).get()?.value;
+ const parentBreed = (id: number | null) =>
+ id != null ? tx.select({ breed: rabbits.breed }).from(rabbits).where(eq(rabbits.id, id)).get()?.breed : null;
- const prefix = prefixRow?.value || 'Kit';
- let counter = Number(counterRow?.value ?? '0');
+ const prefix = setting('kit_prefix') || 'Kit';
+ const farm = setting('rabbitry_name') || null;
+ const breed = parentBreed(damId) || parentBreed(sireId) || null;
+ let counter = Number(setting('kit_counter') ?? '0');
for (let i = 0; i < count; i++) {
counter += 1;
tx.insert(rabbits)
- .values({ name: `${prefix}${counter}`, sireId, damId, dob })
+ .values({ name: `${prefix}${counter}`, sireId, damId, dob, farm, breed })
.run();
}
@@ -148,13 +153,40 @@ router.patch('/:id', (req, res) => {
typeof litters.$inferInsert
>;
- const updated = db.update(litters).set(values).where(eq(litters.id, id)).returning().get();
-
- if (!updated) {
+ const existing = db.select().from(litters).where(eq(litters.id, id)).get();
+ if (!existing) {
res.status(404).json({ error: 'Litter not found' });
return;
}
+ const updated = db.update(litters).set(values).where(eq(litters.id, id)).returning().get();
+
+ if (existing.kindleDate) {
+ // Kits are matched to their litter by sire/dam/DOB, so correcting any of
+ // those on a kindled litter moves its existing kits along with it rather
+ // than silently orphaning them.
+ const moved =
+ existing.kindleDate !== updated.kindleDate ||
+ existing.sireId !== updated.sireId ||
+ existing.damId !== updated.damId;
+ if (moved && updated.kindleDate) {
+ db.update(rabbits)
+ .set({ sireId: updated.sireId, damId: updated.damId, dob: updated.kindleDate, updatedAt: new Date().toISOString() })
+ .where(
+ and(
+ existing.sireId != null ? eq(rabbits.sireId, existing.sireId) : isNull(rabbits.sireId),
+ existing.damId != null ? eq(rabbits.damId, existing.damId) : isNull(rabbits.damId),
+ eq(rabbits.dob, existing.kindleDate)
+ )
+ )
+ .run();
+ }
+ } else if (updated.kindleDate && updated.kitCount && updated.kitCount > 0) {
+ // Recording the kindle on a breeding creates its kits, exactly as recording
+ // a kindled litter directly does.
+ generateKits(updated.sireId, updated.damId, updated.kindleDate, updated.kitCount);
+ }
+
res.json(toSnakeCase(updated));
});
diff --git a/mykindlebox/server/routes/rabbits.ts b/mykindlebox/server/routes/rabbits.ts
index ca5fe26..38aaaa9 100644
--- a/mykindlebox/server/routes/rabbits.ts
+++ b/mykindlebox/server/routes/rabbits.ts
@@ -47,6 +47,17 @@ function nameContains(term: string): SQL {
return sql`${rabbits.name} like ${`%${escaped}%`} escape '\\'`;
}
+// The Herd page's search box: the same escaped substring match, but across
+// name, ear number and color so one box finds a rabbit however you know it.
+function herdTermContains(term: string): SQL {
+ const pattern = `%${term.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
+ return or(
+ sql`${rabbits.name} like ${pattern} escape '\\'`,
+ sql`${rabbits.earNumber} like ${pattern} escape '\\'`,
+ sql`${rabbits.colorName} like ${pattern} escape '\\'`
+ )!;
+}
+
// Quick-search "wildcard at the end": a prefix match (`term%`), so typing "Ju"
// matches "Jim Bean"… no, "Jupiter" — only the start of the field. The user's
// text is escaped so literal % / _ aren't treated as wildcards. SQLite's LIKE is
@@ -240,7 +251,7 @@ router.get('/', (req, res) => {
}
if (typeof search === 'string' && search) {
- conditions.push(nameContains(search));
+ conditions.push(herdTermContains(search));
}
const sortColumn =
@@ -321,6 +332,7 @@ router.get('/quick-search', (req, res) => {
sex: rabbits.sex,
earNumber: rabbits.earNumber,
colorName: rabbits.colorName,
+ status: rabbits.status,
})
.from(rabbits)
.where(or(...matchers))
diff --git a/mykindlebox/server/routes/showEvents.ts b/mykindlebox/server/routes/showEvents.ts
index c423bd9..326cd9e 100644
--- a/mykindlebox/server/routes/showEvents.ts
+++ b/mykindlebox/server/routes/showEvents.ts
@@ -137,6 +137,43 @@ router.get('/', (req, res) => {
res.json(result);
});
+// One rabbit's show history across every event (`?rabbit_id=X`, required), so
+// its timeline can list the shows it was entered in with their results — the
+// entries/placements live here, not in `rabbit_events`. Registered before
+// `GET /:id` so "entries" isn't parsed as an event id.
+router.get('/entries', (req, res) => {
+ const rabbitId = Number(req.query.rabbit_id);
+ if (!Number.isInteger(rabbitId)) {
+ res.status(400).json({ error: 'rabbit_id is required' });
+ return;
+ }
+
+ const rows = db
+ .select({
+ entryId: showEventEntries.id,
+ entryClass: showEventEntries.entryClass,
+ eventId: showEvents.id,
+ eventName: showEvents.name,
+ clubName: showEventShows.clubName,
+ showDate: showEventShows.showDate,
+ showType: showEventShows.showType,
+ })
+ .from(showEventEntries)
+ .innerJoin(showEventShows, eq(showEventEntries.showEventShowId, showEventShows.id))
+ .innerJoin(showEvents, eq(showEventShows.showEventId, showEvents.id))
+ .where(eq(showEventEntries.rabbitId, rabbitId))
+ .orderBy(asc(showEventShows.showDate))
+ .all();
+
+ const placements = placementsByEntry(rows.map((r) => r.entryId));
+ res.json(
+ rows.map((row) => {
+ const placement = placements.get(row.entryId);
+ return { ...toSnakeCase(row), placement: placement ? toSnakeCase(placement) : null };
+ })
+ );
+});
+
// Event detail: the event plus its snapshotted shows (date order). Judges and
// entries (Phases 3–4) will be embedded here later.
router.get('/:id', (req, res) => {