From 4544aa8060941c85cc70be934686cdbc3e6d6331 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 22 Jun 2026 23:13:30 -0400 Subject: [PATCH] Add ARBA Shows feature (browse, events, entries, judges, placements) Adds a self-hosted Shows section that scrapes the ARBA show-search listing and lets the user build show events, enter herd rabbits, and track judges and placements. Server: - lib/arba.ts: Cloudflare-aware scraper + normalizer (cheerio), US-only, cavy-excluded; cached in arba_shows_cache (re-scrape when >24h stale or on demand). Rows are read from server-rendered Ninja Tables cells. - routes/shows.ts: cached ARBA listing + force refresh. - routes/showEvents.ts: event CRUD, ARBA-show snapshots, entries, judges, placements (PUT upsert), and the plain-text entries report. - schema: show_events, show_event_shows, show_event_entries, show_event_judges, show_event_placements; breed column on rabbits. - migrations 0007-0010. Client: - /shows browser with filters + 'Add to Event'. - /shows/events list + detail with Shows / Entries / Results tabs, rabbit picker, inline class/notes, placement entry, and report modal. - breed surfaced in add/edit/detail/review. Also folds in two previously-uncommitted bodies of work that share schema.ts / index.ts / api.js with the above and can't be split out cleanly: the rabbit timeline (rabbit_events, migrations 0004-0006, routes/events.ts, RabbitTimeline) and the breed-select / default-breed herd work (BreedSelect, lib/breeds.js, settings default_breed). Co-Authored-By: Claude Opus 4.8 --- memory-bank/architecture.md | 19 +- memory-bank/mykindlebox-shows-design-doc.md | 381 ++++++ mykindlebox/client/src/App.jsx | 6 + mykindlebox/client/src/api.js | 50 + .../client/src/components/AddToEventModal.jsx | 170 +++ .../client/src/components/BreedSelect.jsx | 101 ++ mykindlebox/client/src/components/Layout.jsx | 3 + .../src/components/PedigreeTreeReadOnly.jsx | 17 +- .../src/components/RabbitDetailsForm.jsx | 27 + .../client/src/components/RabbitPicker.jsx | 96 ++ .../client/src/components/RabbitTimeline.jsx | 394 +++++++ .../client/src/components/ReportModal.jsx | 63 + .../client/src/components/ReviewStep.jsx | 1 + mykindlebox/client/src/index.css | 22 + mykindlebox/client/src/lib/breeds.js | 64 ++ mykindlebox/client/src/pages/AddRabbit.jsx | 15 +- mykindlebox/client/src/pages/EditRabbit.jsx | 3 + mykindlebox/client/src/pages/RabbitDetail.jsx | 28 +- mykindlebox/client/src/pages/Settings.jsx | 13 + .../client/src/pages/ShowEventDetail.jsx | 720 ++++++++++++ mykindlebox/client/src/pages/ShowEvents.jsx | 140 +++ mykindlebox/client/src/pages/Shows.jsx | 380 ++++++ mykindlebox/server/db/client.ts | 3 + .../migrations/0004_add_timeline_events.sql | 14 + .../server/db/migrations/0005_drop_shows.sql | 1 + .../db/migrations/0006_add_rabbit_events.sql | 13 + .../0007_add_breed_and_arba_cache.sql | 7 + .../db/migrations/0008_add_show_events.sql | 26 + .../0009_add_show_event_entries.sql | 11 + .../migrations/0010_add_judges_placements.sql | 23 + .../db/migrations/meta/0004_snapshot.json | 547 +++++++++ .../db/migrations/meta/0005_snapshot.json | 466 ++++++++ .../db/migrations/meta/0006_snapshot.json | 559 +++++++++ .../db/migrations/meta/0007_snapshot.json | 598 ++++++++++ .../db/migrations/meta/0008_snapshot.json | 775 +++++++++++++ .../db/migrations/meta/0009_snapshot.json | 862 ++++++++++++++ .../db/migrations/meta/0010_snapshot.json | 1021 +++++++++++++++++ .../server/db/migrations/meta/_journal.json | 49 + mykindlebox/server/db/schema.ts | 130 +++ mykindlebox/server/index.ts | 8 + mykindlebox/server/lib/arba.ts | 201 ++++ mykindlebox/server/package-lock.json | 304 +++++ mykindlebox/server/package.json | 1 + mykindlebox/server/routes/events.ts | 113 ++ mykindlebox/server/routes/settings.ts | 2 +- mykindlebox/server/routes/showEvents.ts | 657 +++++++++++ mykindlebox/server/routes/shows.ts | 94 ++ 47 files changed, 9172 insertions(+), 26 deletions(-) create mode 100644 memory-bank/mykindlebox-shows-design-doc.md create mode 100644 mykindlebox/client/src/components/AddToEventModal.jsx create mode 100644 mykindlebox/client/src/components/BreedSelect.jsx create mode 100644 mykindlebox/client/src/components/RabbitPicker.jsx create mode 100644 mykindlebox/client/src/components/RabbitTimeline.jsx create mode 100644 mykindlebox/client/src/components/ReportModal.jsx create mode 100644 mykindlebox/client/src/lib/breeds.js create mode 100644 mykindlebox/client/src/pages/ShowEventDetail.jsx create mode 100644 mykindlebox/client/src/pages/ShowEvents.jsx create mode 100644 mykindlebox/client/src/pages/Shows.jsx create mode 100644 mykindlebox/server/db/migrations/0004_add_timeline_events.sql create mode 100644 mykindlebox/server/db/migrations/0005_drop_shows.sql create mode 100644 mykindlebox/server/db/migrations/0006_add_rabbit_events.sql create mode 100644 mykindlebox/server/db/migrations/0007_add_breed_and_arba_cache.sql create mode 100644 mykindlebox/server/db/migrations/0008_add_show_events.sql create mode 100644 mykindlebox/server/db/migrations/0009_add_show_event_entries.sql create mode 100644 mykindlebox/server/db/migrations/0010_add_judges_placements.sql create mode 100644 mykindlebox/server/db/migrations/meta/0004_snapshot.json create mode 100644 mykindlebox/server/db/migrations/meta/0005_snapshot.json create mode 100644 mykindlebox/server/db/migrations/meta/0006_snapshot.json create mode 100644 mykindlebox/server/db/migrations/meta/0007_snapshot.json create mode 100644 mykindlebox/server/db/migrations/meta/0008_snapshot.json create mode 100644 mykindlebox/server/db/migrations/meta/0009_snapshot.json create mode 100644 mykindlebox/server/db/migrations/meta/0010_snapshot.json create mode 100644 mykindlebox/server/lib/arba.ts create mode 100644 mykindlebox/server/routes/events.ts create mode 100644 mykindlebox/server/routes/showEvents.ts create mode 100644 mykindlebox/server/routes/shows.ts diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 53931d2..946d539 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -19,7 +19,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment |---|---| | `index.ts` | Entry point. Creates the Express app, applies `cors()` (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and `express.json()`, imports `db/client` (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001. | | `db/client.ts` | Opens the SQLite connection and exports the Drizzle `db` instance used by all query code. Ensures `server/data/` exists before opening the file — `better-sqlite3` will not create missing parent directories on its own. The DB file lives at `server/data/mykindlebox.db`. On startup it now (Step 2.5) calls `drizzle-orm/better-sqlite3/migrator`'s `migrate()` to apply any pending migrations, then seeds the two default `settings` rows (`rabbitry_name`, `weight_unit`) via raw `INSERT OR IGNORE` keyed on the `key` primary key — idempotent across restarts. Also exports `dataDir` and `uploadsDir` (`data/uploads/`, created here) so other modules (`routes/rabbits.ts`'s multer config, `index.ts`'s static file serving) share one canonical path instead of each recomputing it. | -| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields, 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). | +| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields incl. `acquired_date`/`death_date` — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), `rabbit_events` (`rabbit_id` FK with `ON DELETE cascade`, `type` (`show`/`sale`/`other`, CHECK-constrained), `event_date`/`title` notNull, `notes`, plus `show_class`/`placement` which are only meaningful for `type='show'` — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). The `rabbits` table also carries a nullable `breed` column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). `arba_shows_cache` (`id`, `fetched_at`, `data` — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see `lib/arba.ts` + `routes/shows.ts`). `show_events` (`name` notNull, `notes`, `created_at`/`updated_at` — a user-created grouping of ARBA shows; **no `user_id`**, single-user app) and `show_event_shows` (`show_event_id` FK with `ON DELETE cascade`, plus a **snapshot** of the ARBA row at add-time: `sanction_number`/`club_name` notNull, `show_date`/`show_type`/`specialty_breed`/`city`/`state`/`address`/`secretary`/`secretary_email`/`catalog_url`/`maps_url`/`display_order` — copied rather than referencing the cache because ARBA listings are transient). `show_event_entries` (a herd rabbit entered into a specific show: `show_event_show_id` FK `ON DELETE cascade`, `rabbit_id` FK to `rabbits` **without** cascade, `entry_class`/`entry_number`/`notes` free text, `created_at`). `show_event_judges` (`show_event_show_id` cascade FK, `judge_name` notNull, `judge_arba_number`/`notes`). `show_event_placements` (one per entry — `entry_id` is a **UNIQUE** cascade FK, making the PUT endpoint a clean upsert; `placement` free text, `legs_earned` int 0–3, `bob`/`bos`/`bis`/`ris` boolean-mode integers, `notes`). The Shows feature's data model is now complete (Phases 1–4). | | `db/migrations/` | Generated by `drizzle-kit generate`, applied either manually via `drizzle-kit migrate` (CLI) or automatically on server startup via `db/client.ts`'s in-code `migrate()` call. Both share the same `__drizzle_migrations` tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (`0000_modern_power_pack.sql` — rabbits, `0001_military_lord_tyger.sql` — litters, `0002_luxuriant_the_stranger.sql` — settings) — these are committed and intentionally **not** excluded by `.dockerignore` (only `node_modules` and `data` are). | | `drizzle.config.ts` | Points `drizzle-kit` at `./db/schema.ts` for migration generation and `./data/mykindlebox.db` as the target DB. | | `tsconfig.json` | TypeScript config: CommonJS module output (matches `package.json`'s `"type": "commonjs"`), strict mode on, compiles to `dist/`. | @@ -28,6 +28,10 @@ docker-compose.yml — orchestrates both services for self-hosted deployment | `.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/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). | +| `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')`. | | `routes/import.ts` | Express `Router` mounted at `/api/import` (behind `requireAuth`). `POST /` accepts a single multipart `file` (multer **memory** storage, 10 MB cap) and **replaces the entire herd**: it wipes `litters` then `rabbits` and inserts the file's rabbits, all inside one `db.transaction` so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: **legacy CSV** (the old PHP Kindlebox export — header has `ID`/`GENDER`/`SIRE_ID`), **native CSV** (snake_case headers matching the API), and **JSON** (a rabbit array or `{ rabbits: [...] }`). Legacy mapping: `GENDER` 1→buck/0→doe; numeric `STATUS` via `LEGACY_STATUS_MAP` (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original `editrabbit.php` status `` + `