Cohesion pass: litter lifecycle, show history, edit parents, UI polish
- Litters split into Expecting/Kindled; Record Kindle generates kits (farm and breed filled in), and kindle-date/parent edits move existing kits - Litter detail: Record Kindle/Add Kit, Edit, Delete; due/wean projections - Rabbit detail: section cards, status select, show results from events - Edit Rabbit: re-link sire/dam (grandparents come from parents' records) - Herd search matches ear number and color; quick search shows status - Settings split into shared Rabbitry and per-user Preferences - Shared LoadError, Button `to` prop, responsive read-only pedigree - Genetics hidden from nav for now - Parent autocomplete popup stacks above the action bar and drops up when there's no room below Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
990c703bb2
commit
5dcaf9d04b
|
|
@ -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 `<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')`. |
|
||||
|
|
@ -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 (`<Route element={<Layout />}>`) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all `*` → `NotFound` route. |
|
||||
| `src/api.js` | Plain JS (not `.ts` — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, events CRUD (`getEvents`/`createEvent`/`updateEvent`/`deleteEvent`), settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`, shows (`getArbaShows`/`refreshArbaShows`), show events (`getShowEvents`/`getShowEvent`/`createShowEvent`/`updateShowEvent`/`deleteShowEvent`/`addShowToEvent`/`removeShowFromEvent`), show entries + report (`addShowEntry`/`updateShowEntry`/`removeShowEntry`/`getEventReport`), judges + placements (`addShowJudge`/`updateShowJudge`/`removeShowJudge`/`setShowEntryPlacement`)), all targeting `http://localhost:3001/api`. Shared `request()` helper throws on non-2xx (surfacing the JSON `{ error }` body's message) and only sets `Content-Type: application/json` when the body is a JSON string, so the multipart `FormData` photo-upload call isn't given the wrong header. |
|
||||
| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's `<Outlet />`. Desktop: 240px left sidebar (`hidden md:flex`) with `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`, icon + label, 56px tall, padded by `env(safe-area-inset-bottom)`) with Dashboard/Herd/Add (→ `/herd/add`)/Litters/More — "More" opens a small menu for Genetics/Shows/Settings so every page is reachable on a phone. The sidebar is `sticky` and the content column is `min-w-0` + `max-w-6xl`, so wide children (tables, the 900px pedigree tree) scroll inside their own containers instead of stretching the page. `<main>` owns the page gutter (`px-4 md:px-8`); pages must not add their own horizontal padding. Active-route styling comes from `NavLink`'s `isActive` callback, not manual path matching. Also renders `<QuickSearch />` at the top of the `<main>` content area (above `<Outlet />`), so the quick-search bar appears on every page. |
|
||||
| `src/components/QuickSearch.jsx` | Global quick-search bar (top of every page via `Layout`). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), **all selected by default**, controlling which fields `GET /api/rabbits/quick-search` prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per `matched_fields` entry (value-bearing chips for ear/color, e.g. `Ear #: A15`). Selecting a result navigates to `/herd/:id`. Reuses `lib/keyboardNav.js`'s `handleSearchInputKeyDown`/`handleResultKeyDown` for the same `<ul><li><button>` roving-focus dropdown shape as `RabbitAutocomplete`/`PedigreeTree`. The debounce effect deliberately does **not** call `setState` synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show. |
|
||||
| `src/components/Button.jsx` | `variant` (`primary`/`secondary`/`danger`) × `size` (`sm`/`md`) class-map component. |
|
||||
| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's `<Outlet />`. Desktop: 240px left sidebar (`hidden md:flex`) with `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`, icon + label, 56px tall, padded by `env(safe-area-inset-bottom)`) with Dashboard/Herd/Add (→ `/herd/add`)/Litters/More — "More" opens a small menu for Genetics/Shows/Settings so every page is reachable on a phone. The sidebar is `sticky` and the content column is `min-w-0` + `max-w-6xl`, so wide children (tables, the 900px pedigree tree) scroll inside their own containers instead of stretching the page. `<main>` owns the page gutter (`px-4 md:px-8`); pages must not add their own horizontal padding. Active-route styling comes from `NavLink`'s `isActive` callback, not manual path matching. Also renders `<QuickSearch />` at the top of the `<main>` content area (above `<Outlet />`) on every page **except** `/herd` (which has its own search box) and the add/edit form routes (`showsQuickSearch`), where jumping to a result would silently discard the unsaved form. |
|
||||
| `src/components/QuickSearch.jsx` | Global quick-search bar (top of every page via `Layout`). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), **all selected by default**, controlling which fields `GET /api/rabbits/quick-search` prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per `matched_fields` entry (value-bearing chips for ear/color, e.g. `Ear #: A15`). Selecting a result navigates to `/herd/:id`. Results outside the active herd show a status `Badge` (pedigree-materialized ancestors are status `other`). Reuses `lib/keyboardNav.js`'s `handleSearchInputKeyDown`/`handleResultKeyDown` for the same `<ul><li><button>` roving-focus dropdown shape as `RabbitAutocomplete`/`PedigreeTree`. The debounce effect deliberately does **not** call `setState` synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show. |
|
||||
| `src/components/Button.jsx` | `variant` (`primary`/`secondary`/`danger`) × `size` (`sm`/`md`) class-map component. With a `to` prop it renders a router `<Link>` with the same classes — **use `<Button to=…>` for navigation, never `<Link><Button>`** (a button inside a link is invalid HTML and adds a second tab stop). |
|
||||
| `src/components/LoadError.jsx` | Failed-fetch state for a page's main record (`title`, `error`, `backTo`, `backLabel`); says "not found" for 404s. Detail/edit pages render it on `isError` so a stale link never sits on "Loading…" forever. |
|
||||
| `src/hooks/useElementWidth.js` | `[ref, width]` via `ResizeObserver` — for layouts that depend on a column's real width rather than the viewport (used by `PedigreeTreeReadOnly`). |
|
||||
| `src/components/Badge.jsx` | Status → color/label class map covering all six Rabbit `status` values; unrecognized values fall back to the `other` style rather than crashing. |
|
||||
| `src/components/Card.jsx` | Bordered `surface-raised` container with shadow; just forwards `className`/props. |
|
||||
| `src/components/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. No horizontal padding (the Layout gutter supplies it); stacks the action row under the title below `sm`. Pages render it as the first child of a `flex flex-col gap-6` root, and loading states render `<PageHeader title=… subtitle="Loading…" />`. |
|
||||
|
|
@ -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 `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
||||
- **Auth is account-based and always on** (`FEATURE_User_Accounts.md`, replacing the original optional shared-password mode): accounts live in `users`, sessions in `auth_sessions` (so restarts don't sign anyone out), passwords are scrypt-hashed via `node:crypto` (**deliberately not bcrypt/argon2** — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created through `POST /api/auth/setup`, which only works while the `users` table is empty — the client's `AuthProvider` shows a `SetupScreen` when `GET /api/auth/status` reports `configured: false`. Any signed-in user manages users at `/api/users` (Settings → Users card); you can't delete yourself, which transitively protects the last account. **`/api/settings` kept its exact single-object shape** but storage split: `theme`/`weight_unit` read/write the caller's `users` row (NULL falls back to the global settings value), the rest stay in the `settings` table — client consumers (ThemeSync, `useWeightUnit`, the Settings page) required no changes. The old `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` env var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping.
|
||||
- **Deleting a rabbit** (`DELETE /api/rabbits/:id`, Delete button on Rabbit Detail): refused with 409 while it has show entries (history the user would lose; Archive is the alternative). Otherwise, in one transaction: offspring get a manual-ancestor JSON snapshot of it and the two generations above (full name/farm/ear #/weight/DOB, so it can be re-matched later), litters null that parent id, and its `rabbit_events` are deleted — SQLite FK enforcement is off, so nothing cascades by itself.
|
||||
- **Cohesion pass (2026-10-30)** — app-wide UX consistency decisions to preserve:
|
||||
- **Litter lifecycle**: `/litters` lists "Expecting" (no kindle date) and "Kindled" sections; whole `LitterCard` links to the litter. Litter Detail carries Record Kindle (→ `/litters/:id/edit?kindle=1`, which requires the kindle date and creates kits) or Add Kit, plus Edit and Delete. `EditLitter` edits an unkindled litter as a breeding (`mode="breeding"`, no kindle date required) and a kindled one as a litter; `LitterForm`'s `createsKits` flag picks the kit-count hint. Gestation/wean constants + a timezone-safe `addDays` live in `lib/format.js` (`GESTATION_DAYS` 31, `WEAN_DAYS` 56). "Record Breeding" is offered everywhere "Record Litter" is (Dashboard, Litters, Rabbit Detail).
|
||||
- **Rabbit Detail actions**: Edit · Record Breeding · Record Litter (bucks/does only) · Print Pedigree · a **Status select** (all six statuses). The old "Mark For Sale" toggle and "Archive" button (which silently set `deceased`) are gone; Delete sits at the bottom of the page. Pedigree and Litters have section headings. `PedigreeTreeReadOnly` switches to a stacked Sire/Dam layout whenever its container is narrower than the bracket layout (`useElementWidth`), so it never scrolls sideways.
|
||||
- **Edit Rabbit** has a **Parents** section (two `RabbitAutocomplete`s) that re-links `sire_id`/`dam_id`; changing a parent also nulls that side's 7 `*_name_text` columns (the old line's freeform ancestors), since the new parent's own record now supplies them. A typed-in (unlinked) parent is shown as a hint until replaced. Deeper per-slot editing of freeform ancestors is still Add-flow only. Uses `StickyActionBar` like Add.
|
||||
- **Timeline ↔ Shows**: entries made under Shows → My Events appear on the rabbit timeline as derived `show` rows (club, class, placement/legs/BOB…) linking to the event; the manual "Show" event type remains for shows tracked elsewhere.
|
||||
- **Settings** is split into a shared **Rabbitry** card (name, kit prefix, default breed) and a per-user **Your Preferences** card (weight display, theme), each saving only its own keys. Export is labelled **Back Up** and states that Import can't read it (Import = CSV/JSON herd replacement).
|
||||
- **Genetics is hidden from the sidebar and mobile More menu** until the calculator is built; the `/genetics` placeholder route still exists. Grandparents are corrected by editing the parent rabbit (Edit Rabbit only re-links sire/dam), by user decision.
|
||||
- Herd's sex tab for `sex='unknown'` is labelled **Unknown** (was "Other", which collided with the `other` status).
|
||||
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Link to={`/shows/events/${result.id}`}>
|
||||
<Button variant="primary">View Event</Button>
|
||||
</Link>
|
||||
<Button to={`/shows/events/${result.id}`} variant="primary">
|
||||
View Event
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
className={`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}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
// With `to`, renders a router <Link> styled as a button — use this for
|
||||
// navigation instead of wrapping a <Button> in a <Link> (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 <Link to={to} className={classes} {...props} />
|
||||
return <button className={classes} {...props} />
|
||||
}
|
||||
|
||||
export default Button
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<OfflineBanner />
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(5rem+env(safe-area-inset-bottom))] pt-4 md:px-8 md:pb-8 md:pt-6">
|
||||
<p className="mb-3 font-display text-lg font-bold text-text-primary md:hidden">MyKindlebox</p>
|
||||
<QuickSearch />
|
||||
{showsQuickSearch(pathname) && <QuickSearch />}
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card className="flex flex-wrap items-center justify-between gap-4 p-4">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">
|
||||
<Link to={`/litters/${litter.id}`} className="block">
|
||||
<Card className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-surface-sunken">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-display text-base text-text-primary">
|
||||
{sireName} × {damName}
|
||||
</p>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">{meta}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Link to={`/litters/${litter.id}`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
{kindled ? 'View Kits' : 'View'}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/litters/${litter.id}/edit`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<span aria-hidden="true" className="shrink-0 font-body text-lg text-text-secondary">
|
||||
›
|
||||
</span>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title={title} />
|
||||
<Card>
|
||||
<EmptyState
|
||||
title={notFound ? `${title} not found` : `Couldn’t load ${title.toLowerCase()}`}
|
||||
description={
|
||||
notFound
|
||||
? 'It may have been deleted, or the link is out of date.'
|
||||
: error?.message || 'Check your connection and try again.'
|
||||
}
|
||||
action={backTo ? <Button to={backTo}>{backLabel}</Button> : null}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadError
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<AncestorNodeView path={path} label={LABEL_BY_PATH.get(path.join('.'))} ancestors={ancestors} />
|
||||
{path.length < depth && (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
|
||||
<StackedBranch path={[...path, 'sire']} ancestors={ancestors} depth={depth} />
|
||||
<StackedBranch path={[...path, 'dam']} ancestors={ancestors} depth={depth} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// `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,12 +77,14 @@ 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.
|
||||
<div className="w-full overflow-x-auto p-4">
|
||||
// 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.
|
||||
<div ref={ref} className="w-full overflow-x-auto p-4">
|
||||
{wide ? (
|
||||
<div className="flex items-center gap-4" style={{ minWidth }}>
|
||||
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
|
||||
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
||||
|
|
@ -72,6 +93,12 @@ function PedigreeTreeReadOnly({ subjectName, ancestors, generations = 3 }) {
|
|||
{generations >= 2 && <Generation entries={GEN2} ancestors={ancestors} />}
|
||||
{generations >= 3 && <Generation entries={GEN3} ancestors={ancestors} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<StackedBranch path={['sire']} ancestors={ancestors} depth={generations} />
|
||||
<StackedBranch path={['dam']} ancestors={ancestors} depth={generations} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<span className="text-text-secondary"> · {result.farm}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Ancestors added from pedigrees are status Other — flag
|
||||
anything outside the active herd. */}
|
||||
{result.status && result.status !== 'active' && (
|
||||
<span className="ml-2 align-middle">
|
||||
<Badge status={result.status} />
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
{(result.matched_fields ?? []).map((field) => {
|
||||
const value = matchedValue(result, field)
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => 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'}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised p-2 shadow-sm">
|
||||
<div
|
||||
className={`absolute left-0 z-30 w-full rounded-md border border-border bg-surface-raised p-2 shadow-lg ${
|
||||
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import Button from './Button'
|
||||
import { getLitters, getEvents, createEvent, updateEvent, deleteEvent } from '../api'
|
||||
import { getLitters, getEvents, getRabbitShowEntries, createEvent, updateEvent, deleteEvent } from '../api'
|
||||
import { formatDate } from '../lib/format'
|
||||
|
||||
// Visual config per timeline event type. `order` breaks ties when two events
|
||||
|
|
@ -34,10 +34,20 @@ const TITLE_PLACEHOLDER = {
|
|||
other: 'e.g. Vet visit',
|
||||
}
|
||||
|
||||
// Builds the chronological event list from the rabbit record, its litters, and
|
||||
// its logged events. Births/breedings are derived (no storage); acquired/death
|
||||
// come off the rabbit row; show/sale/other are logged event records.
|
||||
function buildEvents(rabbit, litters, logged) {
|
||||
// "1st • 1 leg • BOB" — the result line for a Shows-feature entry.
|
||||
function placementSummary(placement) {
|
||||
if (!placement) return null
|
||||
const parts = [placement.placement]
|
||||
if (placement.legs_earned > 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 }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isShow && !editingEvent && (
|
||||
<p className="font-body text-xs text-text-secondary">
|
||||
Shows you enter this rabbit in under Shows → My Events appear here automatically. Use this for shows
|
||||
tracked elsewhere.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form.type === 'sale' && (
|
||||
<p className="font-body text-xs text-text-secondary">
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/herd/add">
|
||||
<Button variant="primary">Add Rabbit</Button>
|
||||
</Link>
|
||||
<Link to="/litters/add">
|
||||
<Button variant="secondary">Record Litter</Button>
|
||||
</Link>
|
||||
<Link to="/herd">
|
||||
<Button variant="secondary">View Herd</Button>
|
||||
</Link>
|
||||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||||
<Button to="/breedings/add" variant="secondary">Record Breeding</Button>
|
||||
<Button to="/litters/add" variant="secondary">Record Litter</Button>
|
||||
<Button to="/herd" variant="secondary">View Herd</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="No litters yet"
|
||||
description="Record a litter to see it here."
|
||||
description="Record a breeding or litter to see it here."
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
|
|
@ -119,11 +106,15 @@ function RecentLitters() {
|
|||
{litter.sire?.name ?? 'Unknown'} × {litter.dam?.name ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatDate(litter.kindle_date)}
|
||||
{litter.kindle_date
|
||||
? formatDate(litter.kindle_date)
|
||||
: `Due ${formatDate(addDays(litter.breed_date, GESTATION_DAYS))}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-body text-sm text-text-secondary">
|
||||
{litter.kit_count} {litter.kit_count === 1 ? 'kit' : 'kits'}
|
||||
{litter.kindle_date
|
||||
? `${litter.kit_count ?? 0} ${litter.kit_count === 1 ? 'kit' : 'kits'}`
|
||||
: 'Expecting'}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 <LoadError title="Litter" error={loadError} backTo="/litters" backLabel="Back to Litters" />
|
||||
}
|
||||
if (isLoading || !litter) {
|
||||
return <PageHeader title="Edit Litter" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Edit Litter" />
|
||||
<PageHeader title={title} subtitle={`${litter.sire?.name ?? 'Unknown'} × ${litter.dam?.name ?? 'Unknown'}`} />
|
||||
<div>
|
||||
<LitterForm
|
||||
mode={kindled || recordingKindle ? 'litter' : 'breeding'}
|
||||
createsKits={!kindled}
|
||||
initialValues={{
|
||||
sire: litter.sire,
|
||||
dam: litter.dam,
|
||||
|
|
@ -49,7 +67,7 @@ function EditLitter() {
|
|||
onCancel={() => navigate(`/litters/${id}`)}
|
||||
saving={saving}
|
||||
error={error}
|
||||
submitLabel="Save Changes"
|
||||
submitLabel={recordingKindle ? 'Save Kindle' : 'Save Changes'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<span className="block font-body text-sm font-medium text-text-primary">{label}</span>
|
||||
<div className="mt-1">
|
||||
<RabbitAutocomplete sex={sex} value={value} onChange={onChange} />
|
||||
</div>
|
||||
{!value && manualName && (
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||
Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <LoadError title="Rabbit" error={loadError} backTo="/herd" backLabel="Back to Herd" />
|
||||
}
|
||||
if (details === null || genetics === null || parents === null) {
|
||||
return <PageHeader title="Edit Rabbit" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-4">
|
||||
<h2 className="font-body text-sm font-medium text-text-primary">Parents</h2>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||
Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<ParentField
|
||||
label="Sire"
|
||||
sex="buck"
|
||||
value={parents.sire}
|
||||
onChange={(sire) => setParents((prev) => ({ ...prev, sire }))}
|
||||
manualName={rabbit.sire_id == null ? ancestors?.sire?.name : null}
|
||||
/>
|
||||
<ParentField
|
||||
label="Dam"
|
||||
sex="doe"
|
||||
value={parents.dam}
|
||||
onChange={(dam) => setParents((prev) => ({ ...prev, dam }))}
|
||||
manualName={rabbit.dam_id == null ? ancestors?.dam?.name : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GeneticsForm
|
||||
value={genetics}
|
||||
onChange={updateGenetics}
|
||||
|
|
@ -107,17 +203,21 @@ function EditRabbit() {
|
|||
onUseColor={(color) => updateDetails({ color_name: color })}
|
||||
/>
|
||||
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="font-body text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<StickyActionBar>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate(`/herd/${id}`)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</StickyActionBar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -303,9 +305,7 @@ function Herd() {
|
|||
<PageHeader
|
||||
title="Herd"
|
||||
action={
|
||||
<Link to="/herd/add">
|
||||
<Button variant="primary">Add Rabbit</Button>
|
||||
</Link>
|
||||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -332,9 +332,7 @@ function Herd() {
|
|||
title="No rabbits found"
|
||||
description="Try adjusting your filters or add a new rabbit to get started."
|
||||
action={
|
||||
<Link to="/herd/add">
|
||||
<Button variant="primary">Add Rabbit</Button>
|
||||
</Link>
|
||||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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 <p className="font-body text-sm text-text-secondary">Unknown</p>
|
||||
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 <LoadError title="Litter" error={loadError} backTo="/litters" backLabel="Back to Litters" />
|
||||
}
|
||||
if (isLoading || !litter) {
|
||||
return <PageHeader title="Litter Detail" subtitle="Loading…" />
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
<Link to={`/herd/add?${addKitParams.toString()}`}>
|
||||
<Button size="sm">Add Kit</Button>
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kindled ? (
|
||||
<Button to={`/herd/add?${addKitParams.toString()}`} size="sm">
|
||||
Add Kit
|
||||
</Button>
|
||||
) : (
|
||||
<Link to={`/litters/${litter.id}/edit`}>
|
||||
<Button size="sm">Record Kindle</Button>
|
||||
</Link>
|
||||
)
|
||||
<Button to={`/litters/${litter.id}/edit?kindle=1`} size="sm">
|
||||
Record Kindle
|
||||
</Button>
|
||||
)}
|
||||
<Button to={`/litters/${litter.id}/edit`} variant="secondary" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleDelete} disabled={deleting} className="text-danger">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
<Card className="grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">Sire</p>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,67 @@
|
|||
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 (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-lg text-text-primary">
|
||||
{title} <span className="font-body text-sm text-text-secondary">({litters.length})</span>
|
||||
</h2>
|
||||
{litters.map((litter) => (
|
||||
<LitterCard key={litter.id} litter={litter} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Litters"
|
||||
subtitle="Breedings and the litters they produce"
|
||||
action={
|
||||
<Link to="/litters/add">
|
||||
<Button>Record Litter</Button>
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button to="/breedings/add" variant="secondary">
|
||||
Record Breeding
|
||||
</Button>
|
||||
<Button to="/litters/add">Record Litter</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{isLoading ? null : litters.length === 0 ? (
|
||||
{isError ? (
|
||||
<p className="font-body text-sm text-danger">Failed to load litters.</p>
|
||||
) : isLoading ? (
|
||||
<p className="font-body text-sm text-text-secondary">Loading…</p>
|
||||
) : litters.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="No litters yet"
|
||||
description="Record your first litter to start tracking kindle dates and kits."
|
||||
action={
|
||||
<Link to="/litters/add">
|
||||
<Button>Record Litter</Button>
|
||||
</Link>
|
||||
}
|
||||
description="Record a breeding when you pair a buck and doe, or record a litter once it's kindled."
|
||||
action={<Button to="/breedings/add">Record Breeding</Button>}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{litters.map((litter) => (
|
||||
<LitterCard key={litter.id} litter={litter} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<LitterSection title="Expecting" litters={expecting} />
|
||||
<LitterSection title="Kindled" litters={kindled} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<Link to="/">
|
||||
<Button>Back to Dashboard</Button>
|
||||
</Link>
|
||||
<Button to="/">Back to Dashboard</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-4">
|
||||
<h2 className="font-display text-lg text-text-primary">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LitterHistory({ rabbitId }) {
|
||||
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||
|
||||
|
|
@ -99,7 +112,8 @@ function LitterHistory({ rabbitId }) {
|
|||
if (history.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="overflow-x-auto">
|
||||
<SectionCard title="Litters">
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-surface-sunken">
|
||||
<tr>
|
||||
|
|
@ -133,7 +147,8 @@ function LitterHistory({ rabbitId }) {
|
|||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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 <LoadError title="Rabbit" error={loadError} backTo="/herd" backLabel="Back to Herd" />
|
||||
}
|
||||
if (isLoading || !rabbit) {
|
||||
return <PageHeader title="Rabbit Detail" subtitle="Loading…" />
|
||||
return <PageHeader title="Rabbit" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -217,41 +224,38 @@ function RabbitDetail() {
|
|||
title={rabbit.name}
|
||||
subtitle={rabbit.farm}
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to={`/herd/${rabbit.id}/edit`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button to={`/herd/${rabbit.id}/edit`} size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={addBreedingHref}>
|
||||
<Button variant="secondary" size="sm">
|
||||
Add Breeding
|
||||
{parentQuery && (
|
||||
<>
|
||||
<Button to={`/breedings/add${parentQuery}`} variant="secondary" size="sm">
|
||||
Record Breeding
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={addLitterHref}>
|
||||
<Button variant="secondary" size="sm">
|
||||
Add Litter
|
||||
<Button to={`/litters/add${parentQuery}`} variant="secondary" size="sm">
|
||||
Record Litter
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/herd/${rabbit.id}/pedigree`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
</>
|
||||
)}
|
||||
<Button to={`/herd/${rabbit.id}/pedigree`} variant="secondary" size="sm">
|
||||
Print Pedigree
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
<label className="inline-flex min-h-[40px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 font-body text-sm text-text-secondary md:min-h-[32px]">
|
||||
Status
|
||||
<select
|
||||
value={rabbit.status}
|
||||
disabled={updating}
|
||||
onClick={() => patchStatus(rabbit.status === 'for_sale' ? 'active' : 'for_sale')}
|
||||
onChange={(e) => changeStatus(e.target.value)}
|
||||
className="bg-transparent py-1 font-medium text-text-primary focus:outline-none"
|
||||
>
|
||||
{rabbit.status === 'for_sale' ? 'Mark Active' : 'Mark For Sale'}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" disabled={updating} onClick={handleArchive}>
|
||||
Archive
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled={updating} onClick={handleDelete} className="text-danger">
|
||||
Delete
|
||||
</Button>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
@ -269,23 +273,32 @@ function RabbitDetail() {
|
|||
<DetailColumn rabbit={rabbit} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-6 xl:flex-row xl:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-6">
|
||||
<SectionCard
|
||||
title="Pedigree"
|
||||
action={
|
||||
<Link to={`/herd/${rabbit.id}/pedigree`} className="font-body text-sm text-text-secondary hover:text-accent">
|
||||
Full pedigree
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{ancestors ? (
|
||||
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} generations={2} />
|
||||
) : (
|
||||
<p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||
<p className="p-4 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||
)}
|
||||
</Card>
|
||||
</SectionCard>
|
||||
|
||||
<LitterHistory rabbitId={rabbitId} />
|
||||
</div>
|
||||
|
||||
<div className="w-full xl:w-[340px] xl:shrink-0">
|
||||
<Card className="overflow-hidden">
|
||||
<RabbitTimeline rabbit={rabbit} />
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" disabled={updating} onClick={handleDelete} className="text-danger">
|
||||
Delete Rabbit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Settings" />
|
||||
<PageHeader title="Settings" subtitle="Loading…" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -125,6 +128,11 @@ function Settings() {
|
|||
<PageHeader title="Settings" />
|
||||
<div className="space-y-6">
|
||||
<Card className="space-y-4 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Rabbitry</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">Shared by everyone who signs in.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label>
|
||||
<input
|
||||
|
|
@ -135,26 +143,6 @@ function Settings() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Weight Display</label>
|
||||
<div className="mt-1 flex gap-1">
|
||||
{WEIGHT_UNIT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Kit Prefix</label>
|
||||
<input
|
||||
|
|
@ -180,6 +168,36 @@ function Settings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => handleSave(['rabbitry_name', 'kit_prefix', 'default_breed'])} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Rabbitry Settings'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Your Preferences</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">Only affect your account.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Weight Display</label>
|
||||
<div className="mt-1 flex gap-1">
|
||||
{WEIGHT_UNIT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Theme</label>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||
|
|
@ -220,28 +238,29 @@ function Settings() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
<Button onClick={() => handleSave(['weight_unit', 'theme'])} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Preferences'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Export Database</h2>
|
||||
<h2 className="font-display text-lg text-text-primary">Back Up</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={handleExport}>
|
||||
Export Database
|
||||
Download Backup
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Import Data</h2>
|
||||
<h2 className="font-display text-lg text-text-primary">Import Herd</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
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 —{' '}
|
||||
<span className="font-medium text-danger">all current rabbits and litters are deleted first.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<Link to="/shows">
|
||||
<Button variant="primary">Browse Shows</Button>
|
||||
</Link>
|
||||
<Button to="/shows" variant="primary">Browse Shows</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
|
@ -651,9 +649,7 @@ function ShowEventDetail() {
|
|||
subtitle={`${shows.length} show${shows.length === 1 ? '' : 's'} · ${entryCount} entr${entryCount === 1 ? 'y' : 'ies'}`}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link to="/shows">
|
||||
<Button variant="secondary">Add Shows</Button>
|
||||
</Link>
|
||||
<Button to="/shows" variant="secondary">Add Shows</Button>
|
||||
<Button variant="secondary" onClick={() => setEditing((v) => !v)}>
|
||||
Edit
|
||||
</Button>
|
||||
|
|
@ -695,9 +691,7 @@ function ShowEventDetail() {
|
|||
title="No shows in this event"
|
||||
description="Add shows from the browser to start planning."
|
||||
action={
|
||||
<Link to="/shows">
|
||||
<Button variant="primary">Browse Shows</Button>
|
||||
</Link>
|
||||
<Button to="/shows" variant="primary">Browse Shows</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -86,9 +86,7 @@ function ShowEvents() {
|
|||
subtitle="Your planned show weekends and the rabbits you’ll enter"
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/shows">
|
||||
<Button variant="secondary">Browse Shows</Button>
|
||||
</Link>
|
||||
<Button to="/shows" variant="secondary">Browse Shows</Button>
|
||||
<Button variant="primary" onClick={() => setCreating((v) => !v)}>
|
||||
New Event
|
||||
</Button>
|
||||
|
|
@ -110,9 +108,7 @@ function ShowEvents() {
|
|||
title="No events yet"
|
||||
description="Create an event, then add shows to it from the browser."
|
||||
action={
|
||||
<Link to="/shows">
|
||||
<Button variant="primary">Browse Shows</Button>
|
||||
</Link>
|
||||
<Button to="/shows" variant="primary">Browse Shows</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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)' : ''}
|
||||
</span>
|
||||
<Link to="/shows/events">
|
||||
<Button variant="secondary">My Events</Button>
|
||||
</Link>
|
||||
<Button to="/shows/events" variant="secondary">My Events</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => refresh.mutate()}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue