diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 219553b..3bb01fa 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -26,11 +26,11 @@ docker-compose.yml — orchestrates both services for self-hosted deployment | `package.json` scripts | `dev` — `tsx watch index.ts` (hot-reload local dev, no separate build step needed). `build` — `tsc` compiles to `dist/`. `start` — runs the compiled output, used in production/Docker. | | `Dockerfile` | Builds a Node 20 Alpine image, installs deps, runs `npm run dev` (tsx) inside the container — not yet switched to a production build for the container; revisit when deploying for real. | | `.dockerignore` | Excludes `node_modules` and `data/` from the Docker build context. | -| `routes/rabbits.ts` | Express `Router` mounted at `/api/rabbits` in `index.ts`. `GET /` (list, with `sex`/`status`/`search`/`sort` filters — `sex=kits` is a special case matching any sex with `dob` in the last 56 days), `GET /search` (autocomplete by *substring* on name only, must be registered before `GET /:id`), `GET /quick-search` (the global top-of-page quick-search bar — distinct from `/search`: a **prefix** match — "wildcard at the end" — across a chooseable subset of `farm`/`name`/`ear_number`/`color` via `?fields=` comma-list, defaulting to all four; returns each hit plus a `matched_fields` array re-derived in JS so the dropdown can label which filter the row flagged on. Empty `q` or no valid fields → `[]`. Also must be registered before `GET /:id`), `GET /:id` (404 if missing), `POST /` (validates `name`, 201 on success), `PATCH /:id` (partial update, refreshes `updated_at`), `DELETE /:id` (nulls referencing `sire_id`/`dam_id` before deleting), `GET /:id/ancestors` (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries `id`/`name`/`farm`/`sex`/`ear_number`/`weight_lbs`/`dob`/`color_name` — `ear_number`/`weight_lbs`/`dob` were added so the printable pedigree slip can show full per-ancestor detail), `POST /:id/photo` (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so `fileFilter` rejections become this route's own 400 rather than the global error handler's 500). | -| `routes/litters.ts` | Express `Router` mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice (via `alias()`) to embed sire/dam `{ id, name }`. `GET /:id` derives its `kits` list from `rabbits` matching `sire_id`/`dam_id`/`dob` (the same no-Kit-table derivation the design doc specifies elsewhere). `POST /` (requires a `breed_date` or `kindle_date`; only generates kits when a `kindle_date` is present), `PATCH /:id`, `DELETE /:id`. Recording a breeding (`POST` with `breed_date`, no `kindle_date`) creates a litter-in-progress with no kits yet. | +| `routes/rabbits.ts` | Express `Router` mounted at `/api/rabbits` in `index.ts`. `GET /` (list, with `sex`/`status`/`search`/`sort` filters — `search` is a substring match across name, ear number and color — `sex=kits` is a special case matching any sex with `dob` in the last 56 days), `GET /search` (autocomplete by *substring* on name only, must be registered before `GET /:id`), `GET /quick-search` (the global top-of-page quick-search bar — distinct from `/search`: a **prefix** match — "wildcard at the end" — across a chooseable subset of `farm`/`name`/`ear_number`/`color` via `?fields=` comma-list, defaulting to all four; returns each hit plus a `matched_fields` array re-derived in JS so the dropdown can label which filter the row flagged on. Empty `q` or no valid fields → `[]`. Also must be registered before `GET /:id`), `GET /:id` (404 if missing), `POST /` (validates `name`, 201 on success), `PATCH /:id` (partial update, refreshes `updated_at`), `DELETE /:id` (nulls referencing `sire_id`/`dam_id` before deleting), `GET /:id/ancestors` (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries `id`/`name`/`farm`/`sex`/`ear_number`/`weight_lbs`/`dob`/`color_name` — `ear_number`/`weight_lbs`/`dob` were added so the printable pedigree slip can show full per-ancestor detail), `POST /:id/photo` (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so `fileFilter` rejections become this route's own 400 rather than the global error handler's 500). | +| `routes/litters.ts` | Express `Router` mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice (via `alias()`) to embed sire/dam `{ id, name }`. `GET /:id` derives its `kits` list from `rabbits` matching `sire_id`/`dam_id`/`dob` (the same no-Kit-table derivation the design doc specifies elsewhere). `POST /` (requires a `breed_date` or `kindle_date`; only generates kits when a `kindle_date` is present), `PATCH /:id`, `DELETE /:id`. `PATCH` keeps kits consistent with the lifecycle: recording the first `kindle_date` on a breeding (with `kit_count > 0`) generates kits exactly as `POST` does, and changing `kindle_date`/`sire_id`/`dam_id` on an already-kindled litter re-points the kits that matched the old triple so they aren't orphaned. Generated kits get `farm` = `settings.rabbitry_name` and `breed` = the dam's (else sire's) breed. Recording a breeding (`POST` with `breed_date`, no `kindle_date`) creates a litter-in-progress with no kits yet. | | `routes/events.ts` | Express `Router` mounted at `/api/events` (behind `requireAuth`). Manually-logged timeline events (`show`/`sale`/`other`). `GET /?rabbit_id=X` (required filter — no all-events view; ordered oldest-first so the client can splice them straight into the timeline), `POST /` (requires `rabbit_id`/`type`/`event_date`/`title`; a `sale` also flips the rabbit's `status` to `sold` in the **same transaction** as the insert), `PATCH /:id` (drops `rabbit_id` **and** `type` from the patch — an event keeps its rabbit and kind), `DELETE /:id`. Same mass-assignment guard + snake/camel boundary conversion as the other routers. | | `routes/shows.ts` | Express `Router` mounted at `/api/shows` (behind `requireAuth`) — Phase 1 of the Shows feature (browse-only). `GET /arba` returns the cached ARBA listing as `{ fetched_at, stale, shows: [...] }`, re-scraping first if the cache is missing or older than 24h; if a needed scrape fails but a cache exists, it serves the **stale** cache (flagged `stale: true`) rather than erroring (only a cold miss returns 502). `POST /arba/refresh` forces a re-scrape, leaving the old cache intact on failure. The cache is a single row in `arba_shows_cache` (delete-then-insert in a transaction). Show objects are stored camelCase (from `lib/arba.ts`) and converted to snake_case at the boundary like every other route. | -| `routes/showEvents.ts` | Express `Router` mounted at `/api/shows/events` (behind `requireAuth`) — Phase 2 of the Shows feature. Event CRUD (`GET /` list with derived `show_count`/`first_show_date`/`last_show_date` from one grouped aggregate query, newest-`updated_at` first; `GET /:id` returns the event + its snapshotted `shows` in date order; `POST /`, `PATCH /:id`, `DELETE /:id`) plus `POST /:id/shows` (snapshots an ARBA show into the event — accepts the snake_case ARBA show object `GET /api/shows/arba` returns and maps it: `secretary_email`←`email`, `show_type`←`show_type_full`, `show_date`←`date`‖`raw_date`, `specialty_breed`←`special_breed`; **idempotent** per (event, `sanction_number`) — re-adding returns the existing row with 200) and `DELETE /:id/shows/:showId`. Add/remove also bump the event's `updated_at`. **Mounted before `/api/shows`** in `index.ts` so `/api/shows/events/*` resolves here, not to the ARBA browser router. **Phase 3 (entries)** adds: `GET /:id` now embeds an `entries` array per show (each entry left-joined to its rabbit's `name`/`ear_number`/`breed`/`color_name`/`sex` via `entriesByShow`); `POST`/`PATCH`/`DELETE /:id/shows/:showId/entries[/:entryId]` (PATCH only touches `entry_class`/`entry_number`/`notes` — never the rabbit or which show); and `GET /:id/report`, which builds the plain-text entries report (`{ report }`) — one section per show that has entries, owner line from `settings.rabbitry_name`, ARBA membership # deliberately omitted (resolved decision), dates rendered MM/DD/YYYY. **Phase 4 (judges + placements)** adds: `GET /:id` also embeds a `judges` array per show and a `placement` object (or null) per entry; `POST`/`PATCH`/`DELETE /:id/shows/:showId/judges[/:judgeId]`; and `PUT /:id/shows/:showId/entries/:entryId/placement` — a **full-object upsert** keyed on the entry's UNIQUE placement row (legs clamped 0–3, the four flags coerced to booleans). | +| `routes/showEvents.ts` | Express `Router` mounted at `/api/shows/events` (behind `requireAuth`) — Phase 2 of the Shows feature. `GET /entries?rabbit_id=X` (registered before `/:id`) returns one rabbit's entries across all events with event/show info + placement — feeds the rabbit timeline. Event CRUD (`GET /` list with derived `show_count`/`first_show_date`/`last_show_date` from one grouped aggregate query, newest-`updated_at` first; `GET /:id` returns the event + its snapshotted `shows` in date order; `POST /`, `PATCH /:id`, `DELETE /:id`) plus `POST /:id/shows` (snapshots an ARBA show into the event — accepts the snake_case ARBA show object `GET /api/shows/arba` returns and maps it: `secretary_email`←`email`, `show_type`←`show_type_full`, `show_date`←`date`‖`raw_date`, `specialty_breed`←`special_breed`; **idempotent** per (event, `sanction_number`) — re-adding returns the existing row with 200) and `DELETE /:id/shows/:showId`. Add/remove also bump the event's `updated_at`. **Mounted before `/api/shows`** in `index.ts` so `/api/shows/events/*` resolves here, not to the ARBA browser router. **Phase 3 (entries)** adds: `GET /:id` now embeds an `entries` array per show (each entry left-joined to its rabbit's `name`/`ear_number`/`breed`/`color_name`/`sex` via `entriesByShow`); `POST`/`PATCH`/`DELETE /:id/shows/:showId/entries[/:entryId]` (PATCH only touches `entry_class`/`entry_number`/`notes` — never the rabbit or which show); and `GET /:id/report`, which builds the plain-text entries report (`{ report }`) — one section per show that has entries, owner line from `settings.rabbitry_name`, ARBA membership # deliberately omitted (resolved decision), dates rendered MM/DD/YYYY. **Phase 4 (judges + placements)** adds: `GET /:id` also embeds a `judges` array per show and a `placement` object (or null) per entry; `POST`/`PATCH`/`DELETE /:id/shows/:showId/judges[/:judgeId]`; and `PUT /:id/shows/:showId/entries/:entryId/placement` — a **full-object upsert** keyed on the entry's UNIQUE placement row (legs clamped 0–3, the four flags coerced to booleans). | | `lib/arba.ts` | The ARBA scraper/normalizer. `fetchArbaShows()` fetches `https://arba.net/show-search/` and parses it with **cheerio**. Two non-obvious constraints, both load-bearing (see convention note below): (1) ARBA sits behind **Cloudflare** — the request MUST send browser-like `User-Agent` + `Accept` headers or it gets a block page (we deliberately don't set `Accept-Encoding`, letting global-fetch own decompression); (2) the page is a WordPress **Ninja Tables** table whose ~2,000 rows are server-rendered as plain **positional `` cells** (the cells carry no column classes — only the `` does), so rows are read by cell index 0–10. `parseShowInformation()` splits the `showInformation` string (`CLUB, (OPEN\|YOUTH) (ALL BREED\|SPECIALTY) [FAIR] SHOW[, BREED]`) by anchoring on the show-type phrase (club names contain commas) into `clubName`/`showType` bucket/`showTypeFull`/`specialBreed`. Cavy shows (`CAVY` anywhere in `showInformation`) and non-US rows (state not in the 50+DC allowlist — excludes Canadian provinces and blanks) are dropped at normalization. Throws on a Cloudflare block / non-OK / missing table so the route can fall back to cache. | | `routes/settings.ts` | Express `Router` mounted at `/api/settings`. `GET /` flattens the `settings` table into a single object; `PATCH /` rejects any request containing a key outside the `rabbitry_name`/`weight_unit` allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly. | | `routes/stats.ts` | Express `Router` mounted at `/api/stats`. Returns dashboard counts (`herd`, `bucks`, `does`, `kits`, `for_sale`, `on_hold`, `sold`, `archived`) computed live from `rabbits` on every request — no cached counters. `kits` reuses the same 56-day `dob` window as the rabbits list's `sex=kits` filter; `archived` sums `status IN ('other', 'deceased')`. | @@ -48,9 +48,11 @@ docker-compose.yml — orchestrates both services for self-hosted deployment |---|---| | `src/App.jsx` | Root component. Wraps the app in `QueryClientProvider` (React Query) and `BrowserRouter`, defining every IA route nested under a single `Layout` layout-route (`}>`) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all `*` → `NotFound` route. | | `src/api.js` | Plain JS (not `.ts` — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, events CRUD (`getEvents`/`createEvent`/`updateEvent`/`deleteEvent`), settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`, shows (`getArbaShows`/`refreshArbaShows`), show events (`getShowEvents`/`getShowEvent`/`createShowEvent`/`updateShowEvent`/`deleteShowEvent`/`addShowToEvent`/`removeShowFromEvent`), show entries + report (`addShowEntry`/`updateShowEntry`/`removeShowEntry`/`getEventReport`), judges + placements (`addShowJudge`/`updateShowJudge`/`removeShowJudge`/`setShowEntryPlacement`)), all targeting `http://localhost:3001/api`. Shared `request()` helper throws on non-2xx (surfacing the JSON `{ error }` body's message) and only sets `Content-Type: application/json` when the body is a JSON string, so the multipart `FormData` photo-upload call isn't given the wrong header. | -| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's ``. Desktop: 240px left sidebar (`hidden md:flex`) with `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`, icon + label, 56px tall, padded by `env(safe-area-inset-bottom)`) with Dashboard/Herd/Add (→ `/herd/add`)/Litters/More — "More" opens a small menu for Genetics/Shows/Settings so every page is reachable on a phone. The sidebar is `sticky` and the content column is `min-w-0` + `max-w-6xl`, so wide children (tables, the 900px pedigree tree) scroll inside their own containers instead of stretching the page. `
` owns the page gutter (`px-4 md:px-8`); pages must not add their own horizontal padding. Active-route styling comes from `NavLink`'s `isActive` callback, not manual path matching. Also renders `` at the top of the `
` content area (above ``), so the quick-search bar appears on every page. | -| `src/components/QuickSearch.jsx` | Global quick-search bar (top of every page via `Layout`). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), **all selected by default**, controlling which fields `GET /api/rabbits/quick-search` prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per `matched_fields` entry (value-bearing chips for ear/color, e.g. `Ear #: A15`). Selecting a result navigates to `/herd/:id`. Reuses `lib/keyboardNav.js`'s `handleSearchInputKeyDown`/`handleResultKeyDown` for the same `