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 <noreply@anthropic.com>
This commit is contained in:
parent
77cb71b7aa
commit
4544aa8060
|
|
@ -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 `<td>` cells** (the cells carry no column classes — only the `<thead>` does), so rows are read by cell index 0–10. `parseShowInformation()` splits the `showInformation` string (`CLUB, (OPEN\|YOUTH) (ALL BREED\|SPECIALTY) [FAIR] SHOW[, BREED]`) by anchoring on the show-type phrase (club names contain commas) into `clubName`/`showType` bucket/`showTypeFull`/`specialBreed`. Cavy shows (`CAVY` anywhere in `showInformation`) and non-US rows (state not in the 50+DC allowlist — excludes Canadian provinces and blanks) are dropped at normalization. Throws on a Cloudflare block / non-OK / missing table so the route can fall back to cache. |
|
||||
| `routes/settings.ts` | Express `Router` mounted at `/api/settings`. `GET /` flattens the `settings` table into a single object; `PATCH /` rejects any request containing a key outside the `rabbitry_name`/`weight_unit` allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly. |
|
||||
| `routes/stats.ts` | Express `Router` mounted at `/api/stats`. Returns dashboard counts (`herd`, `bucks`, `does`, `kits`, `for_sale`, `on_hold`, `sold`, `archived`) computed live from `rabbits` on every request — no cached counters. `kits` reuses the same 56-day `dob` window as the rabbits list's `sex=kits` filter; `archived` sums `status IN ('other', 'deceased')`. |
|
||||
| `routes/import.ts` | Express `Router` mounted at `/api/import` (behind `requireAuth`). `POST /` accepts a single multipart `file` (multer **memory** storage, 10 MB cap) and **replaces the entire herd**: it wipes `litters` then `rabbits` and inserts the file's rabbits, all inside one `db.transaction` so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: **legacy CSV** (the old PHP Kindlebox export — header has `ID`/`GENDER`/`SIRE_ID`), **native CSV** (snake_case headers matching the API), and **JSON** (a rabbit array or `{ rabbits: [...] }`). Legacy mapping: `GENDER` 1→buck/0→doe; numeric `STATUS` via `LEGACY_STATUS_MAP` (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original `editrabbit.php` status `<select>`); `"0"`/blank sentinels for ear/registration numbers → null; `WEIGHT` of 0 → null (unweighed); gene columns → `locus_*`. Parentage is preserved by importing **original ids** and a **two-pass insert** (insert all rows with null `sire_id`/`dam_id`, then set the FK only when it points at an id actually present in the file — legacy `9999` "unknown parent" sentinels and references to non-imported rabbits become null). Includes a hand-rolled RFC-4180-ish `parseCsv` (quoted fields, `""` escapes, CR/LF, BOM strip) rather than adding a CSV dependency, matching the project's dependency-light ethos. Returns `{ format, rabbits_imported }`. **Scope is rabbits only** (per user decision) — litters are wiped but not reconstructed from the legacy `LITTER_ID` column. Rejects non-UTF-8 input early (`file.buffer.includes(0)` — a NUL byte means a UTF-16 Excel "Unicode Text" export or a binary `.xlsx`/image) with an actionable message, since decoding such a file as UTF-8 would otherwise import garbage/phantom rows. The Settings page also pre-validates extension + 10 MB size **before** uploading, because an over-limit file is rejected by multer mid-stream and the browser surfaces that as an opaque "network error" rather than the server's 400. |
|
||||
|
|
@ -40,8 +44,8 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/App.jsx` | Root component. Wraps the app in `QueryClientProvider` (React Query) and `BrowserRouter`, defining every IA route nested under a single `Layout` layout-route (`<Route element={<Layout />}>`) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all `*` → `NotFound` route. |
|
||||
| `src/api.js` | Plain JS (not `.ts` — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`), 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/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`) with Dashboard/Herd/Litters/Add (→ `/herd/add`)/Settings. 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/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`) with Dashboard/Herd/Litters/Add (→ `/herd/add`)/Settings. 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/Badge.jsx` | Status → color/label class map covering all six Rabbit `status` values; unrecognized values fall back to the `other` style rather than crashing. |
|
||||
|
|
@ -49,7 +53,10 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
| `src/components/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. |
|
||||
| `src/components/EmptyState.jsx` | Centered title + optional description + optional action, used by list pages with no results. |
|
||||
| `src/pages/*.jsx` | One file per IA route (`Dashboard`, `Herd`, `AddRabbit`, `RabbitDetail`, `EditRabbit`, `Litters`, `AddLitter`, `LitterDetail`, `Genetics`, `Settings`, `NotFound`). `Dashboard.jsx` is built out (Phase 5): a stats bar (8 `Card` tiles from `GET /api/stats`, each linking to `/herd` with a sex or status query param), a quick-actions row (`Button`s linking to `/herd/add`, `/litters/add`, `/herd`), and a two-column (`md:grid-cols-2`) widget row with Recent Litters (top 3 by `kindle_date` from `GET /api/litters`) and Upcoming Birthdays (a user-requested addition not in the original plan — computed client-side from `GET /api/rabbits?status=active`'s `dob` field, no backend support; see the "Upcoming Birthdays" convention note below). `Herd.jsx` is built out (Phase 6): a sex/sort/search/view filter bar synced to URL query params (`?sex=&sort=&q=&view=`), and a `RabbitGrid`/`RabbitListView` pair sharing one `useQuery` over `GET /api/rabbits` — see the Herd filtering convention note below. `AddRabbit.jsx` (Phase 7) is a 3-step wizard (Pedigree → Details+Genetics → Review) composing the Step 7.1–7.4 components, holding `details`/`genetics`/`pedigreeFields` state and calling `createRabbit` then (if a photo was picked) `uploadRabbitPhoto` on save. `EditRabbit.jsx` (Phase 7) is a single page with all three sections visible, pre-populated from `GET /api/rabbits/:id` + `GET /api/rabbits/:id/ancestors`, sending only the changed fields via `PATCH` — see the pedigree convention note below for why it must wait on the ancestors fetch before mounting `PedigreeTree`. All other pages remain placeholders rendering just a heading — real content for them starts in Phase 8+. |
|
||||
| `src/pages/Shows.jsx` | The `/shows` ARBA show browser (Shows feature Phase 1 — browse-only, no event/entry actions yet). One `useQuery(['arba-shows'])` over `getArbaShows`; a "Refresh Data" button mutates via `refreshArbaShows` and writes the result straight into the query cache (`setQueryData`). All filtering/pagination is **client-side** over the loaded list: State/District/Show Type multi-selects (an inline `MultiSelect` checkbox-popover component with outside-click close; State/District options derived from the data, Show Type a fixed 5-bucket list matching `lib/arba.ts`'s `ShowType`), Date From (defaults to today) / Date To, and a keyword box (club/city/secretary). 50 rows/page. Reads the snake_case show fields the API returns (`raw_date`, `club_name`, `show_type`/`show_type_full`, `special_breed`, `maps_url`, `catalog_url`, etc.). |
|
||||
| `src/pages/ShowEvents.jsx` + `src/pages/ShowEventDetail.jsx` | The Shows feature Phase 2 event UI. `ShowEvents.jsx` (`/shows/events`) lists events as cards (name, date range, show count) over `getShowEvents`, with an inline `NewEventForm` toggled by "New Event". `ShowEventDetail.jsx` (`/shows/events/:id`) shows the event header (inline `EditEventForm` for rename/notes, Delete with a `window.confirm`, "Add Shows" link back to `/shows`) and the **Shows tab** (the only tab in Phase 2 — judges/entries/placements come later): each `ShowCard` renders the snapshot (club, date, type, sanction #, location, secretary, mailto/map/catalog links) with a Remove button. Mutations invalidate `['show-events']` and `['show-event', id]`. The "Add to Event" flow lives in `components/AddToEventModal.jsx` (launched per-row from `Shows.jsx`): pick an existing event or create one inline, then `addShowToEvent`; shows a success state with a link to the event. **Phase 3** adds a Shows/Entries tab switch to `ShowEventDetail.jsx`: the **Entries tab** groups entries per show, with an "Add Rabbit" picker (`components/RabbitPicker.jsx` — a modal that client-side searches the herd by name/tattoo/breed/variety and disables rabbits already entered in that show), inline-editable `entry_class`/`notes` (saved on blur via `updateShowEntry`), a remove action, and an "in multiple shows" badge for a rabbit entered across more than one show in the event. "Generate Entries Report" opens `components/ReportModal.jsx`, which fetches `getEventReport` and offers Copy to Clipboard. **Phase 4** adds a third **Results** tab plus: a `JudgesSection` on each Shows-tab card (list + inline add/remove of judges), and a per-entry `PlacementEditor` (toggled by an "Add/Edit results" link in the Entries tab) — placement text, a legs +/- counter (0–3), and BOB/BOS/BIS/RIS checkboxes that persist immediately via `setShowEntryPlacement` (the full-object PUT; legs/checkboxes save on change, text on blur). The **Results tab** (`ResultsTab`) is a read-only rollup aggregated client-side from the embedded placements — one card per rabbit, sorted by total legs, with leg/flag badges. |
|
||||
| `src/pages/PedigreePrint.jsx` | Standalone print-optimized 4-generation pedigree slip at `/herd/:id/pedigree`, deliberately routed **outside** `<Layout>` (in `App.jsx`) so the sidebar/bottom-nav don't render or print. Reuses `GET /api/rabbits/:id/ancestors` + `GET /api/settings` (rabbitry name) + `getRabbit`. Modeled on the original Kindlebox PHP slip. Renders a rabbitry header band (rabbitry name, subject farm/name, printed-on date) above a CSS-grid bracket layout (subject column + parents + grandparents + great-grandparents, each generation's row-spans aligning it against the next). The leftmost column stacks a fill-in **Sold To** block, the **subject box** (color, ear number, DOB, weight, sex), and a **certification + Signed** line — mirroring the old slip. Each ancestor box shows farm+name, color, ear, weight, and DOB via a `nodeInfo()` helper that normalizes linked DB nodes, manual-JSON nodes (parsed via `parseManualAncestor`), and missing nodes (→ "Unknown"); `fmtDob` renders MM-DD-YYYY (or "Unknown"), `fmtWeight` omits unset/zero weights. The user produces the PDF via the browser's Print → Save as PDF — **browser print was the chosen method over a bundled PDF library** to keep the self-hosted footprint dependency-free. A component-scoped `<style>` block sets `@page { size: landscape }` (scoped to this mounted page so it doesn't leak to other routes), hides the on-screen Back/Print buttons (`.no-print`) when printing, and forces `print-color-adjust: exact` so the accent header band prints. A "Print Pedigree" button on `RabbitDetail.jsx`'s action bar links here. |
|
||||
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
|
||||
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
|
||||
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
|
||||
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
|
||||
|
|
@ -59,6 +66,8 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. |
|
||||
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared `dash()` helper for empty values. |
|
||||
| `src/lib/theme.js` | Theme registry (post-Phase-11 feature) — the single source of truth for the *list* of selectable themes (`classic`/`midnight`/`ocean`/`berry`/`sunset`/`carnival`), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome `theme-color`. The actual palettes are CSS variables in `index.css` (one `[data-theme='…']` block per theme); this module only knows ids + preview metadata. `applyTheme(id)` sets `document.documentElement.dataset.theme` (which swaps the variables), updates the `theme-color` meta, and mirrors the id to `localStorage` (key `mykindlebox-theme`). `normalizeTheme` falls back to `classic` for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below. |
|
||||
| `src/lib/breeds.js` | The ARBA-recognized rabbit breed list (`ARBA_BREEDS`) powering the breed picker, plus `isCustomBreed(breed)` (true for a non-empty value not on the list). Suggestion list only — `BreedSelect` keeps a free-text fallback, so a breed not on this list still saves. Add new ARBA breeds here. |
|
||||
| `src/components/BreedSelect.jsx` | The breed picker (combobox): a text input bound **directly** to `value` (so anything typed is a valid free-text breed) with a filtered dropdown of `ARBA_BREEDS` underneath for guided selection. Reuses `lib/keyboardNav.js` for the same arrow-key roving focus as `RabbitAutocomplete`/`PedigreeTree`. Shows a "Custom breed" hint when the value isn't an ARBA breed. Used by `RabbitDetailsForm.jsx` (the rabbit Breed field) and `Settings.jsx` (the Default Breed setting). |
|
||||
| `src/lib/keyboardNav.js` | `handleSearchInputKeyDown`/`handleResultKeyDown` (Phase 11 Step 11.4) — arrow-key roving focus for the `<ul><li><button>` search-results dropdown shape shared by `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx`. ArrowDown/ArrowUp move between the input and result buttons, Escape closes the dropdown and refocuses its trigger button. |
|
||||
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
|
||||
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `<link>` tags, applies `font-body` to `<body>` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
|
||||
|
|
@ -81,7 +90,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
- **Chain-authority rule** (pedigree ancestors): a `_name_text` freeform column is only ever written/read when the immediate parent slot has no FK. Once a chain resolves through a real DB record, all deeper data comes from that ancestor's own columns, never the subject's flat columns. This is encoded in the `rabbits` schema as 14 plain nullable text columns (Step 2.2) — the DB schema itself doesn't enforce the rule, application code must. Implemented in `routes/rabbits.ts`'s `resolveAncestorNode(record, path)`: it walks a direction path (`['sire','dam',...]`) one step at a time, recursing into the FK'd DB row whenever one exists for the current step, and only consulting the *current* record's flat column when the path still has remaining depth but no FK to recurse through. Because recursion always switches the "current record" to the fetched ancestor, the original root's flat columns become structurally unreachable as soon as any FK in the path resolves — there's no separate "is broken" flag to get out of sync. Any future write path (the rabbit details/pedigree form, Phase 7) must preserve this: write a `_name_text` column only when the immediate parent FK for that slot is null, and clear deeper-generation flat columns for a branch once a FK is set for an ancestor in that branch (or at minimum, never read them — the read path already ignores them correctly).
|
||||
- **Migrations are file-based via `drizzle-kit`**, not auto-generated at runtime. After editing `db/schema.ts`, run `npx drizzle-kit generate` (writes a new SQL file to `db/migrations/`). Applying them happens automatically on server startup (`db/client.ts` calls `migrate()`) — running `npx drizzle-kit migrate` by hand also works and shares the same tracking table, but is no longer required day-to-day.
|
||||
- **No separate Kit table** — kits are full `Rabbit` rows; a litter's kit list is derived by matching `sire_id`/`dam_id`/`dob` against the litter, not via a join table.
|
||||
- Settings is a simple key-value table (`settings`), not a typed config object in code. Seeded with `rabbitry_name`/`weight_unit` defaults on every startup via `INSERT OR IGNORE`.
|
||||
- Settings is a simple key-value table (`settings`), not a typed config object in code. Seeded with `rabbitry_name`/`weight_unit`/`kit_prefix`/`theme`/`default_breed` defaults (plus the non-editable `kit_counter`) on every startup via `INSERT OR IGNORE`. The editable allowlist lives in `routes/settings.ts`'s `ALLOWED_KEYS`. `default_breed` pre-fills the Breed field on **new** rabbits only — `AddRabbit.jsx` seeds `details.breed` from it via the same one-shot `detailsDefaulted` ref guard that defaults `farm` from `rabbitry_name` (Edit never overrides an existing breed).
|
||||
- **API JSON casing**: Drizzle schema field names are camelCase (idiomatic JS/TS), but the REST API's request/response JSON bodies are snake_case, matching the design doc and implementation plan's literal examples (`weight_lbs`, `sire_id`, etc.). Every route handler must convert: incoming bodies via `toCamelCase` before passing to Drizzle, outgoing rows via `toSnakeCase` before `res.json()`. Don't `res.json()` a raw Drizzle row.
|
||||
- **Upcoming Birthdays** (Dashboard widget, added post-Phase-5, not in the implementation plan or design doc): there is intentionally no `/api/birthdays` endpoint. `Dashboard.jsx`'s `getUpcomingBirthdays()` fetches active rabbits and computes the next occurrence of each `dob`'s month/day client-side, windowed to 30 days. If this logic is ever needed elsewhere (e.g. a notifications feature), lift it into `api.js` or a backend endpoint rather than duplicating the date math.
|
||||
- **Herd "Archived" filter**: `?status=archived` is a frontend-only convenience value, not a real `status` column value — it mirrors the same `other`+`deceased` grouping `routes/stats.ts` uses for the Dashboard's Archived count. `Herd.jsx` strips `status=archived` before calling the backend (which only does exact-match status filtering) and instead filters the response client-side to `status IN ('other', 'deceased')`. Any other status value passed in the URL goes straight to the backend unmodified.
|
||||
|
|
@ -95,4 +104,6 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
- **Search-dropdown keyboard nav lives in `lib/keyboardNav.js`, not duplicated per component**: `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx` both render an identical `<input>` + `<ul><li><button>` results dropdown. Both wire their `onKeyDown` handlers to the same `handleSearchInputKeyDown`/`handleResultKeyDown` exports rather than each implementing their own arrow-key logic. Any third component with this same dropdown shape should reuse these instead of re-deriving roving focus.
|
||||
- **Theming is CSS-variable swaps driven by `data-theme`, not a JS theme object** (post-Phase-11 feature): the ten design tokens in `tailwind.config.js` resolve to CSS variables (`var(--color-surface)` etc.) rather than literal hex. `src/index.css` defines each theme as a `[data-theme='…']` block overriding those ten variables (`:root` = the default Classic palette); setting `document.documentElement.dataset.theme` re-themes every existing utility class with **no markup changes**, which is why adding a theme is purely: a CSS block in `index.css` + an entry in `src/lib/theme.js`'s `THEMES` array (keep the two id lists in sync, including the inline allowlist array in `index.html`). No alpha modifiers (`bg-accent/20`) are used on theme tokens, so the simple `var(--x)` format suffices — if you ever need one, the variables must switch to space-separated RGB channels + the `rgb(var(--x) / <alpha-value>)` format. **Persistence is dual**: the backend `settings.theme` row (added to `routes/settings.ts`'s `ALLOWED_KEYS` and seeded `'classic'` in `db/client.ts`) is the cross-device source of truth; `localStorage` is only a fast-path cache so the inline `<script>` in `index.html` can apply the last-known theme **before first paint** (avoiding a flash of the default palette). `App.jsx`'s `ThemeSync` (a render-nothing child of the providers) reconciles the document with `settings.theme` once that query resolves. `Settings.jsx`'s picker calls `applyTheme` on click for instant live preview, but only **Save** persists it to the backend — an un-saved preview lingers in `localStorage`/the current tab until the next backend reconcile, which is acceptable. The `:focus-visible` rule's `theme('colors.accent')` now compiles to `var(--color-accent)`, so the focus ring themes correctly too.
|
||||
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
|
||||
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
|
||||
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,381 @@
|
|||
# MyKindlebox: Shows Section — Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
Add a **Shows** section to MyKindlebox that pulls live show listings from the ARBA show search page, lets users filter and browse upcoming shows, build personal show events, assign rabbits from their herd, track judges and placements, and generate a show entries report for email.
|
||||
|
||||
---
|
||||
|
||||
## 1. ARBA Data Source
|
||||
|
||||
**Source URL:** `https://arba.net/show-search/`
|
||||
|
||||
The page renders a DataTables-powered table client-side from an embedded dataset. The table columns map to the following data fields:
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `sanctionNumber` | e.g. `YOA4567` — unique per show |
|
||||
| `showDateStart` | String, format varies (`06/26/2026` or `6/27/2026`) |
|
||||
| `stateProvinceCountry` | State/province abbreviation; blank for international |
|
||||
| `district` | ARBA district number (1–9+); blank for international |
|
||||
| `city` | City name |
|
||||
| `showInformation` | Club name + show type (e.g. `OPEN ALL BREED SHOW`, `YOUTH SPECIALTY BREED SHOW, HOLLAND LOP`) |
|
||||
| `showAddress` | Full street address or just city/state if no address on file |
|
||||
| `mapsUrl` | Google Maps link (may be absent) |
|
||||
| `showSecretary` | Secretary name |
|
||||
| `email` | Secretary email |
|
||||
| `catalogUrl` | PDF catalog link on `arba.net` (optional, ~20% of shows) |
|
||||
|
||||
### Data Fetch Strategy
|
||||
|
||||
The ARBA page renders via JavaScript (DataTables). Options:
|
||||
|
||||
**Option A — Server-side proxy scrape (recommended)**
|
||||
- Node.js backend route: `GET /api/shows/arba`
|
||||
- Use `puppeteer` or `playwright` headless browser to load the page, wait for the table to render, then extract the full dataset as JSON.
|
||||
- Cache result in SQLite with a `fetched_at` timestamp. Refresh on first request of the day (ARBA updates Mon–Fri during business hours).
|
||||
- Cache table: `arba_shows_cache` with columns: `id`, `fetched_at`, `data` (JSON blob).
|
||||
|
||||
**Option B — Parse embedded data**
|
||||
- The DataTables source may be an inline JSON variable in the page source. Inspect and parse with `cheerio` if available — faster than headless browser, but brittle if ARBA changes rendering.
|
||||
|
||||
Recommendation: start with Option B, fall back to headless if the JSON isn't in source.
|
||||
|
||||
### Parsed Show Fields
|
||||
|
||||
After scraping, normalize to:
|
||||
|
||||
```json
|
||||
{
|
||||
"sanctionNumber": "YOA4567",
|
||||
"date": "2026-06-26",
|
||||
"state": "SC",
|
||||
"district": 9,
|
||||
"city": "LAURENS",
|
||||
"clubName": "FLINT FIRE UNITED RABBIT REBELS",
|
||||
"showType": "OPEN ALL BREED SHOW",
|
||||
"showTypeFull": "OPEN ALL BREED SHOW",
|
||||
"specialBreed": null,
|
||||
"address": "186 FAIRGROUNDS RD, LAURENS, SC 29360",
|
||||
"mapsUrl": "https://local.google.com/maps/?q=...",
|
||||
"secretary": "SHELLY KURTZ",
|
||||
"email": "SKURTZSHOWSECRETARY@GMAIL.COM",
|
||||
"catalogUrl": null
|
||||
}
|
||||
```
|
||||
|
||||
Parse `showInformation` to split into `clubName`, `showType` (OPEN/YOUTH ALL BREED or SPECIALTY), and `specialBreed` (the breed name after the last comma in specialty shows).
|
||||
|
||||
---
|
||||
|
||||
## 2. Shows Browser (Read-Only View)
|
||||
|
||||
### Route
|
||||
`/shows` — top-level nav item in MyKindlebox sidebar.
|
||||
|
||||
### Filters (above the table)
|
||||
|
||||
| Filter | Type | Behavior |
|
||||
|---|---|---|
|
||||
| State / Province | Multi-select dropdown | Populated dynamically from cached data. Includes blank option for international. |
|
||||
| District | Multi-select dropdown | Values 1–9 plus blank. |
|
||||
| Date From | Date picker | Defaults to today's date. |
|
||||
| Date To | Date picker | Defaults to empty (no upper bound). |
|
||||
| Show Type | Multi-select | ALL BREED OPEN, ALL BREED YOUTH, SPECIALTY OPEN, SPECIALTY YOUTH, FAIR SHOW |
|
||||
| Keyword | Text input | Filters against club name, city, secretary |
|
||||
|
||||
All filters are client-side after the initial load. A **Reset Filters** button restores defaults.
|
||||
|
||||
A **Refresh Data** button (with last-fetched timestamp) triggers a server-side re-scrape.
|
||||
|
||||
### Results Table
|
||||
|
||||
Columns:
|
||||
- Date
|
||||
- State
|
||||
- District
|
||||
- City
|
||||
- Club / Show Name (parsed from showInformation)
|
||||
- Show Type
|
||||
- Specialty Breed (blank if all-breed)
|
||||
- Secretary
|
||||
- Email (mailto link)
|
||||
- Address (links to Maps URL if present)
|
||||
- Catalog (PDF icon link if present)
|
||||
- Action — **"Add to Event"** button
|
||||
|
||||
Default sort: Date ascending, then State.
|
||||
|
||||
Pagination: 50 rows per page, client-side.
|
||||
|
||||
Each row is selectable. Selecting multiple rows enables a bulk **"Add selected to Event"** action in the toolbar.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model Additions
|
||||
|
||||
### `show_events` table
|
||||
A user-created event grouping one or more ARBA shows together (e.g. a weekend away with 4 shows across 2 days).
|
||||
|
||||
```sql
|
||||
CREATE TABLE show_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### `show_event_shows` table
|
||||
Junction: which ARBA shows are included in an event.
|
||||
|
||||
```sql
|
||||
CREATE TABLE show_event_shows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_event_id INTEGER NOT NULL,
|
||||
sanction_number TEXT NOT NULL,
|
||||
show_date TEXT NOT NULL,
|
||||
club_name TEXT NOT NULL,
|
||||
show_type TEXT NOT NULL,
|
||||
specialty_breed TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
address TEXT,
|
||||
secretary TEXT,
|
||||
secretary_email TEXT,
|
||||
catalog_url TEXT,
|
||||
maps_url TEXT,
|
||||
display_order INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (show_event_id) REFERENCES show_events(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
Store a snapshot of the ARBA data at time of adding (the ARBA listing may change or disappear).
|
||||
|
||||
### `show_event_judges` table
|
||||
Judges assigned per show within an event.
|
||||
|
||||
```sql
|
||||
CREATE TABLE show_event_judges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_event_show_id INTEGER NOT NULL,
|
||||
judge_name TEXT NOT NULL,
|
||||
judge_arba_number TEXT,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (show_event_show_id) REFERENCES show_event_shows(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### `show_event_entries` table
|
||||
Rabbits entered into a specific show within an event.
|
||||
|
||||
```sql
|
||||
CREATE TABLE show_event_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_event_show_id INTEGER NOT NULL,
|
||||
rabbit_id INTEGER NOT NULL,
|
||||
entry_class TEXT,
|
||||
entry_number TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (show_event_show_id) REFERENCES show_event_shows(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (rabbit_id) REFERENCES rabbits(id)
|
||||
);
|
||||
```
|
||||
|
||||
`entry_class` is a free-text field — user fills in the class the rabbit is competing in (e.g. `Sr Buck`, `6-8 Doe`).
|
||||
|
||||
### `show_event_placements` table
|
||||
Post-show placement tracking per entry.
|
||||
|
||||
```sql
|
||||
CREATE TABLE show_event_placements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL UNIQUE,
|
||||
placement TEXT,
|
||||
legs_earned INTEGER DEFAULT 0,
|
||||
bos BOOLEAN DEFAULT 0,
|
||||
bob BOOLEAN DEFAULT 0,
|
||||
bis BOOLEAN DEFAULT 0,
|
||||
ris BOOLEAN DEFAULT 0,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (entry_id) REFERENCES show_event_entries(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
`placement` is free text to handle variety: `1st`, `2nd`, `BOB`, `BOSV`, etc. The boolean flags (`bob`, `bos`, `bis`, `ris`) are quick toggles for rollup stats.
|
||||
|
||||
---
|
||||
|
||||
## 4. Show Event Management
|
||||
|
||||
### Route
|
||||
`/shows/events` — list of user's show events.
|
||||
`/shows/events/:id` — event detail / management page.
|
||||
`/shows/events/new` — create new event.
|
||||
|
||||
### Event List View
|
||||
- Cards or table showing event name, date range of included shows, number of shows, number of rabbits entered.
|
||||
- Quick access to open/edit/delete.
|
||||
|
||||
### Event Detail View
|
||||
|
||||
Organized in tabs or sections:
|
||||
|
||||
**Shows Tab**
|
||||
- List of all ARBA shows added to this event, sorted by date.
|
||||
- Each show card shows: date, sanction #, club name, show type, city/state, secretary email, catalog link.
|
||||
- Per show: Judge(s) management (add/edit/remove judges with name and optional ARBA number).
|
||||
- Button to remove a show from the event.
|
||||
- Button to add more shows (opens the browser modal).
|
||||
|
||||
**Entries Tab**
|
||||
- Per-show accordion or grouped list.
|
||||
- Each show shows the rabbits entered into it.
|
||||
- "Add Rabbit" opens a picker from the user's herd — searchable by name, tattoo, breed, variety.
|
||||
- Each entry row: Rabbit name, tattoo, breed, variety, entry class (editable inline), notes.
|
||||
- Remove entry button.
|
||||
- After the show date, an "Enter Placements" toggle switches the row into placement-entry mode: placement text field, legs +/- counter, BOB/BOS/BIS/RIS checkboxes.
|
||||
|
||||
**Results Summary Tab**
|
||||
- Aggregate view of all placements across the event.
|
||||
- Rabbits sorted by total legs earned.
|
||||
- Highlight BOB/BOS/BIS/RIS with badge icons.
|
||||
- Read-only — auto-generated from placement data.
|
||||
|
||||
---
|
||||
|
||||
## 5. Show Entries Report
|
||||
|
||||
Accessible from the Entries tab as **"Generate Entries Report"**.
|
||||
|
||||
Output is a plain-text formatted block in a modal with a **"Copy to Clipboard"** button. Suitable for pasting directly into an email to the show secretary.
|
||||
|
||||
### Report Format
|
||||
|
||||
```
|
||||
SHOW ENTRIES — [Event Name]
|
||||
Generated: [date]
|
||||
|
||||
==============================================
|
||||
[Club Name] — [Show Type]
|
||||
Sanction #: [YOA1234]
|
||||
Date: [MM/DD/YYYY]
|
||||
Secretary: [Name] <[email]>
|
||||
==============================================
|
||||
|
||||
RABBITS ENTERED:
|
||||
|
||||
1. [Rabbit Name] ([Tattoo])
|
||||
Breed: [Breed] / [Variety]
|
||||
Class: [Entry Class]
|
||||
Owner: [User's rabbitry name or full name]
|
||||
ARBA #: [user's ARBA membership number if stored]
|
||||
|
||||
2. [Rabbit Name] ([Tattoo])
|
||||
Breed: [Breed] / [Variety]
|
||||
Class: [Entry Class]
|
||||
Owner: [User's rabbitry name or full name]
|
||||
ARBA #: [user's ARBA membership number if stored]
|
||||
|
||||
[Additional rabbits...]
|
||||
|
||||
==============================================
|
||||
[Next Show in Event if applicable...]
|
||||
==============================================
|
||||
```
|
||||
|
||||
Per-show sections are repeated for each show in the event. If a rabbit is entered in multiple shows within the event, it appears under each show it is entered in.
|
||||
|
||||
### Report Settings (optional, stored per user)
|
||||
- Include/exclude owner ARBA number
|
||||
- Include/exclude rabbit registration numbers (if tracked in herd)
|
||||
- Custom header/footer text block
|
||||
|
||||
---
|
||||
|
||||
## 6. UI / UX Notes
|
||||
|
||||
### Adding Shows to an Event Flow
|
||||
|
||||
1. User clicks **"Add to Event"** on a row in the shows browser.
|
||||
2. Modal appears: "Add to which event?" with dropdown of existing events + "Create new event" option.
|
||||
3. If creating new, prompt for event name and optional notes inline.
|
||||
4. Confirm — show is added to event. Toast confirmation with link to the event.
|
||||
|
||||
Alternatively, from the event detail page, a **"Browse Shows"** button opens the full browser in a slide-over panel where selecting shows adds them directly to the event in context.
|
||||
|
||||
### Placement Entry UX
|
||||
- Placement entry should feel fast. After the show, the user flips a toggle on each row: "Mark Results."
|
||||
- Tab key should advance through fields (placement → legs → BOB → BOS checkboxes).
|
||||
- Legs can be incremented by clicking + since this is a count field (0–3 per show).
|
||||
|
||||
### Linking to Existing Herd Data
|
||||
- Rabbit picker pulls from the `rabbits` table already in MyKindlebox.
|
||||
- Filter by breed and variety to find the right animal quickly.
|
||||
- Show tattoo prominently — this is the primary show identifier.
|
||||
- If a rabbit is already entered in another show within the same event, show a small indicator (useful when the same rabbit runs in multiple shows on the same weekend).
|
||||
|
||||
---
|
||||
|
||||
## 7. Backend API Endpoints
|
||||
|
||||
| Method | Route | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/shows/arba` | Returns cached ARBA show list. Triggers refresh if stale (>24h). |
|
||||
| POST | `/api/shows/arba/refresh` | Force re-scrape. |
|
||||
| GET | `/api/shows/events` | List user's show events. |
|
||||
| POST | `/api/shows/events` | Create new event. |
|
||||
| GET | `/api/shows/events/:id` | Event detail with shows, judges, entries, placements. |
|
||||
| PATCH | `/api/shows/events/:id` | Update event name/notes. |
|
||||
| DELETE | `/api/shows/events/:id` | Delete event. |
|
||||
| POST | `/api/shows/events/:id/shows` | Add an ARBA show to an event. |
|
||||
| DELETE | `/api/shows/events/:id/shows/:showId` | Remove a show from an event. |
|
||||
| POST | `/api/shows/events/:id/shows/:showId/judges` | Add a judge to a show. |
|
||||
| DELETE | `/api/shows/events/:id/shows/:showId/judges/:judgeId` | Remove a judge. |
|
||||
| POST | `/api/shows/events/:id/shows/:showId/entries` | Add a rabbit entry. |
|
||||
| DELETE | `/api/shows/events/:id/shows/:showId/entries/:entryId` | Remove a rabbit entry. |
|
||||
| PUT | `/api/shows/events/:id/shows/:showId/entries/:entryId/placement` | Set or update placement for an entry. |
|
||||
| GET | `/api/shows/events/:id/report` | Returns the formatted entries report as plain text. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Phases
|
||||
|
||||
**Phase 1 — ARBA Browser**
|
||||
- Scraper/cache layer (Node, SQLite).
|
||||
- `/shows` route with filter UI and results table.
|
||||
- No event functionality yet — just browse and view.
|
||||
|
||||
**Phase 2 — Event Management**
|
||||
- `show_events`, `show_event_shows` tables and all CRUD.
|
||||
- "Add to Event" flow from browser.
|
||||
- Event list and detail views (Shows tab only).
|
||||
|
||||
**Phase 3 — Entries**
|
||||
- `show_event_entries` table.
|
||||
- Rabbit picker integration.
|
||||
- Entries tab in event detail.
|
||||
- Entries report generation.
|
||||
|
||||
**Phase 4 — Judges & Placements**
|
||||
- `show_event_judges` and `show_event_placements` tables.
|
||||
- Judge management UI per show.
|
||||
- Placement entry UI per entry.
|
||||
- Results Summary tab.
|
||||
|
||||
---
|
||||
|
||||
## 9. Resolved Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| ARBA scrape reliability | No user fallback. If the scraper fails, surface an error state in the UI and log it server-side. The cache remains available until it can be refreshed. |
|
||||
| ARBA membership number | Not required to show rabbits; not collected or included in the entries report. |
|
||||
| Rabbitry name | Already stored as part of farm setup — pull from existing profile for the report header. No new field needed. |
|
||||
| Mobile filter bar | Filters collapse to a drawer on small screens; wide table scrolls horizontally. No special redesign needed. |
|
||||
| Cavy shows | Filter out at the scrape/normalization layer. Any show with `CAVY` in the `showInformation` field is excluded before being written to the cache. |
|
||||
| International shows | Only US shows displayed. Rows with a blank or non-US `stateProvinceCountry` field are excluded at the normalization layer. |
|
||||
|
|
@ -17,6 +17,9 @@ import AddBreeding from './pages/AddBreeding'
|
|||
import LitterDetail from './pages/LitterDetail'
|
||||
import EditLitter from './pages/EditLitter'
|
||||
import Genetics from './pages/Genetics'
|
||||
import Shows from './pages/Shows'
|
||||
import ShowEvents from './pages/ShowEvents'
|
||||
import ShowEventDetail from './pages/ShowEventDetail'
|
||||
import Settings from './pages/Settings'
|
||||
import NotFound from './pages/NotFound'
|
||||
|
||||
|
|
@ -57,6 +60,9 @@ function App() {
|
|||
<Route path="/litters/:id" element={<LitterDetail />} />
|
||||
<Route path="/litters/:id/edit" element={<EditLitter />} />
|
||||
<Route path="/genetics" element={<Genetics />} />
|
||||
<Route path="/shows" element={<Shows />} />
|
||||
<Route path="/shows/events" element={<ShowEvents />} />
|
||||
<Route path="/shows/events/:id" element={<ShowEventDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,56 @@ export const updateLitter = (id, data) =>
|
|||
request(`/litters/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
||||
export const deleteLitter = (id) => request(`/litters/${id}`, { method: 'DELETE' })
|
||||
|
||||
// Events — manually-logged timeline events (show / sale / other) for a rabbit
|
||||
export const getEvents = (rabbitId) => request(withQuery('/events', { rabbit_id: rabbitId }))
|
||||
export const createEvent = (data) =>
|
||||
request('/events', { method: 'POST', body: JSON.stringify(data) })
|
||||
export const updateEvent = (id, data) =>
|
||||
request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
||||
export const deleteEvent = (id) => request(`/events/${id}`, { method: 'DELETE' })
|
||||
|
||||
// Shows — ARBA show listing (scraped + cached server-side). `getArbaShows`
|
||||
// returns { fetched_at, stale, shows: [...] }; `refreshArbaShows` forces a
|
||||
// server re-scrape and returns the same shape.
|
||||
export const getArbaShows = () => request('/shows/arba')
|
||||
export const refreshArbaShows = () => request('/shows/arba/refresh', { method: 'POST' })
|
||||
|
||||
// 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')
|
||||
export const getShowEvent = (id) => request(`/shows/events/${id}`)
|
||||
export const createShowEvent = (data) =>
|
||||
request('/shows/events', { method: 'POST', body: JSON.stringify(data) })
|
||||
export const updateShowEvent = (id, data) =>
|
||||
request(`/shows/events/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
|
||||
export const deleteShowEvent = (id) => request(`/shows/events/${id}`, { method: 'DELETE' })
|
||||
export const addShowToEvent = (id, show) =>
|
||||
request(`/shows/events/${id}/shows`, { method: 'POST', body: JSON.stringify(show) })
|
||||
export const removeShowFromEvent = (id, showId) =>
|
||||
request(`/shows/events/${id}/shows/${showId}`, { method: 'DELETE' })
|
||||
|
||||
// Show entries — a herd rabbit entered into a specific show within an event.
|
||||
export const addShowEntry = (id, showId, data) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/entries`, { method: 'POST', body: JSON.stringify(data) })
|
||||
export const updateShowEntry = (id, showId, entryId, data) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/entries/${entryId}`, { method: 'PATCH', body: JSON.stringify(data) })
|
||||
export const removeShowEntry = (id, showId, entryId) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/entries/${entryId}`, { method: 'DELETE' })
|
||||
export const getEventReport = (id) => request(`/shows/events/${id}/report`)
|
||||
|
||||
// Judges (per show within an event) and placements (one per entry; PUT upserts).
|
||||
export const addShowJudge = (id, showId, data) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/judges`, { method: 'POST', body: JSON.stringify(data) })
|
||||
export const updateShowJudge = (id, showId, judgeId, data) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/judges/${judgeId}`, { method: 'PATCH', body: JSON.stringify(data) })
|
||||
export const removeShowJudge = (id, showId, judgeId) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/judges/${judgeId}`, { method: 'DELETE' })
|
||||
export const setShowEntryPlacement = (id, showId, entryId, data) =>
|
||||
request(`/shows/events/${id}/shows/${showId}/entries/${entryId}/placement`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => request('/settings')
|
||||
export const updateSettings = (data) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
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'
|
||||
|
||||
// Modal launched from a row's "Add to Event" button in the Shows browser.
|
||||
// Lets the user drop the ARBA show into an existing event or spin up a new one
|
||||
// inline (design doc §6 "Adding Shows to an Event Flow").
|
||||
function AddToEventModal({ show, onClose }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: events, isLoading } = useQuery({ queryKey: ['show-events'], queryFn: getShowEvents })
|
||||
|
||||
const [mode, setMode] = useState('existing') // 'existing' | 'new'
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newNotes, setNewNotes] = useState('')
|
||||
const [result, setResult] = useState(null) // { id, name } once added
|
||||
|
||||
// No events yet → force the create path.
|
||||
const hasEvents = (events?.length ?? 0) > 0
|
||||
const effectiveMode = hasEvents ? mode : 'new'
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let eventId
|
||||
let eventName
|
||||
if (effectiveMode === 'new') {
|
||||
const created = await createShowEvent({ name: newName.trim(), notes: newNotes.trim() || null })
|
||||
eventId = created.id
|
||||
eventName = created.name
|
||||
} else {
|
||||
eventId = Number(selectedId)
|
||||
eventName = events.find((e) => e.id === eventId)?.name ?? 'event'
|
||||
}
|
||||
await addShowToEvent(eventId, show)
|
||||
return { id: eventId, name: eventName }
|
||||
},
|
||||
onSuccess: (added) => {
|
||||
setResult(added)
|
||||
queryClient.invalidateQueries({ queryKey: ['show-events'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['show-event', added.id] })
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit =
|
||||
effectiveMode === 'new' ? newName.trim().length > 0 : selectedId !== ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-surface-raised p-6 shadow-xl">
|
||||
{result ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Added to event</h2>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
<span className="font-medium text-text-primary">{show.club_name}</span> was added to{' '}
|
||||
<span className="font-medium text-text-primary">{result.name}</span>.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Link to={`/shows/events/${result.id}`}>
|
||||
<Button variant="primary">View Event</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Add to Event</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
{show.club_name} — {show.raw_date || show.date}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasEvents && (
|
||||
<div className="flex gap-1 rounded-md border border-border bg-surface p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('existing')}
|
||||
className={`flex-1 rounded px-3 py-1.5 font-body text-sm ${
|
||||
effectiveMode === 'existing' ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
Existing event
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('new')}
|
||||
className={`flex-1 rounded px-3 py-1.5 font-body text-sm ${
|
||||
effectiveMode === 'new' ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
New event
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveMode === 'existing' ? (
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Event</label>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-surface-raised px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="">{isLoading ? 'Loading…' : 'Select an event'}</option>
|
||||
{events?.map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.name} ({event.show_count} show{event.show_count === 1 ? '' : 's'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">
|
||||
Event name <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. Spring Circuit Weekend"
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Notes</label>
|
||||
<textarea
|
||||
value={newNotes}
|
||||
onChange={(e) => setNewNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="font-body text-sm text-danger">{mutation.error.message}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onClose} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!canSubmit || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Adding…' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddToEventModal
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ARBA_BREEDS, isCustomBreed } from '../lib/breeds'
|
||||
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
||||
|
||||
// Breed picker: a text input bound directly to `value` (so anything typed is a
|
||||
// valid free-text breed) with a filtered dropdown of ARBA breeds underneath for
|
||||
// guided selection. Mirrors the search-dropdown shape used by RabbitAutocomplete
|
||||
// /PedigreeTree, reusing lib/keyboardNav for arrow-key roving focus.
|
||||
function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', invalid = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
const query = value.trim().toLowerCase()
|
||||
const matches = query
|
||||
? ARBA_BREEDS.filter((b) => b.toLowerCase().includes(query))
|
||||
: ARBA_BREEDS
|
||||
|
||||
function select(breed) {
|
||||
onChange(breed)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
|
||||
placeholder={placeholder}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
autoComplete="off"
|
||||
className={`mt-1 w-full rounded-md border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
|
||||
invalid ? 'border-danger' : 'border-border'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{isCustomBreed(value) && (
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">Custom breed (not an ARBA breed)</p>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised p-1 shadow-sm">
|
||||
<ul className="max-h-56 overflow-auto">
|
||||
{matches.map((breed, index) => (
|
||||
<li key={breed}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(breed)}
|
||||
onKeyDown={(e) =>
|
||||
handleResultKeyDown(e, {
|
||||
containerEl: containerRef.current,
|
||||
index,
|
||||
inputEl: inputRef.current,
|
||||
close,
|
||||
})
|
||||
}
|
||||
className={`w-full rounded px-2 py-1.5 text-left font-body text-sm hover:bg-surface-sunken ${
|
||||
breed.toLowerCase() === query ? 'bg-surface-sunken' : ''
|
||||
}`}
|
||||
>
|
||||
{breed}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{matches.length === 0 && (
|
||||
<li className="px-2 py-1.5 font-body text-sm text-text-secondary">
|
||||
No ARBA breed matches — “{value.trim()}” will be saved as a custom breed
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BreedSelect
|
||||
|
|
@ -63,6 +63,9 @@ function Layout() {
|
|||
<NavLink to="/genetics" className={navLinkClass}>
|
||||
Genetics
|
||||
</NavLink>
|
||||
<NavLink to="/shows" className={navLinkClass}>
|
||||
Shows
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={navLinkClass}>
|
||||
Settings
|
||||
</NavLink>
|
||||
|
|
|
|||
|
|
@ -50,13 +50,18 @@ function Generation({ entries, ancestors }) {
|
|||
|
||||
function PedigreeTreeReadOnly({ subjectName, ancestors }) {
|
||||
return (
|
||||
<div className="flex w-full min-w-[900px] items-center gap-6 overflow-x-auto p-4">
|
||||
<div className="flex h-16 w-32 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>
|
||||
// The scroll container fills (and shrinks with) its column; the 720px-wide
|
||||
// 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">
|
||||
<div className="flex min-w-[720px] items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<Generation entries={GEN1} ancestors={ancestors} />
|
||||
<Generation entries={GEN2} ancestors={ancestors} />
|
||||
<Generation entries={GEN3} ancestors={ancestors} />
|
||||
</div>
|
||||
<Generation entries={GEN1} ancestors={ancestors} />
|
||||
<Generation entries={GEN2} ancestors={ancestors} />
|
||||
<Generation entries={GEN3} ancestors={ancestors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import BreedSelect from './BreedSelect'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
|
|
@ -104,6 +105,27 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Acquired Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value.acquired_date ?? ''}
|
||||
onChange={(e) => setField('acquired_date', e.target.value || null)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Death Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value.death_date ?? ''}
|
||||
onChange={(e) => setField('death_date', e.target.value || null)}
|
||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Weight</label>
|
||||
{weightUnit === 'lbs_oz' ? (
|
||||
|
|
@ -174,6 +196,11 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Breed</label>
|
||||
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRabbits } from '../api'
|
||||
import { SEX_LABELS } from '../lib/format'
|
||||
|
||||
// Modal that picks a rabbit from the herd for a show entry. Searchable by name,
|
||||
// tattoo (ear number), breed, and variety (color), per the design doc. Filters
|
||||
// client-side over the full herd — fine for a single-rabbitry herd, and avoids a
|
||||
// bespoke multi-field search endpoint.
|
||||
function RabbitPicker({ onSelect, onClose, alreadyEnteredIds }) {
|
||||
const { data: rabbits, isLoading } = useQuery({ queryKey: ['rabbits', 'picker'], queryFn: () => getRabbits({}) })
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const entered = alreadyEnteredIds ?? new Set()
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = rabbits ?? []
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return list
|
||||
return list.filter((r) => {
|
||||
const hay = `${r.name ?? ''} ${r.ear_number ?? ''} ${r.breed ?? ''} ${r.color_name ?? ''}`.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}, [rabbits, query])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg border border-border bg-surface-raised shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Add Rabbit</h2>
|
||||
<button type="button" onClick={onClose} className="font-body text-sm text-text-secondary hover:text-text-primary">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="Search name, tattoo, breed, variety"
|
||||
className="w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||
{isLoading ? (
|
||||
<p className="font-body text-sm text-text-secondary">Loading herd…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="font-body text-sm text-text-secondary">No matching rabbits.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{filtered.map((r) => {
|
||||
const isEntered = entered.has(r.id)
|
||||
const meta = [
|
||||
r.ear_number ? `#${r.ear_number}` : null,
|
||||
r.breed,
|
||||
r.color_name,
|
||||
SEX_LABELS[r.sex] ?? r.sex,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(r)}
|
||||
disabled={isEntered}
|
||||
className={`flex w-full flex-col items-start rounded-md border border-transparent px-3 py-2 text-left ${
|
||||
isEntered
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:border-border hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<span className="font-body text-sm font-medium text-text-primary">
|
||||
{r.name}
|
||||
{isEntered ? ' — already entered' : ''}
|
||||
</span>
|
||||
{meta ? <span className="font-body text-xs text-text-secondary">{meta}</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RabbitPicker
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
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 { formatDate } from '../lib/format'
|
||||
|
||||
// Visual config per timeline event type. `order` breaks ties when two events
|
||||
// share a date (e.g. birth always sorts above an acquired-on-the-same-day note).
|
||||
// `dot` is a Tailwind class pair for the rail marker.
|
||||
const EVENT_TYPES = {
|
||||
birth: { icon: '🐣', order: 0, dot: 'bg-accent text-surface' },
|
||||
acquired: { icon: '🏡', order: 1, dot: 'bg-accent-warm text-surface' },
|
||||
breeding: { icon: '❤', order: 2, dot: 'bg-surface-sunken text-text-primary' },
|
||||
kindle: { icon: '🐰', order: 3, dot: 'bg-accent text-surface' },
|
||||
show: { icon: '🏆', order: 4, dot: 'bg-accent-warm text-surface' },
|
||||
sale: { icon: '🏷️', order: 5, dot: 'bg-accent text-surface' },
|
||||
other: { icon: '📌', order: 6, dot: 'bg-surface-sunken text-text-primary' },
|
||||
death: { icon: '✝', order: 7, dot: 'bg-danger text-white' },
|
||||
}
|
||||
|
||||
// The manually-loggable event types (the Add Event dropdown). Breeding is left
|
||||
// out on purpose — it's derived automatically from litter records.
|
||||
const ADD_EVENT_TYPES = [
|
||||
{ value: 'show', label: 'Show' },
|
||||
{ value: 'sale', label: 'Sale' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
const TITLE_LABEL = { show: 'Show Name', sale: 'Title', other: 'Title' }
|
||||
const TITLE_PLACEHOLDER = {
|
||||
show: 'e.g. Spring ARBA Open',
|
||||
sale: 'e.g. Sold to Jane Doe',
|
||||
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) {
|
||||
const events = []
|
||||
|
||||
if (rabbit.dob) {
|
||||
events.push({ key: 'birth', type: 'birth', date: rabbit.dob, title: 'Born' })
|
||||
}
|
||||
|
||||
// Only meaningful when the rabbit joined the herd after birth — same-day or
|
||||
// missing acquired dates aren't worth a separate point.
|
||||
if (rabbit.acquired_date && rabbit.acquired_date !== rabbit.dob) {
|
||||
events.push({
|
||||
key: 'acquired',
|
||||
type: 'acquired',
|
||||
date: rabbit.acquired_date,
|
||||
title: 'Added to herd',
|
||||
})
|
||||
}
|
||||
|
||||
for (const litter of litters) {
|
||||
const partner = litter.sire?.id === rabbit.id ? litter.dam : litter.sire
|
||||
const partnerName = partner?.name ?? 'unknown partner'
|
||||
|
||||
if (litter.breed_date) {
|
||||
events.push({
|
||||
key: `breeding-${litter.id}`,
|
||||
type: 'breeding',
|
||||
date: litter.breed_date,
|
||||
title: `Bred with ${partnerName}`,
|
||||
to: `/litters/${litter.id}`,
|
||||
})
|
||||
}
|
||||
if (litter.kindle_date) {
|
||||
const count = litter.kit_count ?? 0
|
||||
events.push({
|
||||
key: `kindle-${litter.id}`,
|
||||
type: 'kindle',
|
||||
date: litter.kindle_date,
|
||||
title: `Litter with ${partnerName} kindled`,
|
||||
detail: `${count} ${count === 1 ? 'kit' : 'kits'}`,
|
||||
to: `/litters/${litter.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(' • ') : ''
|
||||
events.push({
|
||||
key: `event-${ev.id}`,
|
||||
type: ev.type,
|
||||
date: ev.event_date,
|
||||
title: ev.title,
|
||||
detail: [showResult, ev.notes].filter(Boolean).join(' — ') || null,
|
||||
event: ev,
|
||||
})
|
||||
}
|
||||
|
||||
if (rabbit.death_date) {
|
||||
events.push({ key: 'death', type: 'death', date: rabbit.death_date, title: 'Died' })
|
||||
}
|
||||
|
||||
// ISO date strings sort lexicographically; type order is the tiebreaker.
|
||||
return events.sort((a, b) => {
|
||||
if (a.date !== b.date) return a.date < b.date ? -1 : 1
|
||||
return EVENT_TYPES[a.type].order - EVENT_TYPES[b.type].order
|
||||
})
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
|
||||
|
||||
function EventForm({ editingEvent, onSave, onCancel, saving }) {
|
||||
const [form, setForm] = useState(
|
||||
editingEvent
|
||||
? {
|
||||
type: editingEvent.type,
|
||||
event_date: editingEvent.event_date ?? '',
|
||||
title: editingEvent.title ?? '',
|
||||
notes: editingEvent.notes ?? '',
|
||||
show_class: editingEvent.show_class ?? '',
|
||||
placement: editingEvent.placement ?? '',
|
||||
}
|
||||
: { type: 'show', event_date: '', title: '', notes: '', show_class: '', placement: '' }
|
||||
)
|
||||
|
||||
function setField(field, value) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const canSave = form.event_date.trim() !== '' && form.title.trim() !== ''
|
||||
const isShow = form.type === 'show'
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border bg-surface-sunken p-4">
|
||||
{editingEvent ? (
|
||||
<p className="font-body text-sm font-medium text-text-primary">
|
||||
Edit {ADD_EVENT_TYPES.find((t) => t.value === form.type)?.label ?? 'Event'}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Event Type</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setField('type', e.target.value)}
|
||||
className={`${inputClass} bg-surface-raised`}
|
||||
>
|
||||
{ADD_EVENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">
|
||||
Date <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.event_date}
|
||||
onChange={(e) => setField('event_date', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">
|
||||
{TITLE_LABEL[form.type]} <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setField('title', e.target.value)}
|
||||
placeholder={TITLE_PLACEHOLDER[form.type]}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
{isShow && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Class</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.show_class}
|
||||
onChange={(e) => setField('show_class', e.target.value)}
|
||||
placeholder="e.g. Senior Buck"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Placement</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.placement}
|
||||
onChange={(e) => setField('placement', e.target.value)}
|
||||
placeholder="e.g. BOB, 1st"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Notes</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setField('notes', e.target.value)}
|
||||
rows={2}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.type === 'sale' && (
|
||||
<p className="font-body text-xs text-text-secondary">
|
||||
Saving a sale also marks this rabbit as Sold.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onSave(form)}
|
||||
disabled={saving || !canSave}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Event'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRow({ event, isLast, onEdit, onDelete }) {
|
||||
const config = EVENT_TYPES[event.type]
|
||||
const titleNode = event.to ? (
|
||||
<Link to={event.to} className="font-medium text-text-primary hover:text-accent">
|
||||
{event.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-text-primary">{event.title}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<li className="grid grid-cols-[1.5rem_1fr] gap-x-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={`z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[0.7rem] ${config.dot}`}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
{!isLast && <span className="w-px flex-1 bg-border" />}
|
||||
</div>
|
||||
|
||||
<div className={isLast ? 'pb-1' : 'pb-6'}>
|
||||
<p className="font-body text-xs text-text-secondary">{formatDate(event.date)}</p>
|
||||
<p className="font-body text-sm">{titleNode}</p>
|
||||
{event.detail && <p className="font-body text-sm text-text-secondary">{event.detail}</p>}
|
||||
{event.event && (
|
||||
<div className="mt-1 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(event.event)}
|
||||
className="font-body text-xs text-text-secondary hover:text-accent"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(event.event)}
|
||||
className="font-body text-xs text-text-secondary hover:text-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function RabbitTimeline({ rabbit }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [formMode, setFormMode] = useState(null) // null | 'add' | event id being edited
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||
const { data: logged } = useQuery({
|
||||
queryKey: ['events', rabbit.id],
|
||||
queryFn: () => getEvents(rabbit.id),
|
||||
})
|
||||
|
||||
const rabbitLitters = (litters ?? []).filter(
|
||||
(litter) => litter.sire?.id === rabbit.id || litter.dam?.id === rabbit.id
|
||||
)
|
||||
const events = buildEvents(rabbit, rabbitLitters, logged ?? [])
|
||||
|
||||
async function handleSave(form) {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const isSale = form.type === 'sale'
|
||||
if (formMode === 'add') {
|
||||
await createEvent({ rabbit_id: rabbit.id, ...form })
|
||||
} else {
|
||||
await updateEvent(formMode, form)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['events', rabbit.id] })
|
||||
// A sale flips the rabbit's status to Sold server-side; refresh the rabbit
|
||||
// record (status badge) and dashboard counts.
|
||||
if (isSale) {
|
||||
queryClient.invalidateQueries({ queryKey: ['rabbit'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
}
|
||||
setFormMode(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(ev) {
|
||||
if (!window.confirm(`Delete the "${ev.title}" event?`)) return
|
||||
setError(null)
|
||||
try {
|
||||
await deleteEvent(ev.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['events', rabbit.id] })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const editingEvent =
|
||||
typeof formMode === 'number' ? (logged ?? []).find((e) => e.id === formMode) : null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-display text-lg text-text-primary">Timeline</h2>
|
||||
{formMode === null && (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setFormMode('add')}>
|
||||
Add Event
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-3 font-body text-sm text-danger">{error}</p>}
|
||||
|
||||
{formMode !== null && (
|
||||
<div className="mb-4">
|
||||
<EventForm
|
||||
editingEvent={editingEvent}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setFormMode(null)
|
||||
setError(null)
|
||||
}}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
No timeline events yet. Add a date of birth, record a breeding, or log an event.
|
||||
</p>
|
||||
) : (
|
||||
<ol>
|
||||
{events.map((event, i) => (
|
||||
<TimelineRow
|
||||
key={event.key}
|
||||
event={event}
|
||||
isLast={i === events.length - 1}
|
||||
onEdit={(ev) => {
|
||||
setError(null)
|
||||
setFormMode(ev.id)
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RabbitTimeline
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getEventReport } from '../api'
|
||||
import Button from './Button'
|
||||
|
||||
// Shows the generated plain-text entries report with a Copy to Clipboard
|
||||
// button, ready to paste into an email to a show secretary (design doc §5).
|
||||
function ReportModal({ eventId, onClose }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['event-report', eventId],
|
||||
queryFn: () => getEventReport(eventId),
|
||||
staleTime: 0,
|
||||
})
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const report = data?.report ?? ''
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(report)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-surface-raised shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Entries Report</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={copy} disabled={isLoading || isError || !report}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</Button>
|
||||
<button type="button" onClick={onClose} className="font-body text-sm text-text-secondary hover:text-text-primary">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<p className="font-body text-sm text-text-secondary">Generating…</p>
|
||||
) : isError ? (
|
||||
<p className="font-body text-sm text-danger">Could not generate the report.</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-surface-sunken p-4 font-mono text-xs text-text-primary">
|
||||
{report}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportModal
|
||||
|
|
@ -11,6 +11,7 @@ const DETAIL_FIELDS = [
|
|||
{ key: 'ear_number', label: 'Ear Number' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'breed', label: 'Breed' },
|
||||
{ key: 'color_name', label: 'Color Name' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,28 @@
|
|||
--stat-8-hover: #334155;
|
||||
}
|
||||
|
||||
/* Theme-aware form controls. Inputs/textareas/selects across the app don't set
|
||||
their own background or text color, so without this they fall back to the
|
||||
browser default (white background, near-black text) — fine on the light
|
||||
themes (where it matches `surface-raised`) but unreadable on the dark
|
||||
Midnight theme, which renders a glaring white box with washed-out placeholder
|
||||
text. Tying the controls to the theme tokens keeps them legible everywhere
|
||||
without touching each component. */
|
||||
@layer base {
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='color']),
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--color-surface-raised);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* `!important` is deliberate: several inputs/buttons already set
|
||||
`focus:outline-none` (paired with their own `focus:ring-2`) which would
|
||||
otherwise win on specificity over a plain `:focus-visible` rule and leave
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
// The ARBA-recognized rabbit breeds, used to power the breed picker
|
||||
// (BreedSelect). This is a suggestion list only — the picker keeps a free-text
|
||||
// fallback so a breed not on this list (rare/foreign/meat-cross) can still be
|
||||
// typed and saved. If ARBA recognizes a new breed, add it here.
|
||||
export const ARBA_BREEDS = [
|
||||
'American',
|
||||
'American Chinchilla',
|
||||
'American Fuzzy Lop',
|
||||
'American Sable',
|
||||
'Argente Brun',
|
||||
'Belgian Hare',
|
||||
'Beveren',
|
||||
'Blanc de Hotot',
|
||||
'Britannia Petite',
|
||||
'Californian',
|
||||
"Champagne d'Argent",
|
||||
'Checkered Giant',
|
||||
'Cinnamon',
|
||||
"Crème d'Argent",
|
||||
'Dutch',
|
||||
'Dwarf Hotot',
|
||||
'English Angora',
|
||||
'English Lop',
|
||||
'English Spot',
|
||||
'Flemish Giant',
|
||||
'Florida White',
|
||||
'French Angora',
|
||||
'French Lop',
|
||||
'Giant Angora',
|
||||
'Giant Chinchilla',
|
||||
'Harlequin',
|
||||
'Havana',
|
||||
'Himalayan',
|
||||
'Holland Lop',
|
||||
'Jersey Wooly',
|
||||
'Lilac',
|
||||
'Lionhead',
|
||||
'Mini Lop',
|
||||
'Mini Rex',
|
||||
'Mini Satin',
|
||||
'Netherland Dwarf',
|
||||
'New Zealand',
|
||||
'Palomino',
|
||||
'Polish',
|
||||
'Rex',
|
||||
'Rhinelander',
|
||||
'Satin',
|
||||
'Satin Angora',
|
||||
'Silver',
|
||||
'Silver Fox',
|
||||
'Silver Marten',
|
||||
'Standard Chinchilla',
|
||||
'Tan',
|
||||
'Thrianta',
|
||||
]
|
||||
|
||||
// True when `breed` is a non-empty value that isn't one of the ARBA breeds
|
||||
// (case-insensitive) — i.e. a free-text custom entry. Used to surface a small
|
||||
// "Custom" hint in the picker.
|
||||
export function isCustomBreed(breed) {
|
||||
if (!breed || !breed.trim()) return false
|
||||
const lower = breed.trim().toLowerCase()
|
||||
return !ARBA_BREEDS.some((b) => b.toLowerCase() === lower)
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ const INITIAL_DETAILS = {
|
|||
ear_number: '',
|
||||
location: '',
|
||||
status: 'active',
|
||||
breed: '',
|
||||
color_name: '',
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ function AddRabbit() {
|
|||
const [photoFile, setPhotoFile] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const farmDefaulted = useRef(false)
|
||||
const detailsDefaulted = useRef(false)
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
|
||||
|
|
@ -66,11 +67,13 @@ function AddRabbit() {
|
|||
enabled: Boolean(damId),
|
||||
})
|
||||
|
||||
if (settings && !farmDefaulted.current) {
|
||||
farmDefaulted.current = true
|
||||
if (settings.rabbitry_name) {
|
||||
setDetails((prev) => (prev.farm ? prev : { ...prev, farm: settings.rabbitry_name }))
|
||||
}
|
||||
if (settings && !detailsDefaulted.current) {
|
||||
detailsDefaulted.current = true
|
||||
setDetails((prev) => ({
|
||||
...prev,
|
||||
farm: prev.farm || settings.rabbitry_name || prev.farm,
|
||||
breed: prev.breed || settings.default_breed || prev.breed,
|
||||
}))
|
||||
}
|
||||
|
||||
// Coming from a litter's "Add Kit" button — the sire/dam must finish
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@ const DETAIL_KEYS = [
|
|||
'farm',
|
||||
'sex',
|
||||
'dob',
|
||||
'acquired_date',
|
||||
'death_date',
|
||||
'weight_lbs',
|
||||
'ear_number',
|
||||
'location',
|
||||
'status',
|
||||
'breed',
|
||||
'color_name',
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Badge from '../components/Badge'
|
|||
import Button from '../components/Button'
|
||||
import Silhouette from '../components/Silhouette'
|
||||
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
|
||||
import RabbitTimeline from '../components/RabbitTimeline'
|
||||
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, SERVER_BASE } from '../api'
|
||||
import { SEX_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
|
||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||
|
|
@ -59,6 +60,7 @@ function DetailColumn({ rabbit }) {
|
|||
<StatRow label="Date of Birth" value={formatDate(rabbit.dob)} />
|
||||
<StatRow label="Age" value={formatAge(rabbit.dob)} />
|
||||
<StatRow label="Weight" value={formatWeight(rabbit.weight_lbs, weightUnit)} />
|
||||
<StatRow label="Breed" value={rabbit.breed} />
|
||||
<StatRow label="Ear Number" value={rabbit.ear_number} />
|
||||
<StatRow label="Location" value={rabbit.location} />
|
||||
</div>
|
||||
|
|
@ -241,16 +243,24 @@ function RabbitDetail() {
|
|||
<DetailColumn rabbit={rabbit} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
{ancestors ? (
|
||||
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} />
|
||||
) : (
|
||||
<p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||
)}
|
||||
</Card>
|
||||
<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">
|
||||
{ancestors ? (
|
||||
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} />
|
||||
) : (
|
||||
<p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<LitterHistory rabbitId={rabbitId} />
|
||||
<LitterHistory rabbitId={rabbitId} />
|
||||
</div>
|
||||
|
||||
<div className="w-full xl:w-[340px] xl:shrink-0">
|
||||
<Card className="overflow-hidden">
|
||||
<RabbitTimeline rabbit={rabbit} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
import PageHeader from '../components/PageHeader'
|
||||
import Card from '../components/Card'
|
||||
import Button from '../components/Button'
|
||||
import BreedSelect from '../components/BreedSelect'
|
||||
import { useAuth } from '../authContext'
|
||||
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
|
||||
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
|
||||
|
|
@ -39,6 +40,7 @@ function Settings() {
|
|||
rabbitry_name: settings.rabbitry_name ?? '',
|
||||
weight_unit: settings.weight_unit ?? 'decimal_lbs',
|
||||
kit_prefix: settings.kit_prefix ?? '',
|
||||
default_breed: settings.default_breed ?? '',
|
||||
theme: settings.theme ?? DEFAULT_THEME,
|
||||
})
|
||||
}
|
||||
|
|
@ -165,6 +167,17 @@ function Settings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Default Breed</label>
|
||||
<BreedSelect
|
||||
value={form.default_breed}
|
||||
onChange={(breed) => setForm({ ...form, default_breed: breed })}
|
||||
/>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||
Pre-fills the breed field when adding a new rabbit.
|
||||
</p>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,720 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getShowEvent,
|
||||
updateShowEvent,
|
||||
deleteShowEvent,
|
||||
removeShowFromEvent,
|
||||
addShowEntry,
|
||||
updateShowEntry,
|
||||
removeShowEntry,
|
||||
addShowJudge,
|
||||
removeShowJudge,
|
||||
setShowEntryPlacement,
|
||||
} from '../api'
|
||||
import Card from '../components/Card'
|
||||
import Button from '../components/Button'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import RabbitPicker from '../components/RabbitPicker'
|
||||
import ReportModal from '../components/ReportModal'
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null
|
||||
const d = new Date(`${iso}T00:00:00`)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function eventKey(id) {
|
||||
// React Query keys: useParams gives a string id; keep it consistent everywhere.
|
||||
return ['show-event', String(id)]
|
||||
}
|
||||
|
||||
function EditEventForm({ event, onDone }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(event.name)
|
||||
const [notes, setNotes] = useState(event.notes ?? '')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateShowEvent(event.id, { name: name.trim(), notes: notes.trim() || null }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: eventKey(event.id) })
|
||||
queryClient.invalidateQueries({ queryKey: ['show-events'] })
|
||||
onDone()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Notes"
|
||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onDone} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={name.trim().length === 0 || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Shows tab ----
|
||||
|
||||
function JudgesSection({ eventId, show }) {
|
||||
const queryClient = useQueryClient()
|
||||
const judges = show.judges ?? []
|
||||
const [name, setName] = useState('')
|
||||
const [arba, setArba] = useState('')
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: eventKey(eventId) })
|
||||
const add = useMutation({
|
||||
mutationFn: () => addShowJudge(eventId, show.id, { judge_name: name.trim(), judge_arba_number: arba.trim() || null }),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setName('')
|
||||
setArba('')
|
||||
},
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: (judgeId) => removeShowJudge(eventId, show.id, judgeId),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-2">
|
||||
<p className="font-body text-xs font-medium uppercase tracking-wide text-text-secondary">Judges</p>
|
||||
{judges.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-col gap-1">
|
||||
{judges.map((judge) => (
|
||||
<li key={judge.id} className="flex items-center justify-between gap-2 font-body text-sm text-text-primary">
|
||||
<span>
|
||||
{judge.judge_name}
|
||||
{judge.judge_arba_number ? (
|
||||
<span className="text-text-secondary"> · #{judge.judge_arba_number}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate(judge.id)}
|
||||
className="font-body text-xs text-danger hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Judge name"
|
||||
className="flex-1 rounded-md border border-border px-2 py-1 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={arba}
|
||||
onChange={(e) => setArba(e.target.value)}
|
||||
placeholder="ARBA # (opt.)"
|
||||
className="w-28 rounded-md border border-border px-2 py-1 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => add.mutate()} disabled={name.trim().length === 0 || add.isPending}>
|
||||
Add Judge
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShowCard({ eventId, show }) {
|
||||
const queryClient = useQueryClient()
|
||||
const remove = useMutation({
|
||||
mutationFn: () => removeShowFromEvent(eventId, show.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: eventKey(eventId) })
|
||||
queryClient.invalidateQueries({ queryKey: ['show-events'] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">{show.club_name}</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatDate(show.show_date) ?? '—'} · {show.show_type ?? '—'}
|
||||
{show.specialty_breed ? ` · ${show.specialty_breed}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => remove.mutate()} disabled={remove.isPending}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 font-body text-sm text-text-secondary">
|
||||
<span>Sanction #{show.sanction_number}</span>
|
||||
{show.city || show.state ? <span>{[show.city, show.state].filter(Boolean).join(', ')}</span> : null}
|
||||
{show.secretary ? <span>{show.secretary}</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 font-body text-sm">
|
||||
{show.secretary_email ? (
|
||||
<a href={`mailto:${show.secretary_email}`} className="text-accent hover:underline">
|
||||
{show.secretary_email}
|
||||
</a>
|
||||
) : null}
|
||||
{show.maps_url ? (
|
||||
<a href={show.maps_url} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Map
|
||||
</a>
|
||||
) : null}
|
||||
{show.catalog_url ? (
|
||||
<a href={show.catalog_url} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Catalog
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<JudgesSection eventId={eventId} show={show} />
|
||||
{remove.isError && <p className="font-body text-sm text-danger">{remove.error.message}</p>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Entries tab ----
|
||||
|
||||
const PLACEMENT_FLAGS = [
|
||||
{ key: 'bob', label: 'BOB' },
|
||||
{ key: 'bos', label: 'BOS' },
|
||||
{ key: 'bis', label: 'BIS' },
|
||||
{ key: 'ris', label: 'RIS' },
|
||||
]
|
||||
|
||||
// Compact read-only summary of a placement, shown next to the rabbit's name.
|
||||
function PlacementBadges({ placement }) {
|
||||
if (!placement) return null
|
||||
const flags = PLACEMENT_FLAGS.filter((f) => placement[f.key])
|
||||
if (!placement.placement && !placement.legs_earned && flags.length === 0) return null
|
||||
return (
|
||||
<span className="ml-2 inline-flex flex-wrap items-center gap-1 align-middle">
|
||||
{placement.placement ? (
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs text-text-primary">{placement.placement}</span>
|
||||
) : null}
|
||||
{placement.legs_earned > 0 ? (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-surface">
|
||||
{placement.legs_earned} leg{placement.legs_earned === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
{flags.map((f) => (
|
||||
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-surface">
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Fast placement entry (design doc §6): placement text → legs +/- → BOB/BOS/BIS/RIS.
|
||||
// Persists immediately via the PUT upsert (legs/checkboxes on change, text on blur).
|
||||
function PlacementEditor({ eventId, showId, entryId, placement }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
placement: placement?.placement ?? '',
|
||||
legs_earned: placement?.legs_earned ?? 0,
|
||||
bob: placement?.bob ?? false,
|
||||
bos: placement?.bos ?? false,
|
||||
bis: placement?.bis ?? false,
|
||||
ris: placement?.ris ?? false,
|
||||
notes: placement?.notes ?? '',
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (data) => setShowEntryPlacement(eventId, showId, entryId, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: eventKey(eventId) }),
|
||||
})
|
||||
|
||||
// Update local state and persist (for the controls that should save on change).
|
||||
function commit(patch) {
|
||||
const next = { ...form, ...patch }
|
||||
setForm(next)
|
||||
save.mutate(next)
|
||||
}
|
||||
// Update local state only (text fields persist on blur instead).
|
||||
function edit(patch) {
|
||||
setForm((prev) => ({ ...prev, ...patch }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-md bg-surface-sunken p-3">
|
||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||
Placement
|
||||
<input
|
||||
type="text"
|
||||
value={form.placement}
|
||||
onChange={(e) => edit({ placement: e.target.value })}
|
||||
onBlur={() => save.mutate(form)}
|
||||
placeholder="1st, BOB…"
|
||||
className="w-28 rounded-md border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||
Legs
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit({ legs_earned: Math.max(0, form.legs_earned - 1) })}
|
||||
className="rounded-md border border-border px-2 py-1 text-sm text-text-primary"
|
||||
aria-label="Decrease legs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-4 text-center font-body text-sm text-text-primary">{form.legs_earned}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit({ legs_earned: Math.min(3, form.legs_earned + 1) })}
|
||||
className="rounded-md border border-border px-2 py-1 text-sm text-text-primary"
|
||||
aria-label="Increase legs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{PLACEMENT_FLAGS.map((f) => (
|
||||
<label key={f.key} className="flex items-center gap-1 font-body text-sm text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[f.key]}
|
||||
onChange={() => commit({ [f.key]: !form[f.key] })}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{f.label}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||
Notes
|
||||
<input
|
||||
type="text"
|
||||
value={form.notes}
|
||||
onChange={(e) => edit({ notes: e.target.value })}
|
||||
onBlur={() => save.mutate(form)}
|
||||
className="w-36 rounded-md border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span className="font-body text-xs text-text-secondary">
|
||||
{save.isPending ? 'Saving…' : save.isSuccess ? 'Saved' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryRow({ eventId, showId, entry, multiShow }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [entryClass, setEntryClass] = useState(entry.entry_class ?? '')
|
||||
const [notes, setNotes] = useState(entry.notes ?? '')
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (patch) => updateShowEntry(eventId, showId, entry.id, patch),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: eventKey(eventId) }),
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: () => removeShowEntry(eventId, showId, entry.id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: eventKey(eventId) }),
|
||||
})
|
||||
|
||||
function saveClass() {
|
||||
if ((entry.entry_class ?? '') !== entryClass) save.mutate({ entry_class: entryClass || null })
|
||||
}
|
||||
function saveNotes() {
|
||||
if ((entry.notes ?? '') !== notes) save.mutate({ notes: notes || null })
|
||||
}
|
||||
|
||||
const meta = [entry.rabbit_ear_number ? `#${entry.rabbit_ear_number}` : null, entry.rabbit_breed, entry.rabbit_color_name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-border py-3 first:border-t-0">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-body text-sm font-medium text-text-primary">
|
||||
{entry.rabbit_name ?? 'Unknown rabbit'}
|
||||
{multiShow ? (
|
||||
<span className="ml-2 rounded-full bg-surface-sunken px-2 py-0.5 text-xs text-text-secondary">
|
||||
in multiple shows
|
||||
</span>
|
||||
) : null}
|
||||
<PlacementBadges placement={entry.placement} />
|
||||
</p>
|
||||
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={entryClass}
|
||||
onChange={(e) => setEntryClass(e.target.value)}
|
||||
onBlur={saveClass}
|
||||
placeholder="Class (e.g. Sr Buck)"
|
||||
className="w-full rounded-md border border-border px-3 py-1.5 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent md:w-40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={saveNotes}
|
||||
placeholder="Notes"
|
||||
className="w-full rounded-md border border-border px-3 py-1.5 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent md:w-44"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResults((v) => !v)}
|
||||
className="self-start font-body text-sm text-accent hover:underline md:self-auto"
|
||||
>
|
||||
{showResults ? 'Hide results' : entry.placement ? 'Edit results' : 'Add results'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
className="self-start font-body text-sm text-danger hover:underline md:self-auto"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{showResults ? (
|
||||
<PlacementEditor eventId={eventId} showId={showId} entryId={entry.id} placement={entry.placement} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShowEntriesGroup({ eventId, show, multiShowIds }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [picking, setPicking] = useState(false)
|
||||
const entries = show.entries ?? []
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (rabbit) => addShowEntry(eventId, show.id, { rabbit_id: rabbit.id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: eventKey(eventId) })
|
||||
setPicking(false)
|
||||
},
|
||||
})
|
||||
|
||||
const enteredIds = new Set(entries.map((e) => e.rabbit_id))
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">{show.club_name}</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatDate(show.show_date) ?? '—'} · {show.show_type ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setPicking(true)}>
|
||||
Add Rabbit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="font-body text-sm text-text-secondary">No rabbits entered yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry) => (
|
||||
<EntryRow
|
||||
key={entry.id}
|
||||
eventId={eventId}
|
||||
showId={show.id}
|
||||
entry={entry}
|
||||
multiShow={multiShowIds.has(entry.rabbit_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{add.isError && <p className="font-body text-sm text-danger">{add.error.message}</p>}
|
||||
|
||||
{picking ? (
|
||||
<RabbitPicker
|
||||
alreadyEnteredIds={enteredIds}
|
||||
onSelect={(rabbit) => add.mutate(rabbit)}
|
||||
onClose={() => setPicking(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EntriesTab({ event }) {
|
||||
const [showReport, setShowReport] = useState(false)
|
||||
const shows = event.shows ?? []
|
||||
|
||||
// A rabbit entered in more than one show within this event gets an indicator
|
||||
// (useful when the same animal runs across a weekend) — design doc §6.
|
||||
const counts = new Map()
|
||||
for (const show of shows) {
|
||||
for (const entry of show.entries ?? []) {
|
||||
counts.set(entry.rabbit_id, (counts.get(entry.rabbit_id) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const multiShowIds = new Set([...counts.entries()].filter(([, n]) => n > 1).map(([rabbitId]) => rabbitId))
|
||||
|
||||
if (shows.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" onClick={() => setShowReport(true)}>
|
||||
Generate Entries Report
|
||||
</Button>
|
||||
</div>
|
||||
{shows.map((show) => (
|
||||
<ShowEntriesGroup key={show.id} eventId={event.id} show={show} multiShowIds={multiShowIds} />
|
||||
))}
|
||||
{showReport ? <ReportModal eventId={event.id} onClose={() => setShowReport(false)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Results Summary tab ----
|
||||
|
||||
// Read-only rollup of every placement in the event, one card per rabbit, sorted
|
||||
// by total legs earned. Auto-derived from placement data (design doc §4).
|
||||
function ResultsTab({ event }) {
|
||||
const shows = event.shows ?? []
|
||||
|
||||
const byRabbit = new Map()
|
||||
for (const show of shows) {
|
||||
for (const entry of show.entries ?? []) {
|
||||
const p = entry.placement
|
||||
if (!p) continue
|
||||
const agg = byRabbit.get(entry.rabbit_id) ?? {
|
||||
rabbitId: entry.rabbit_id,
|
||||
name: entry.rabbit_name ?? 'Unknown rabbit',
|
||||
ear: entry.rabbit_ear_number,
|
||||
breed: entry.rabbit_breed,
|
||||
legs: 0,
|
||||
flags: { bob: 0, bos: 0, bis: 0, ris: 0 },
|
||||
results: [],
|
||||
}
|
||||
agg.legs += p.legs_earned ?? 0
|
||||
for (const f of PLACEMENT_FLAGS) if (p[f.key]) agg.flags[f.key] += 1
|
||||
agg.results.push({ club: show.club_name, date: show.show_date, placement: p.placement, legs: p.legs_earned ?? 0 })
|
||||
byRabbit.set(entry.rabbit_id, agg)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...byRabbit.values()].sort((a, b) => b.legs - a.legs || a.name.localeCompare(b.name))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="No results yet"
|
||||
description="Enter placements on the Entries tab after a show to see the rollup here."
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((row) => {
|
||||
const meta = [row.ear ? `#${row.ear}` : null, row.breed].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<Card key={row.rabbitId} className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">{row.name}</p>
|
||||
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{row.legs > 0 ? (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-surface">
|
||||
{row.legs} leg{row.legs === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
{PLACEMENT_FLAGS.filter((f) => row.flags[f.key] > 0).map((f) => (
|
||||
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-surface">
|
||||
{f.label}
|
||||
{row.flags[f.key] > 1 ? ` ×${row.flags[f.key]}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1 border-t border-border pt-2 font-body text-sm text-text-secondary">
|
||||
{row.results.map((r, i) => (
|
||||
<li key={i} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="text-text-primary">{r.club}</span>
|
||||
<span>
|
||||
{[r.placement, r.legs > 0 ? `${r.legs} leg${r.legs === 1 ? '' : 's'}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Page ----
|
||||
|
||||
function TabButton({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-md px-4 py-1.5 font-body text-sm ${
|
||||
active ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ShowEventDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [tab, setTab] = useState('shows')
|
||||
|
||||
const { data: event, isLoading, isError } = useQuery({
|
||||
queryKey: ['show-event', id],
|
||||
queryFn: () => getShowEvent(id),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteShowEvent(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['show-events'] })
|
||||
navigate('/shows/events')
|
||||
},
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
return <p className="p-6 font-body text-sm text-danger">Failed to load this event.</p>
|
||||
}
|
||||
if (isLoading || !event) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Show Event</h1>
|
||||
}
|
||||
|
||||
const shows = event.shows ?? []
|
||||
const entryCount = shows.reduce((sum, s) => sum + (s.entries?.length ?? 0), 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title={event.name}
|
||||
subtitle={`${shows.length} show${shows.length === 1 ? '' : 's'} · ${entryCount} entr${entryCount === 1 ? 'y' : 'ies'}`}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/shows">
|
||||
<Button variant="secondary">Add Shows</Button>
|
||||
</Link>
|
||||
<Button variant="secondary" onClick={() => setEditing((v) => !v)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete this event? Its shows and entries will be removed too.')) remove.mutate()
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<EditEventForm event={event} onDone={() => setEditing(false)} />
|
||||
) : event.notes ? (
|
||||
<p className="font-body text-sm text-text-secondary">{event.notes}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-1 self-start rounded-md border border-border bg-surface-raised p-1">
|
||||
<TabButton active={tab === 'shows'} onClick={() => setTab('shows')}>
|
||||
Shows
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'entries'} onClick={() => setTab('entries')}>
|
||||
Entries
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'results'} onClick={() => setTab('results')}>
|
||||
Results
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === 'shows' ? (
|
||||
shows.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{shows.map((show) => (
|
||||
<ShowCard key={show.id} eventId={event.id} show={show} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : tab === 'entries' ? (
|
||||
<EntriesTab event={event} />
|
||||
) : (
|
||||
<ResultsTab event={event} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShowEventDetail
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getShowEvents, createShowEvent } from '../api'
|
||||
import Card from '../components/Card'
|
||||
import Button from '../components/Button'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null
|
||||
// show_date is stored as ISO (YYYY-MM-DD) when ARBA gave a parseable date.
|
||||
const d = new Date(`${iso}T00:00:00`)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatRange(first, last) {
|
||||
const a = formatDate(first)
|
||||
const b = formatDate(last)
|
||||
if (!a && !b) return 'No dated shows'
|
||||
if (a === b || !b) return a
|
||||
if (!a) return b
|
||||
return `${a} – ${b}`
|
||||
}
|
||||
|
||||
function NewEventForm({ onCreated }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createShowEvent({ name: name.trim(), notes: notes.trim() || null }),
|
||||
onSuccess: (created) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['show-events'] })
|
||||
onCreated(created)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<h3 className="font-display text-base text-text-primary">New event</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Event name"
|
||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Notes (optional)"
|
||||
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => onCreated(null)} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={name.trim().length === 0 || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ShowEvents() {
|
||||
const { data: events, isLoading, isError } = useQuery({
|
||||
queryKey: ['show-events'],
|
||||
queryFn: getShowEvents,
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Show Events"
|
||||
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 variant="primary" onClick={() => setCreating((v) => !v)}>
|
||||
New Event
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{creating && <NewEventForm onCreated={() => setCreating(false)} />}
|
||||
|
||||
{isError ? (
|
||||
<p className="font-body text-sm text-danger">Failed to load events.</p>
|
||||
) : isLoading ? (
|
||||
<Card className="p-8">
|
||||
<p className="font-body text-sm text-text-secondary">Loading events…</p>
|
||||
</Card>
|
||||
) : (events?.length ?? 0) === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{events.map((event) => (
|
||||
<Link key={event.id} to={`/shows/events/${event.id}`}>
|
||||
<Card className="flex h-full flex-col gap-2 p-4 hover:border-accent">
|
||||
<h3 className="font-display text-lg text-text-primary">{event.name}</h3>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatRange(event.first_show_date, event.last_show_date)}
|
||||
</p>
|
||||
<p className="mt-auto font-body text-sm text-text-secondary">
|
||||
{event.show_count} show{event.show_count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShowEvents
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
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'
|
||||
import Button from '../components/Button'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import AddToEventModal from '../components/AddToEventModal'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
// The five buckets lib/arba.ts normalizes to. 'OTHER' is intentionally omitted
|
||||
// from the filter — it's a small catch-all for rows whose show-information
|
||||
// string didn't match the expected pattern, and they still show when no
|
||||
// show-type filter is active.
|
||||
const SHOW_TYPE_OPTIONS = [
|
||||
{ value: 'ALL BREED OPEN', label: 'All Breed — Open' },
|
||||
{ value: 'ALL BREED YOUTH', label: 'All Breed — Youth' },
|
||||
{ value: 'SPECIALTY OPEN', label: 'Specialty — Open' },
|
||||
{ value: 'SPECIALTY YOUTH', label: 'Specialty — Youth' },
|
||||
{ value: 'FAIR SHOW', label: 'Fair Show' },
|
||||
]
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function formatTimestamp(iso) {
|
||||
if (!iso) return 'never'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return 'unknown'
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="m6 8 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact checkbox dropdown used for State / District / Show Type. Closes on
|
||||
// outside click. `options` is [{ value, label }]; `selected` is an array of
|
||||
// values.
|
||||
function MultiSelect({ label, options, selected, onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
function onClick(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
}, [open])
|
||||
|
||||
function toggle(value) {
|
||||
if (selected.includes(value)) onChange(selected.filter((v) => v !== value))
|
||||
else onChange([...selected, value])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="rounded-full bg-accent px-1.5 text-xs text-surface">{selected.length}</span>
|
||||
)}
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-10 mt-1 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-surface-raised p-2 shadow-lg">
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="mb-1 w-full rounded px-2 py-1 text-left font-body text-xs text-accent hover:bg-surface-sunken"
|
||||
>
|
||||
Clear {label}
|
||||
</button>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 font-body text-sm text-text-primary hover:bg-surface-sunken"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(option.value)}
|
||||
onChange={() => toggle(option.value)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span>{option.label || '—'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MapPinIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="inline h-4 w-4 align-text-bottom" fill="currentColor">
|
||||
<path d="M10 2a5 5 0 0 0-5 5c0 3.5 5 11 5 11s5-7.5 5-11a5 5 0 0 0-5-5Zm0 7a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Shows() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['arba-shows'],
|
||||
queryFn: getArbaShows,
|
||||
})
|
||||
|
||||
const refresh = useMutation({
|
||||
mutationFn: refreshArbaShows,
|
||||
onSuccess: (result) => queryClient.setQueryData(['arba-shows'], result),
|
||||
})
|
||||
|
||||
const shows = useMemo(() => data?.shows ?? [], [data])
|
||||
|
||||
// Filter state
|
||||
const [states, setStates] = useState([])
|
||||
const [districts, setDistricts] = useState([])
|
||||
const [showTypes, setShowTypes] = useState([])
|
||||
const [dateFrom, setDateFrom] = useState(todayIso)
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [addingShow, setAddingShow] = useState(null)
|
||||
|
||||
// Options derived from the loaded data.
|
||||
const stateOptions = useMemo(() => {
|
||||
const set = [...new Set(shows.map((s) => s.state).filter(Boolean))].sort()
|
||||
return set.map((v) => ({ value: v, label: v }))
|
||||
}, [shows])
|
||||
|
||||
const districtOptions = useMemo(() => {
|
||||
const set = [...new Set(shows.map((s) => s.district ?? ''))].sort((a, b) => {
|
||||
if (a === '') return 1
|
||||
if (b === '') return -1
|
||||
return Number(a) - Number(b)
|
||||
})
|
||||
return set.map((v) => ({ value: v, label: v === '' ? '— (none)' : v }))
|
||||
}, [shows])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
return shows.filter((s) => {
|
||||
if (states.length && !states.includes(s.state)) return false
|
||||
if (districts.length && !districts.includes(s.district ?? '')) return false
|
||||
if (showTypes.length && !showTypes.includes(s.show_type)) return false
|
||||
if (dateFrom && (!s.date || s.date < dateFrom)) return false
|
||||
if (dateTo && (!s.date || s.date > dateTo)) return false
|
||||
if (kw) {
|
||||
const haystack = `${s.club_name} ${s.city} ${s.secretary}`.toLowerCase()
|
||||
if (!haystack.includes(kw)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [shows, states, districts, showTypes, dateFrom, dateTo, keyword])
|
||||
|
||||
// Snap back to page 1 whenever the filtered set changes size/content.
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
const pageRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||
|
||||
function resetFilters() {
|
||||
setStates([])
|
||||
setDistricts([])
|
||||
setShowTypes([])
|
||||
setDateFrom(todayIso())
|
||||
setDateTo('')
|
||||
setKeyword('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
states.length || districts.length || showTypes.length || dateTo || keyword || dateFrom !== todayIso()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Shows"
|
||||
subtitle="Upcoming ARBA-sanctioned shows"
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden font-body text-xs text-text-secondary sm:inline">
|
||||
Updated {formatTimestamp(data?.fetched_at)}
|
||||
{data?.stale ? ' (stale)' : ''}
|
||||
</span>
|
||||
<Link to="/shows/events">
|
||||
<Button variant="secondary">My Events</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => refresh.mutate()}
|
||||
disabled={refresh.isPending}
|
||||
>
|
||||
{refresh.isPending ? 'Refreshing…' : 'Refresh Data'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<MultiSelect label="State" options={stateOptions} selected={states} onChange={(v) => { setStates(v); setPage(1) }} />
|
||||
<MultiSelect label="District" options={districtOptions} selected={districts} onChange={(v) => { setDistricts(v); setPage(1) }} />
|
||||
<MultiSelect label="Show Type" options={SHOW_TYPE_OPTIONS} selected={showTypes} onChange={(v) => { setShowTypes(v); setPage(1) }} />
|
||||
|
||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||
From
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
|
||||
To
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
|
||||
placeholder="Club, city, or secretary"
|
||||
className="w-56 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
|
||||
{hasActiveFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="rounded-md px-3 py-1.5 font-body text-sm text-accent hover:bg-surface-sunken"
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{refresh.isError ? (
|
||||
<p className="font-body text-sm text-danger">
|
||||
Refresh failed: {refresh.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isError ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Couldn’t load shows"
|
||||
description={error?.message || 'The ARBA listing is unavailable right now. Try refreshing.'}
|
||||
action={<Button variant="primary" onClick={() => refresh.mutate()}>Try Again</Button>}
|
||||
/>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<Card className="p-8">
|
||||
<p className="font-body text-sm text-text-secondary">Loading shows…</p>
|
||||
</Card>
|
||||
) : filtered.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="No matching shows"
|
||||
description="Try widening your date range or clearing filters."
|
||||
action={hasActiveFilters ? <Button variant="secondary" onClick={resetFilters}>Reset Filters</Button> : null}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{filtered.length} show{filtered.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<Card className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] font-body text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-text-secondary">
|
||||
<th className="px-3 py-2 font-medium">Date</th>
|
||||
<th className="px-3 py-2 font-medium">State</th>
|
||||
<th className="px-3 py-2 font-medium">Dist.</th>
|
||||
<th className="px-3 py-2 font-medium">City</th>
|
||||
<th className="px-3 py-2 font-medium">Club / Show</th>
|
||||
<th className="px-3 py-2 font-medium">Specialty</th>
|
||||
<th className="px-3 py-2 font-medium">Secretary</th>
|
||||
<th className="px-3 py-2 font-medium">Links</th>
|
||||
<th className="px-3 py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageRows.map((s, i) => (
|
||||
<tr key={s.sanction_number || i} className={i % 2 === 1 ? 'bg-surface-sunken' : ''}>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-text-primary">{s.raw_date || s.date || '—'}</td>
|
||||
<td className="px-3 py-2 text-text-primary">{s.state}</td>
|
||||
<td className="px-3 py-2 text-text-primary">{s.district || '—'}</td>
|
||||
<td className="px-3 py-2 text-text-primary">{s.city}</td>
|
||||
<td className="px-3 py-2 text-text-primary">
|
||||
<div className="font-medium">{s.club_name}</div>
|
||||
<div className="text-xs text-text-secondary">{s.show_type_full || s.show_type}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-primary">{s.special_breed || '—'}</td>
|
||||
<td className="px-3 py-2 text-text-primary">
|
||||
<div>{s.secretary || '—'}</div>
|
||||
{s.email ? (
|
||||
<a href={`mailto:${s.email}`} className="text-xs text-accent hover:underline">
|
||||
{s.email}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{s.maps_url ? (
|
||||
<a href={s.maps_url} target="_blank" rel="noreferrer" title="Map" className="text-accent hover:underline">
|
||||
<MapPinIcon />
|
||||
</a>
|
||||
) : null}
|
||||
{s.catalog_url ? (
|
||||
<a href={s.catalog_url} target="_blank" rel="noreferrer" className="text-xs text-accent hover:underline">
|
||||
Catalog
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right">
|
||||
<Button variant="secondary" size="sm" onClick={() => setAddingShow(s)}>
|
||||
Add to Event
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center justify-between font-body text-sm text-text-secondary">
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" disabled={currentPage <= 1} onClick={() => setPage(currentPage - 1)}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled={currentPage >= totalPages} onClick={() => setPage(currentPage + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{addingShow ? (
|
||||
<AddToEventModal show={addingShow} onClose={() => setAddingShow(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Shows
|
||||
|
|
@ -31,6 +31,9 @@ const defaultSettings = [
|
|||
{ key: 'weight_unit', value: 'decimal_lbs' },
|
||||
{ key: 'kit_prefix', value: '' },
|
||||
{ key: 'theme', value: 'classic' },
|
||||
// Pre-fills the breed field on new rabbits (most herds are a single breed).
|
||||
// Defaults to Holland Lop, the current dataset's breed; editable in Settings.
|
||||
{ key: 'default_breed', value: 'Holland Lop' },
|
||||
// Global, never-resetting sequence used to name auto-generated kits
|
||||
// (see routes/litters.ts) — intentionally not in settings.ts's editable
|
||||
// ALLOWED_KEYS, since it's bookkeeping rather than a user preference.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE `shows` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`rabbit_id` integer NOT NULL,
|
||||
`show_date` text NOT NULL,
|
||||
`show_name` text NOT NULL,
|
||||
`show_class` text,
|
||||
`placement` text,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`rabbit_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `rabbits` ADD `acquired_date` text;--> statement-breakpoint
|
||||
ALTER TABLE `rabbits` ADD `death_date` text;
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE `shows`;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE `rabbit_events` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`rabbit_id` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`event_date` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`notes` text,
|
||||
`show_class` text,
|
||||
`placement` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`rabbit_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "event_type_check" CHECK("rabbit_events"."type" in ('show', 'sale', 'other'))
|
||||
);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE `arba_shows_cache` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`fetched_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`data` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `rabbits` ADD `breed` text;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
CREATE TABLE `show_event_shows` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`show_event_id` integer NOT NULL,
|
||||
`sanction_number` text NOT NULL,
|
||||
`show_date` text,
|
||||
`club_name` text NOT NULL,
|
||||
`show_type` text,
|
||||
`specialty_breed` text,
|
||||
`city` text,
|
||||
`state` text,
|
||||
`address` text,
|
||||
`secretary` text,
|
||||
`secretary_email` text,
|
||||
`catalog_url` text,
|
||||
`maps_url` text,
|
||||
`display_order` integer DEFAULT 0,
|
||||
FOREIGN KEY (`show_event_id`) REFERENCES `show_events`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `show_events` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE `show_event_entries` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`show_event_show_id` integer NOT NULL,
|
||||
`rabbit_id` integer NOT NULL,
|
||||
`entry_class` text,
|
||||
`entry_number` text,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`show_event_show_id`) REFERENCES `show_event_shows`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`rabbit_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
CREATE TABLE `show_event_judges` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`show_event_show_id` integer NOT NULL,
|
||||
`judge_name` text NOT NULL,
|
||||
`judge_arba_number` text,
|
||||
`notes` text,
|
||||
FOREIGN KEY (`show_event_show_id`) REFERENCES `show_event_shows`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `show_event_placements` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`entry_id` integer NOT NULL,
|
||||
`placement` text,
|
||||
`legs_earned` integer DEFAULT 0 NOT NULL,
|
||||
`bob` integer DEFAULT false NOT NULL,
|
||||
`bos` integer DEFAULT false NOT NULL,
|
||||
`bis` integer DEFAULT false NOT NULL,
|
||||
`ris` integer DEFAULT false NOT NULL,
|
||||
`notes` text,
|
||||
FOREIGN KEY (`entry_id`) REFERENCES `show_event_entries`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `show_event_placements_entry_id_unique` ON `show_event_placements` (`entry_id`);
|
||||
|
|
@ -0,0 +1,547 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "8a850cd6-2042-47fd-b733-92b98cf1c9b3",
|
||||
"prevId": "a83e302c-5450-4417-a1e6-cdef0cdb6570",
|
||||
"tables": {
|
||||
"litters": {
|
||||
"name": "litters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"breed_date": {
|
||||
"name": "breed_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kindle_date": {
|
||||
"name": "kindle_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kit_count": {
|
||||
"name": "kit_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"litters_sire_id_rabbits_id_fk": {
|
||||
"name": "litters_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"litters_dam_id_rabbits_id_fk": {
|
||||
"name": "litters_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rabbits": {
|
||||
"name": "rabbits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"farm": {
|
||||
"name": "farm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"dob": {
|
||||
"name": "dob",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_lbs": {
|
||||
"name": "weight_lbs",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ear_number": {
|
||||
"name": "ear_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"color_name": {
|
||||
"name": "color_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"photo_path": {
|
||||
"name": "photo_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_number": {
|
||||
"name": "registration_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gc_number": {
|
||||
"name": "gc_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acquired_date": {
|
||||
"name": "acquired_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"death_date": {
|
||||
"name": "death_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_a": {
|
||||
"name": "locus_a",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_b": {
|
||||
"name": "locus_b",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_c": {
|
||||
"name": "locus_c",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_d": {
|
||||
"name": "locus_d",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_e": {
|
||||
"name": "locus_e",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_en": {
|
||||
"name": "locus_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_v": {
|
||||
"name": "locus_v",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_name_text": {
|
||||
"name": "sire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_name_text": {
|
||||
"name": "dam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_grandsire_name_text": {
|
||||
"name": "paternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_granddam_name_text": {
|
||||
"name": "paternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_grandsire_name_text": {
|
||||
"name": "maternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_granddam_name_text": {
|
||||
"name": "maternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_grandsire_name_text": {
|
||||
"name": "pp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_granddam_name_text": {
|
||||
"name": "pp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_grandsire_name_text": {
|
||||
"name": "pm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_granddam_name_text": {
|
||||
"name": "pm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_grandsire_name_text": {
|
||||
"name": "mp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_granddam_name_text": {
|
||||
"name": "mp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_grandsire_name_text": {
|
||||
"name": "mm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_granddam_name_text": {
|
||||
"name": "mm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbits_sire_id_rabbits_id_fk": {
|
||||
"name": "rabbits_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"rabbits_dam_id_rabbits_id_fk": {
|
||||
"name": "rabbits_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"sex_check": {
|
||||
"name": "sex_check",
|
||||
"value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')"
|
||||
},
|
||||
"status_check": {
|
||||
"name": "status_check",
|
||||
"value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"shows": {
|
||||
"name": "shows",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"rabbit_id": {
|
||||
"name": "rabbit_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_date": {
|
||||
"name": "show_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_name": {
|
||||
"name": "show_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_class": {
|
||||
"name": "show_class",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"placement": {
|
||||
"name": "placement",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"shows_rabbit_id_rabbits_id_fk": {
|
||||
"name": "shows_rabbit_id_rabbits_id_fk",
|
||||
"tableFrom": "shows",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"rabbit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "46f855ed-0535-4c8e-ae88-a1d5954350e2",
|
||||
"prevId": "8a850cd6-2042-47fd-b733-92b98cf1c9b3",
|
||||
"tables": {
|
||||
"litters": {
|
||||
"name": "litters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"breed_date": {
|
||||
"name": "breed_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kindle_date": {
|
||||
"name": "kindle_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kit_count": {
|
||||
"name": "kit_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"litters_sire_id_rabbits_id_fk": {
|
||||
"name": "litters_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"litters_dam_id_rabbits_id_fk": {
|
||||
"name": "litters_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rabbits": {
|
||||
"name": "rabbits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"farm": {
|
||||
"name": "farm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"dob": {
|
||||
"name": "dob",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_lbs": {
|
||||
"name": "weight_lbs",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ear_number": {
|
||||
"name": "ear_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"color_name": {
|
||||
"name": "color_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"photo_path": {
|
||||
"name": "photo_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_number": {
|
||||
"name": "registration_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gc_number": {
|
||||
"name": "gc_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acquired_date": {
|
||||
"name": "acquired_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"death_date": {
|
||||
"name": "death_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_a": {
|
||||
"name": "locus_a",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_b": {
|
||||
"name": "locus_b",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_c": {
|
||||
"name": "locus_c",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_d": {
|
||||
"name": "locus_d",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_e": {
|
||||
"name": "locus_e",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_en": {
|
||||
"name": "locus_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_v": {
|
||||
"name": "locus_v",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_name_text": {
|
||||
"name": "sire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_name_text": {
|
||||
"name": "dam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_grandsire_name_text": {
|
||||
"name": "paternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_granddam_name_text": {
|
||||
"name": "paternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_grandsire_name_text": {
|
||||
"name": "maternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_granddam_name_text": {
|
||||
"name": "maternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_grandsire_name_text": {
|
||||
"name": "pp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_granddam_name_text": {
|
||||
"name": "pp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_grandsire_name_text": {
|
||||
"name": "pm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_granddam_name_text": {
|
||||
"name": "pm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_grandsire_name_text": {
|
||||
"name": "mp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_granddam_name_text": {
|
||||
"name": "mp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_grandsire_name_text": {
|
||||
"name": "mm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_granddam_name_text": {
|
||||
"name": "mm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbits_sire_id_rabbits_id_fk": {
|
||||
"name": "rabbits_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"rabbits_dam_id_rabbits_id_fk": {
|
||||
"name": "rabbits_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"sex_check": {
|
||||
"name": "sex_check",
|
||||
"value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')"
|
||||
},
|
||||
"status_check": {
|
||||
"name": "status_check",
|
||||
"value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "4fe08c9c-e69d-4089-8533-eaea64d913ce",
|
||||
"prevId": "46f855ed-0535-4c8e-ae88-a1d5954350e2",
|
||||
"tables": {
|
||||
"litters": {
|
||||
"name": "litters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"breed_date": {
|
||||
"name": "breed_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kindle_date": {
|
||||
"name": "kindle_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kit_count": {
|
||||
"name": "kit_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"litters_sire_id_rabbits_id_fk": {
|
||||
"name": "litters_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"litters_dam_id_rabbits_id_fk": {
|
||||
"name": "litters_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rabbit_events": {
|
||||
"name": "rabbit_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"rabbit_id": {
|
||||
"name": "rabbit_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_date": {
|
||||
"name": "event_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_class": {
|
||||
"name": "show_class",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"placement": {
|
||||
"name": "placement",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbit_events_rabbit_id_rabbits_id_fk": {
|
||||
"name": "rabbit_events_rabbit_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbit_events",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"rabbit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"event_type_check": {
|
||||
"name": "event_type_check",
|
||||
"value": "\"rabbit_events\".\"type\" in ('show', 'sale', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rabbits": {
|
||||
"name": "rabbits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"farm": {
|
||||
"name": "farm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"dob": {
|
||||
"name": "dob",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_lbs": {
|
||||
"name": "weight_lbs",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ear_number": {
|
||||
"name": "ear_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"color_name": {
|
||||
"name": "color_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"photo_path": {
|
||||
"name": "photo_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_number": {
|
||||
"name": "registration_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gc_number": {
|
||||
"name": "gc_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acquired_date": {
|
||||
"name": "acquired_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"death_date": {
|
||||
"name": "death_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_a": {
|
||||
"name": "locus_a",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_b": {
|
||||
"name": "locus_b",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_c": {
|
||||
"name": "locus_c",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_d": {
|
||||
"name": "locus_d",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_e": {
|
||||
"name": "locus_e",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_en": {
|
||||
"name": "locus_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_v": {
|
||||
"name": "locus_v",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_name_text": {
|
||||
"name": "sire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_name_text": {
|
||||
"name": "dam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_grandsire_name_text": {
|
||||
"name": "paternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_granddam_name_text": {
|
||||
"name": "paternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_grandsire_name_text": {
|
||||
"name": "maternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_granddam_name_text": {
|
||||
"name": "maternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_grandsire_name_text": {
|
||||
"name": "pp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_granddam_name_text": {
|
||||
"name": "pp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_grandsire_name_text": {
|
||||
"name": "pm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_granddam_name_text": {
|
||||
"name": "pm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_grandsire_name_text": {
|
||||
"name": "mp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_granddam_name_text": {
|
||||
"name": "mp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_grandsire_name_text": {
|
||||
"name": "mm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_granddam_name_text": {
|
||||
"name": "mm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbits_sire_id_rabbits_id_fk": {
|
||||
"name": "rabbits_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"rabbits_dam_id_rabbits_id_fk": {
|
||||
"name": "rabbits_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"sex_check": {
|
||||
"name": "sex_check",
|
||||
"value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')"
|
||||
},
|
||||
"status_check": {
|
||||
"name": "status_check",
|
||||
"value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,598 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e213c8fb-c7ae-4476-b647-bba87c7a2acf",
|
||||
"prevId": "4fe08c9c-e69d-4089-8533-eaea64d913ce",
|
||||
"tables": {
|
||||
"arba_shows_cache": {
|
||||
"name": "arba_shows_cache",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"fetched_at": {
|
||||
"name": "fetched_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"litters": {
|
||||
"name": "litters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"breed_date": {
|
||||
"name": "breed_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kindle_date": {
|
||||
"name": "kindle_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kit_count": {
|
||||
"name": "kit_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"litters_sire_id_rabbits_id_fk": {
|
||||
"name": "litters_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"litters_dam_id_rabbits_id_fk": {
|
||||
"name": "litters_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rabbit_events": {
|
||||
"name": "rabbit_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"rabbit_id": {
|
||||
"name": "rabbit_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_date": {
|
||||
"name": "event_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_class": {
|
||||
"name": "show_class",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"placement": {
|
||||
"name": "placement",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbit_events_rabbit_id_rabbits_id_fk": {
|
||||
"name": "rabbit_events_rabbit_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbit_events",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"rabbit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"event_type_check": {
|
||||
"name": "event_type_check",
|
||||
"value": "\"rabbit_events\".\"type\" in ('show', 'sale', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rabbits": {
|
||||
"name": "rabbits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"farm": {
|
||||
"name": "farm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"dob": {
|
||||
"name": "dob",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_lbs": {
|
||||
"name": "weight_lbs",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ear_number": {
|
||||
"name": "ear_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"breed": {
|
||||
"name": "breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_name": {
|
||||
"name": "color_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"photo_path": {
|
||||
"name": "photo_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_number": {
|
||||
"name": "registration_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gc_number": {
|
||||
"name": "gc_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acquired_date": {
|
||||
"name": "acquired_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"death_date": {
|
||||
"name": "death_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_a": {
|
||||
"name": "locus_a",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_b": {
|
||||
"name": "locus_b",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_c": {
|
||||
"name": "locus_c",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_d": {
|
||||
"name": "locus_d",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_e": {
|
||||
"name": "locus_e",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_en": {
|
||||
"name": "locus_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_v": {
|
||||
"name": "locus_v",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_name_text": {
|
||||
"name": "sire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_name_text": {
|
||||
"name": "dam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_grandsire_name_text": {
|
||||
"name": "paternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_granddam_name_text": {
|
||||
"name": "paternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_grandsire_name_text": {
|
||||
"name": "maternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_granddam_name_text": {
|
||||
"name": "maternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_grandsire_name_text": {
|
||||
"name": "pp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_granddam_name_text": {
|
||||
"name": "pp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_grandsire_name_text": {
|
||||
"name": "pm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_granddam_name_text": {
|
||||
"name": "pm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_grandsire_name_text": {
|
||||
"name": "mp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_granddam_name_text": {
|
||||
"name": "mp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_grandsire_name_text": {
|
||||
"name": "mm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_granddam_name_text": {
|
||||
"name": "mm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbits_sire_id_rabbits_id_fk": {
|
||||
"name": "rabbits_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"rabbits_dam_id_rabbits_id_fk": {
|
||||
"name": "rabbits_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"sex_check": {
|
||||
"name": "sex_check",
|
||||
"value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')"
|
||||
},
|
||||
"status_check": {
|
||||
"name": "status_check",
|
||||
"value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,775 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "de214de9-36d5-4f26-987a-c0e80fbc1256",
|
||||
"prevId": "e213c8fb-c7ae-4476-b647-bba87c7a2acf",
|
||||
"tables": {
|
||||
"arba_shows_cache": {
|
||||
"name": "arba_shows_cache",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"fetched_at": {
|
||||
"name": "fetched_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"litters": {
|
||||
"name": "litters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"breed_date": {
|
||||
"name": "breed_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kindle_date": {
|
||||
"name": "kindle_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kit_count": {
|
||||
"name": "kit_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"litters_sire_id_rabbits_id_fk": {
|
||||
"name": "litters_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"litters_dam_id_rabbits_id_fk": {
|
||||
"name": "litters_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rabbit_events": {
|
||||
"name": "rabbit_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"rabbit_id": {
|
||||
"name": "rabbit_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_date": {
|
||||
"name": "event_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_class": {
|
||||
"name": "show_class",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"placement": {
|
||||
"name": "placement",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbit_events_rabbit_id_rabbits_id_fk": {
|
||||
"name": "rabbit_events_rabbit_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbit_events",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"rabbit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"event_type_check": {
|
||||
"name": "event_type_check",
|
||||
"value": "\"rabbit_events\".\"type\" in ('show', 'sale', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rabbits": {
|
||||
"name": "rabbits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"farm": {
|
||||
"name": "farm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"dob": {
|
||||
"name": "dob",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_lbs": {
|
||||
"name": "weight_lbs",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ear_number": {
|
||||
"name": "ear_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"breed": {
|
||||
"name": "breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_name": {
|
||||
"name": "color_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"photo_path": {
|
||||
"name": "photo_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_number": {
|
||||
"name": "registration_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gc_number": {
|
||||
"name": "gc_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acquired_date": {
|
||||
"name": "acquired_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"death_date": {
|
||||
"name": "death_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_a": {
|
||||
"name": "locus_a",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_b": {
|
||||
"name": "locus_b",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_c": {
|
||||
"name": "locus_c",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_d": {
|
||||
"name": "locus_d",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_e": {
|
||||
"name": "locus_e",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_en": {
|
||||
"name": "locus_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_v": {
|
||||
"name": "locus_v",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_name_text": {
|
||||
"name": "sire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_name_text": {
|
||||
"name": "dam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_grandsire_name_text": {
|
||||
"name": "paternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_granddam_name_text": {
|
||||
"name": "paternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_grandsire_name_text": {
|
||||
"name": "maternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_granddam_name_text": {
|
||||
"name": "maternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_grandsire_name_text": {
|
||||
"name": "pp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_granddam_name_text": {
|
||||
"name": "pp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_grandsire_name_text": {
|
||||
"name": "pm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_granddam_name_text": {
|
||||
"name": "pm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_grandsire_name_text": {
|
||||
"name": "mp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_granddam_name_text": {
|
||||
"name": "mp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_grandsire_name_text": {
|
||||
"name": "mm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_granddam_name_text": {
|
||||
"name": "mm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbits_sire_id_rabbits_id_fk": {
|
||||
"name": "rabbits_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"rabbits_dam_id_rabbits_id_fk": {
|
||||
"name": "rabbits_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"sex_check": {
|
||||
"name": "sex_check",
|
||||
"value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')"
|
||||
},
|
||||
"status_check": {
|
||||
"name": "status_check",
|
||||
"value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"show_event_shows": {
|
||||
"name": "show_event_shows",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"show_event_id": {
|
||||
"name": "show_event_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sanction_number": {
|
||||
"name": "sanction_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_date": {
|
||||
"name": "show_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"club_name": {
|
||||
"name": "club_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_type": {
|
||||
"name": "show_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"specialty_breed": {
|
||||
"name": "specialty_breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secretary": {
|
||||
"name": "secretary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secretary_email": {
|
||||
"name": "secretary_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"catalog_url": {
|
||||
"name": "catalog_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maps_url": {
|
||||
"name": "maps_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_order": {
|
||||
"name": "display_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"show_event_shows_show_event_id_show_events_id_fk": {
|
||||
"name": "show_event_shows_show_event_id_show_events_id_fk",
|
||||
"tableFrom": "show_event_shows",
|
||||
"tableTo": "show_events",
|
||||
"columnsFrom": [
|
||||
"show_event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"show_events": {
|
||||
"name": "show_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,862 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "28f67c88-9254-4998-91df-8ae9934da76b",
|
||||
"prevId": "de214de9-36d5-4f26-987a-c0e80fbc1256",
|
||||
"tables": {
|
||||
"arba_shows_cache": {
|
||||
"name": "arba_shows_cache",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"fetched_at": {
|
||||
"name": "fetched_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"litters": {
|
||||
"name": "litters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"breed_date": {
|
||||
"name": "breed_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kindle_date": {
|
||||
"name": "kindle_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kit_count": {
|
||||
"name": "kit_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"litters_sire_id_rabbits_id_fk": {
|
||||
"name": "litters_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"litters_dam_id_rabbits_id_fk": {
|
||||
"name": "litters_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "litters",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rabbit_events": {
|
||||
"name": "rabbit_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"rabbit_id": {
|
||||
"name": "rabbit_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_date": {
|
||||
"name": "event_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_class": {
|
||||
"name": "show_class",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"placement": {
|
||||
"name": "placement",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbit_events_rabbit_id_rabbits_id_fk": {
|
||||
"name": "rabbit_events_rabbit_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbit_events",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"rabbit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"event_type_check": {
|
||||
"name": "event_type_check",
|
||||
"value": "\"rabbit_events\".\"type\" in ('show', 'sale', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rabbits": {
|
||||
"name": "rabbits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"farm": {
|
||||
"name": "farm",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"dob": {
|
||||
"name": "dob",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_lbs": {
|
||||
"name": "weight_lbs",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ear_number": {
|
||||
"name": "ear_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"breed": {
|
||||
"name": "breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_name": {
|
||||
"name": "color_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"photo_path": {
|
||||
"name": "photo_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_number": {
|
||||
"name": "registration_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gc_number": {
|
||||
"name": "gc_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_id": {
|
||||
"name": "sire_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_id": {
|
||||
"name": "dam_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acquired_date": {
|
||||
"name": "acquired_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"death_date": {
|
||||
"name": "death_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_a": {
|
||||
"name": "locus_a",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_b": {
|
||||
"name": "locus_b",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_c": {
|
||||
"name": "locus_c",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_d": {
|
||||
"name": "locus_d",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_e": {
|
||||
"name": "locus_e",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_en": {
|
||||
"name": "locus_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locus_v": {
|
||||
"name": "locus_v",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sire_name_text": {
|
||||
"name": "sire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dam_name_text": {
|
||||
"name": "dam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_grandsire_name_text": {
|
||||
"name": "paternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paternal_granddam_name_text": {
|
||||
"name": "paternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_grandsire_name_text": {
|
||||
"name": "maternal_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maternal_granddam_name_text": {
|
||||
"name": "maternal_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_grandsire_name_text": {
|
||||
"name": "pp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pp_granddam_name_text": {
|
||||
"name": "pp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_grandsire_name_text": {
|
||||
"name": "pm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pm_granddam_name_text": {
|
||||
"name": "pm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_grandsire_name_text": {
|
||||
"name": "mp_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mp_granddam_name_text": {
|
||||
"name": "mp_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_grandsire_name_text": {
|
||||
"name": "mm_grandsire_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mm_granddam_name_text": {
|
||||
"name": "mm_granddam_name_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"rabbits_sire_id_rabbits_id_fk": {
|
||||
"name": "rabbits_sire_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"sire_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"rabbits_dam_id_rabbits_id_fk": {
|
||||
"name": "rabbits_dam_id_rabbits_id_fk",
|
||||
"tableFrom": "rabbits",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"dam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"sex_check": {
|
||||
"name": "sex_check",
|
||||
"value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')"
|
||||
},
|
||||
"status_check": {
|
||||
"name": "status_check",
|
||||
"value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"show_event_entries": {
|
||||
"name": "show_event_entries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"show_event_show_id": {
|
||||
"name": "show_event_show_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rabbit_id": {
|
||||
"name": "rabbit_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entry_class": {
|
||||
"name": "entry_class",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entry_number": {
|
||||
"name": "entry_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"show_event_entries_show_event_show_id_show_event_shows_id_fk": {
|
||||
"name": "show_event_entries_show_event_show_id_show_event_shows_id_fk",
|
||||
"tableFrom": "show_event_entries",
|
||||
"tableTo": "show_event_shows",
|
||||
"columnsFrom": [
|
||||
"show_event_show_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"show_event_entries_rabbit_id_rabbits_id_fk": {
|
||||
"name": "show_event_entries_rabbit_id_rabbits_id_fk",
|
||||
"tableFrom": "show_event_entries",
|
||||
"tableTo": "rabbits",
|
||||
"columnsFrom": [
|
||||
"rabbit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"show_event_shows": {
|
||||
"name": "show_event_shows",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"show_event_id": {
|
||||
"name": "show_event_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sanction_number": {
|
||||
"name": "sanction_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_date": {
|
||||
"name": "show_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"club_name": {
|
||||
"name": "club_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_type": {
|
||||
"name": "show_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"specialty_breed": {
|
||||
"name": "specialty_breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secretary": {
|
||||
"name": "secretary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secretary_email": {
|
||||
"name": "secretary_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"catalog_url": {
|
||||
"name": "catalog_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maps_url": {
|
||||
"name": "maps_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_order": {
|
||||
"name": "display_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"show_event_shows_show_event_id_show_events_id_fk": {
|
||||
"name": "show_event_shows_show_event_id_show_events_id_fk",
|
||||
"tableFrom": "show_event_shows",
|
||||
"tableTo": "show_events",
|
||||
"columnsFrom": [
|
||||
"show_event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"show_events": {
|
||||
"name": "show_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,6 +29,55 @@
|
|||
"when": 1782015377645,
|
||||
"tag": "0003_add_breed_date",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1782171578085,
|
||||
"tag": "0004_add_timeline_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1782176614278,
|
||||
"tag": "0005_drop_shows",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1782176633939,
|
||||
"tag": "0006_add_rabbit_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1782177662168,
|
||||
"tag": "0007_add_breed_and_arba_cache",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1782179848230,
|
||||
"tag": "0008_add_show_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1782182504335,
|
||||
"tag": "0009_add_show_event_entries",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1782183462143,
|
||||
"tag": "0010_add_judges_placements",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export const rabbits = sqliteTable(
|
|||
earNumber: text('ear_number'),
|
||||
location: text('location'),
|
||||
status: text('status').notNull().default('active'),
|
||||
breed: text('breed'),
|
||||
colorName: text('color_name'),
|
||||
photoPath: text('photo_path'),
|
||||
registrationNumber: text('registration_number'),
|
||||
|
|
@ -20,6 +21,13 @@ export const rabbits = sqliteTable(
|
|||
sireId: integer('sire_id').references((): any => rabbits.id),
|
||||
damId: integer('dam_id').references((): any => rabbits.id),
|
||||
|
||||
// Timeline anchors (see routes/shows.ts and client RabbitTimeline). `dob` is
|
||||
// the timeline's start; `acquiredDate` is only meaningful when the rabbit was
|
||||
// added to the herd after birth (bought-in); `deathDate` records when it died
|
||||
// (independent of the `deceased` status value, which can be set without a date).
|
||||
acquiredDate: text('acquired_date'),
|
||||
deathDate: text('death_date'),
|
||||
|
||||
// Genetics loci
|
||||
locusA: text('locus_a'),
|
||||
locusB: text('locus_b'),
|
||||
|
|
@ -75,7 +83,129 @@ export const litters = sqliteTable('litters', {
|
|||
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// Manually-logged events on a rabbit's timeline. The auto-derived events (birth
|
||||
// from `dob`, breedings/kindles from `litters`) and the column-backed ones
|
||||
// (acquired/death) are NOT stored here — only `show`/`sale`/`other`. `title` is
|
||||
// the timeline headline (show name, sale label, etc.); `showClass`/`placement`
|
||||
// are only meaningful for `type='show'`. A `sale` event also flips the rabbit's
|
||||
// status to `sold` (done in routes/events.ts). See the rabbit-timeline note.
|
||||
export const rabbitEvents = sqliteTable(
|
||||
'rabbit_events',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
rabbitId: integer('rabbit_id')
|
||||
.notNull()
|
||||
.references(() => rabbits.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(),
|
||||
eventDate: text('event_date').notNull(),
|
||||
title: text('title').notNull(),
|
||||
notes: text('notes'),
|
||||
showClass: text('show_class'),
|
||||
placement: text('placement'),
|
||||
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
|
||||
},
|
||||
(table) => [check('event_type_check', sql`${table.type} in ('show', 'sale', 'other')`)]
|
||||
);
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
// Cached snapshot of the ARBA show-search listing (see routes/shows.ts and
|
||||
// lib/arba.ts). ARBA sits behind Cloudflare and only updates Mon–Fri, so we
|
||||
// scrape at most once a day and serve everything else from here. `data` is the
|
||||
// normalized show array stored as a JSON blob; `fetchedAt` drives staleness.
|
||||
// Only ever one row — the latest scrape; an insert is paired with a delete of
|
||||
// older rows in the route.
|
||||
export const arbaShowsCache = sqliteTable('arba_shows_cache', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
fetchedAt: text('fetched_at').notNull().default(sql`(current_timestamp)`),
|
||||
data: text('data').notNull(),
|
||||
});
|
||||
|
||||
// A user-created grouping of one or more ARBA shows (e.g. a weekend away with
|
||||
// several shows). Single-user app, so there's no user_id (the design doc's
|
||||
// `user_id`/`users` table doesn't exist here). Judges/entries/placements
|
||||
// (Phases 3–4) hang off `show_event_shows`, not this table.
|
||||
export const showEvents = sqliteTable('show_events', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
notes: text('notes'),
|
||||
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// A snapshot of one ARBA show as it was when added to an event. We copy the
|
||||
// fields rather than referencing the cache because the ARBA listing changes
|
||||
// daily and rolls off once a show passes — the event must keep its own record.
|
||||
// `show_type` stores the human-readable full string (e.g. "OPEN ALL BREED
|
||||
// SHOW"); `specialty_breed` is set only for specialty shows.
|
||||
export const showEventShows = sqliteTable('show_event_shows', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
showEventId: integer('show_event_id')
|
||||
.notNull()
|
||||
.references(() => showEvents.id, { onDelete: 'cascade' }),
|
||||
sanctionNumber: text('sanction_number').notNull(),
|
||||
showDate: text('show_date'),
|
||||
clubName: text('club_name').notNull(),
|
||||
showType: text('show_type'),
|
||||
specialtyBreed: text('specialty_breed'),
|
||||
city: text('city'),
|
||||
state: text('state'),
|
||||
address: text('address'),
|
||||
secretary: text('secretary'),
|
||||
secretaryEmail: text('secretary_email'),
|
||||
catalogUrl: text('catalog_url'),
|
||||
mapsUrl: text('maps_url'),
|
||||
displayOrder: integer('display_order').default(0),
|
||||
});
|
||||
|
||||
// A rabbit from the herd entered into a specific show within an event.
|
||||
// `entry_class` is free text (e.g. "Sr Buck", "6-8 Doe"). Cascades when its
|
||||
// show is removed; the rabbit FK has no cascade (deleting a rabbit shouldn't
|
||||
// silently drop show history — handled in routes/rabbits.ts's delete if needed).
|
||||
// Placements (Phase 4) will reference these rows.
|
||||
export const showEventEntries = sqliteTable('show_event_entries', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
showEventShowId: integer('show_event_show_id')
|
||||
.notNull()
|
||||
.references(() => showEventShows.id, { onDelete: 'cascade' }),
|
||||
rabbitId: integer('rabbit_id')
|
||||
.notNull()
|
||||
.references(() => rabbits.id),
|
||||
entryClass: text('entry_class'),
|
||||
entryNumber: text('entry_number'),
|
||||
notes: text('notes'),
|
||||
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// Judges assigned to a show within an event (Phase 4). Cascades with its show.
|
||||
export const showEventJudges = sqliteTable('show_event_judges', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
showEventShowId: integer('show_event_show_id')
|
||||
.notNull()
|
||||
.references(() => showEventShows.id, { onDelete: 'cascade' }),
|
||||
judgeName: text('judge_name').notNull(),
|
||||
judgeArbaNumber: text('judge_arba_number'),
|
||||
notes: text('notes'),
|
||||
});
|
||||
|
||||
// Post-show placement for a single entry (Phase 4). One row per entry (the
|
||||
// `entry_id` UNIQUE makes the PUT endpoint a clean upsert). `placement` is free
|
||||
// text (`1st`, `BOB`, `BOSV`, …); `legs_earned` is the leg count (0–3); the four
|
||||
// booleans are quick toggles for the Results Summary rollup.
|
||||
export const showEventPlacements = sqliteTable('show_event_placements', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
entryId: integer('entry_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => showEventEntries.id, { onDelete: 'cascade' }),
|
||||
placement: text('placement'),
|
||||
legsEarned: integer('legs_earned').notNull().default(0),
|
||||
bob: integer('bob', { mode: 'boolean' }).notNull().default(false),
|
||||
bos: integer('bos', { mode: 'boolean' }).notNull().default(false),
|
||||
bis: integer('bis', { mode: 'boolean' }).notNull().default(false),
|
||||
ris: integer('ris', { mode: 'boolean' }).notNull().default(false),
|
||||
notes: text('notes'),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { logAuthStatus, requireAuth } from './auth';
|
|||
import authRouter from './routes/auth';
|
||||
import rabbitsRouter from './routes/rabbits';
|
||||
import littersRouter from './routes/litters';
|
||||
import eventsRouter from './routes/events';
|
||||
import showEventsRouter from './routes/showEvents';
|
||||
import showsRouter from './routes/shows';
|
||||
import settingsRouter from './routes/settings';
|
||||
import statsRouter from './routes/stats';
|
||||
import exportRouter from './routes/export';
|
||||
|
|
@ -36,6 +39,11 @@ app.use('/api/auth', authRouter);
|
|||
app.use('/uploads', requireAuth, express.static(uploadsDir));
|
||||
app.use('/api/rabbits', requireAuth, rabbitsRouter);
|
||||
app.use('/api/litters', requireAuth, littersRouter);
|
||||
app.use('/api/events', requireAuth, eventsRouter);
|
||||
// Mounted before /api/shows so /api/shows/events/* resolves here, not to the
|
||||
// ARBA browser router (which only defines /arba paths).
|
||||
app.use('/api/shows/events', requireAuth, showEventsRouter);
|
||||
app.use('/api/shows', requireAuth, showsRouter);
|
||||
app.use('/api/settings', requireAuth, settingsRouter);
|
||||
app.use('/api/stats', requireAuth, statsRouter);
|
||||
app.use('/api/export', requireAuth, exportRouter);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import * as cheerio from 'cheerio';
|
||||
|
||||
// The public ARBA show-search listing. The page is a WordPress "Ninja Tables"
|
||||
// table whose ~2,000 rows are server-rendered into the HTML as plain positional
|
||||
// <td> cells (the cells themselves carry no column classes — only the <thead>
|
||||
// does). So we fetch the page and read each row's cells by index.
|
||||
//
|
||||
// IMPORTANT: arba.net sits behind Cloudflare. A bare request (no/!browser
|
||||
// User-Agent) gets a "Sorry, you have been blocked" challenge page instead of
|
||||
// the listing. Sending a normal browser User-Agent + Accept header is enough to
|
||||
// pass, so those headers are mandatory here — do not remove them. (We
|
||||
// deliberately do NOT set Accept-Encoding: undici/global-fetch negotiates and
|
||||
// transparently decompresses gzip/br only when it owns that header.)
|
||||
const ARBA_URL = 'https://arba.net/show-search/';
|
||||
|
||||
const BROWSER_HEADERS = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
// US states + DC. Rows whose state isn't one of these are international (blank,
|
||||
// or a Canadian province like QC/ON) and are excluded per the design doc.
|
||||
const US_STATES = new Set([
|
||||
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
||||
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
||||
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
||||
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
||||
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
|
||||
'DC',
|
||||
]);
|
||||
|
||||
// The five show-type buckets the browser filter exposes. `FAIR SHOW` wins over
|
||||
// the open/youth × all-breed/specialty split when a show is a fair show.
|
||||
export type ShowType =
|
||||
| 'ALL BREED OPEN'
|
||||
| 'ALL BREED YOUTH'
|
||||
| 'SPECIALTY OPEN'
|
||||
| 'SPECIALTY YOUTH'
|
||||
| 'FAIR SHOW'
|
||||
| 'OTHER';
|
||||
|
||||
export interface ArbaShow {
|
||||
sanctionNumber: string;
|
||||
date: string | null; // ISO YYYY-MM-DD, or null if unparseable
|
||||
rawDate: string; // original MM/DD/YYYY as shown on ARBA
|
||||
state: string;
|
||||
district: string;
|
||||
city: string;
|
||||
clubName: string;
|
||||
showType: ShowType;
|
||||
showTypeFull: string; // e.g. "OPEN ALL BREED SHOW"
|
||||
specialBreed: string | null;
|
||||
address: string;
|
||||
mapsUrl: string | null;
|
||||
secretary: string;
|
||||
email: string;
|
||||
catalogUrl: string | null;
|
||||
}
|
||||
|
||||
// "CLUB NAME, (OPEN|YOUTH) (ALL BREED|SPECIALTY) [BREED] [FAIR] SHOW[, BREED]".
|
||||
// Club names can themselves contain commas, so we anchor on the show-type phrase
|
||||
// rather than naively splitting on the first comma.
|
||||
const SHOW_INFO_RE =
|
||||
/^(.*?),\s*((OPEN|YOUTH)\s+(ALL BREED|SPECIALTY)(?:\s+BREED)?(\s+FAIR)?\s+SHOW)(?:,\s*(.*))?$/i;
|
||||
|
||||
interface ParsedShowInfo {
|
||||
clubName: string;
|
||||
showType: ShowType;
|
||||
showTypeFull: string;
|
||||
specialBreed: string | null;
|
||||
}
|
||||
|
||||
export function parseShowInformation(raw: string): ParsedShowInfo {
|
||||
const text = raw.trim();
|
||||
const m = text.match(SHOW_INFO_RE);
|
||||
if (!m) {
|
||||
// Unexpected format — keep the whole string as the club name so the show
|
||||
// isn't dropped; it just won't match a specific show-type filter.
|
||||
return { clubName: text, showType: 'OTHER', showTypeFull: '', specialBreed: null };
|
||||
}
|
||||
|
||||
const clubName = m[1].trim();
|
||||
const showTypeFull = m[2].replace(/\s+/g, ' ').trim();
|
||||
const isYouth = /YOUTH/i.test(m[3]);
|
||||
const isSpecialty = /SPECIALTY/i.test(m[4]);
|
||||
const isFair = Boolean(m[5]);
|
||||
const breed = m[6] ? m[6].trim() : '';
|
||||
|
||||
let showType: ShowType;
|
||||
if (isFair) showType = 'FAIR SHOW';
|
||||
else if (isSpecialty) showType = isYouth ? 'SPECIALTY YOUTH' : 'SPECIALTY OPEN';
|
||||
else showType = isYouth ? 'ALL BREED YOUTH' : 'ALL BREED OPEN';
|
||||
|
||||
// A breed segment is only meaningful for specialty shows; all-breed/fair shows
|
||||
// shouldn't carry one even if a stray trailing comma produced text.
|
||||
const specialBreed = isSpecialty && breed ? breed : null;
|
||||
|
||||
return { clubName, showType, showTypeFull, specialBreed };
|
||||
}
|
||||
|
||||
// MM/DD/YYYY or M/D/YYYY → ISO. Returns null for anything else so the caller can
|
||||
// still keep the original via rawDate.
|
||||
function toIsoDate(raw: string): string | null {
|
||||
const m = raw.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (!m) return null;
|
||||
const [, mm, dd, yyyy] = m;
|
||||
return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function cellHref($cell: cheerio.Cheerio<any>): string | null {
|
||||
const href = $cell.find('a').first().attr('href');
|
||||
return href ? href.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the ARBA listing and return the normalized, US-only, cavy-excluded set.
|
||||
* Throws on a Cloudflare block, a non-OK response, or a missing/empty table so
|
||||
* the caller can surface an error state and keep serving the previous cache.
|
||||
*/
|
||||
export async function fetchArbaShows(): Promise<ArbaShow[]> {
|
||||
let html: string;
|
||||
try {
|
||||
const res = await fetch(ARBA_URL, { headers: BROWSER_HEADERS });
|
||||
if (!res.ok) {
|
||||
throw new Error(`ARBA responded with HTTP ${res.status}`);
|
||||
}
|
||||
html = await res.text();
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to reach ARBA: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (/you have been blocked|cf-error-details|Attention Required/i.test(html)) {
|
||||
throw new Error('ARBA request was blocked by Cloudflare');
|
||||
}
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
// The data table is the one whose header row defines the sanction-number
|
||||
// column. Anchoring on that class avoids picking up unrelated page tables.
|
||||
const table = $('th.ninja_clmn_nm_sanctionnumber').first().closest('table');
|
||||
const rows = table.find('tbody tr');
|
||||
if (rows.length === 0) {
|
||||
throw new Error('ARBA listing table was empty or not found (page layout may have changed)');
|
||||
}
|
||||
|
||||
const shows: ArbaShow[] = [];
|
||||
rows.each((_, tr) => {
|
||||
const cells = $(tr).find('td');
|
||||
if (cells.length < 10) return; // malformed/placeholder row
|
||||
|
||||
const sanctionNumber = cells.eq(0).text().trim();
|
||||
const rawDate = cells.eq(1).text().trim();
|
||||
const state = cells.eq(2).text().trim().toUpperCase();
|
||||
const district = cells.eq(3).text().trim();
|
||||
const city = cells.eq(4).text().trim();
|
||||
const showInformation = cells.eq(5).text().trim();
|
||||
const address = cells.eq(6).text().trim();
|
||||
const mapsUrl = cellHref(cells.eq(7));
|
||||
const secretary = cells.eq(8).text().trim();
|
||||
const email = cells.eq(9).text().trim();
|
||||
const catalogUrl = cells.length > 10 ? cellHref(cells.eq(10)) : null;
|
||||
|
||||
// Cavy shows are excluded at normalization (CAVY appears as the breed
|
||||
// segment, e.g. "..., OPEN SPECIALTY BREED SHOW, CAVY").
|
||||
if (/\bCAVY\b/i.test(showInformation)) return;
|
||||
// US-only.
|
||||
if (!US_STATES.has(state)) return;
|
||||
|
||||
const { clubName, showType, showTypeFull, specialBreed } =
|
||||
parseShowInformation(showInformation);
|
||||
|
||||
shows.push({
|
||||
sanctionNumber,
|
||||
date: toIsoDate(rawDate),
|
||||
rawDate,
|
||||
state,
|
||||
district,
|
||||
city,
|
||||
clubName,
|
||||
showType,
|
||||
showTypeFull,
|
||||
specialBreed,
|
||||
address,
|
||||
mapsUrl,
|
||||
secretary,
|
||||
email,
|
||||
catalogUrl,
|
||||
});
|
||||
});
|
||||
|
||||
// Default sort: date ascending, then state.
|
||||
shows.sort((a, b) => {
|
||||
const da = a.date ?? '9999-99-99';
|
||||
const db = b.date ?? '9999-99-99';
|
||||
if (da !== db) return da < db ? -1 : 1;
|
||||
return a.state.localeCompare(b.state);
|
||||
});
|
||||
|
||||
return shows;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.6",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
|
|
@ -1149,6 +1150,12 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
|
|
@ -1228,6 +1235,48 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
|
|
@ -1306,6 +1355,34 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1365,6 +1442,61 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.31.10",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||
|
|
@ -2019,6 +2151,31 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
|
|
@ -2028,6 +2185,18 @@
|
|||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
|
@ -2334,6 +2503,37 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -2594,6 +2794,18 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -2636,6 +2848,55 @@
|
|||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
|
@ -3180,6 +3441,15 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
|
|
@ -3211,6 +3481,40 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.6",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { Router } from 'express';
|
||||
import { asc, eq, getTableColumns } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { rabbitEvents, rabbits } from '../db/schema';
|
||||
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Mass-assignment guard (see routes/rabbits.ts): id/created_at are server-managed.
|
||||
const EVENT_SYSTEM_FIELDS = new Set(['id', 'createdAt']);
|
||||
const EVENT_WRITABLE = Object.keys(getTableColumns(rabbitEvents)).filter(
|
||||
(key) => !EVENT_SYSTEM_FIELDS.has(key)
|
||||
);
|
||||
|
||||
const VALID_TYPES = new Set(['show', 'sale', 'other']);
|
||||
|
||||
// List a rabbit's manually-logged events oldest-first so the timeline can splice
|
||||
// them straight in. `rabbit_id` is required — there is no all-events view in v1.
|
||||
router.get('/', (req, res) => {
|
||||
const rabbitId = Number(req.query.rabbit_id);
|
||||
if (!rabbitId) {
|
||||
res.status(400).json({ error: 'rabbit_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = db
|
||||
.select()
|
||||
.from(rabbitEvents)
|
||||
.where(eq(rabbitEvents.rabbitId, rabbitId))
|
||||
.orderBy(asc(rabbitEvents.eventDate))
|
||||
.all();
|
||||
|
||||
res.json(results.map(toSnakeCase));
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { rabbit_id, type, event_date, title } = req.body;
|
||||
|
||||
if (!rabbit_id || typeof rabbit_id !== 'number') {
|
||||
res.status(400).json({ error: 'rabbit_id is required' });
|
||||
return;
|
||||
}
|
||||
if (typeof type !== 'string' || !VALID_TYPES.has(type)) {
|
||||
res.status(400).json({ error: `Invalid event type: ${type}` });
|
||||
return;
|
||||
}
|
||||
if (typeof event_date !== 'string' || event_date.trim() === '') {
|
||||
res.status(400).json({ error: 'A date is required' });
|
||||
return;
|
||||
}
|
||||
if (typeof title !== 'string' || title.trim() === '') {
|
||||
res.status(400).json({ error: 'A title is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as typeof rabbitEvents.$inferInsert;
|
||||
|
||||
// A sale also marks the rabbit Sold so the dashboard counts stay accurate.
|
||||
// Done in the same transaction as the insert so the two can't drift.
|
||||
const created = db.transaction((tx) => {
|
||||
const event = tx.insert(rabbitEvents).values(values).returning().get();
|
||||
if (event.type === 'sale') {
|
||||
tx.update(rabbits)
|
||||
.set({ status: 'sold', updatedAt: new Date().toISOString() })
|
||||
.where(eq(rabbits.id, event.rabbitId))
|
||||
.run();
|
||||
}
|
||||
return event;
|
||||
});
|
||||
|
||||
res.status(201).json(toSnakeCase(created));
|
||||
});
|
||||
|
||||
router.patch('/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
// rabbit_id and type are immutable once set — a logged event belongs to its
|
||||
// rabbit and keeps its kind; only its fields are editable.
|
||||
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as Partial<
|
||||
typeof rabbitEvents.$inferInsert
|
||||
>;
|
||||
delete (values as Record<string, unknown>).rabbitId;
|
||||
delete (values as Record<string, unknown>).type;
|
||||
|
||||
const updated = db
|
||||
.update(rabbitEvents)
|
||||
.set(values)
|
||||
.where(eq(rabbitEvents.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(toSnakeCase(updated));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
const existing = db.select().from(rabbitEvents).where(eq(rabbitEvents.id, id)).get();
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.delete(rabbitEvents).where(eq(rabbitEvents.id, id)).run();
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -5,7 +5,7 @@ import { settings } from '../db/schema';
|
|||
|
||||
const router = Router();
|
||||
|
||||
const ALLOWED_KEYS = ['rabbitry_name', 'weight_unit', 'kit_prefix', 'theme'] as const;
|
||||
const ALLOWED_KEYS = ['rabbitry_name', 'weight_unit', 'kit_prefix', 'theme', 'default_breed'] as const;
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.select().from(settings).all();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,657 @@
|
|||
import { Router } from 'express';
|
||||
import { and, asc, desc, eq, getTableColumns, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import {
|
||||
showEvents,
|
||||
showEventShows,
|
||||
showEventEntries,
|
||||
showEventJudges,
|
||||
showEventPlacements,
|
||||
rabbits,
|
||||
settings,
|
||||
} from '../db/schema';
|
||||
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Mass-assignment guard (see routes/events.ts): only `name`/`notes` are
|
||||
// client-writable on an event; id/timestamps are server-managed.
|
||||
const EVENT_SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt']);
|
||||
const EVENT_WRITABLE = Object.keys(getTableColumns(showEvents)).filter(
|
||||
(key) => !EVENT_SYSTEM_FIELDS.has(key)
|
||||
);
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// The show within an event identified by both ids, or undefined — used to verify
|
||||
// a /:showId path actually belongs to the /:id event before mutating its rows.
|
||||
function getEventShow(eventId: number, showId: number) {
|
||||
return db
|
||||
.select()
|
||||
.from(showEventShows)
|
||||
.where(and(eq(showEventShows.id, showId), eq(showEventShows.showEventId, eventId)))
|
||||
.get();
|
||||
}
|
||||
|
||||
// Entries for a set of shows, each joined to its rabbit's display fields, keyed
|
||||
// by `showEventShowId`. Rows are camelCase here; callers snake-case at the
|
||||
// response boundary (the report builder uses them camel). leftJoin so an entry
|
||||
// whose rabbit was later deleted still returns (with null rabbit fields) rather
|
||||
// than vanishing.
|
||||
function entriesByShow(showIds: number[]) {
|
||||
const map = new Map<number, Record<string, unknown>[]>();
|
||||
if (showIds.length === 0) return map;
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
id: showEventEntries.id,
|
||||
showEventShowId: showEventEntries.showEventShowId,
|
||||
rabbitId: showEventEntries.rabbitId,
|
||||
entryClass: showEventEntries.entryClass,
|
||||
entryNumber: showEventEntries.entryNumber,
|
||||
notes: showEventEntries.notes,
|
||||
createdAt: showEventEntries.createdAt,
|
||||
rabbitName: rabbits.name,
|
||||
rabbitFarm: rabbits.farm,
|
||||
rabbitEarNumber: rabbits.earNumber,
|
||||
rabbitBreed: rabbits.breed,
|
||||
rabbitColorName: rabbits.colorName,
|
||||
rabbitSex: rabbits.sex,
|
||||
})
|
||||
.from(showEventEntries)
|
||||
.leftJoin(rabbits, eq(showEventEntries.rabbitId, rabbits.id))
|
||||
.where(inArray(showEventEntries.showEventShowId, showIds))
|
||||
.orderBy(asc(showEventEntries.id))
|
||||
.all();
|
||||
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.showEventShowId) ?? [];
|
||||
list.push(row);
|
||||
map.set(row.showEventShowId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Judges for a set of shows, keyed by `showEventShowId`.
|
||||
function judgesByShow(showIds: number[]) {
|
||||
const map = new Map<number, Record<string, unknown>[]>();
|
||||
if (showIds.length === 0) return map;
|
||||
const rows = db
|
||||
.select()
|
||||
.from(showEventJudges)
|
||||
.where(inArray(showEventJudges.showEventShowId, showIds))
|
||||
.orderBy(asc(showEventJudges.id))
|
||||
.all();
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.showEventShowId) ?? [];
|
||||
list.push(row);
|
||||
map.set(row.showEventShowId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Placements keyed by `entryId` (at most one per entry — see the UNIQUE column).
|
||||
function placementsByEntry(entryIds: number[]) {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
if (entryIds.length === 0) return map;
|
||||
const rows = db
|
||||
.select()
|
||||
.from(showEventPlacements)
|
||||
.where(inArray(showEventPlacements.entryId, entryIds))
|
||||
.all();
|
||||
for (const row of rows) map.set(row.entryId, row);
|
||||
return map;
|
||||
}
|
||||
|
||||
// List events with derived counts (number of shows) and the date span of their
|
||||
// shows, computed in one grouped query and merged in JS — no cached counters,
|
||||
// matching the dashboard-stats approach. Newest-updated first.
|
||||
router.get('/', (req, res) => {
|
||||
const events = db.select().from(showEvents).orderBy(desc(showEvents.updatedAt)).all();
|
||||
|
||||
const agg = db
|
||||
.select({
|
||||
showEventId: showEventShows.showEventId,
|
||||
showCount: sql<number>`count(*)`,
|
||||
firstDate: sql<string | null>`min(${showEventShows.showDate})`,
|
||||
lastDate: sql<string | null>`max(${showEventShows.showDate})`,
|
||||
})
|
||||
.from(showEventShows)
|
||||
.groupBy(showEventShows.showEventId)
|
||||
.all();
|
||||
|
||||
const byEvent = new Map(agg.map((row) => [row.showEventId, row]));
|
||||
|
||||
const result = events.map((event) => {
|
||||
const stats = byEvent.get(event.id);
|
||||
return {
|
||||
...toSnakeCase(event),
|
||||
show_count: stats?.showCount ?? 0,
|
||||
first_show_date: stats?.firstDate ?? null,
|
||||
last_show_date: stats?.lastDate ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
const id = Number(req.params.id);
|
||||
const event = db.select().from(showEvents).where(eq(showEvents.id, id)).get();
|
||||
if (!event) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const shows = db
|
||||
.select()
|
||||
.from(showEventShows)
|
||||
.where(eq(showEventShows.showEventId, id))
|
||||
.orderBy(asc(showEventShows.showDate), asc(showEventShows.id))
|
||||
.all();
|
||||
|
||||
const entriesMap = entriesByShow(shows.map((s) => s.id));
|
||||
const judgesMap = judgesByShow(shows.map((s) => s.id));
|
||||
const allEntryIds = [...entriesMap.values()].flat().map((e) => Number(e.id));
|
||||
const placementsMap = placementsByEntry(allEntryIds);
|
||||
|
||||
const showsOut = shows.map((s) => ({
|
||||
...toSnakeCase(s),
|
||||
judges: (judgesMap.get(s.id) ?? []).map(toSnakeCase),
|
||||
entries: (entriesMap.get(s.id) ?? []).map((e) => {
|
||||
const placement = placementsMap.get(Number(e.id));
|
||||
return {
|
||||
...toSnakeCase(e),
|
||||
placement: placement ? toSnakeCase(placement) : null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
res.json({ ...toSnakeCase(event), shows: showsOut });
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
res.status(400).json({ error: 'A name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as typeof showEvents.$inferInsert;
|
||||
const created = db.insert(showEvents).values(values).returning().get();
|
||||
res.status(201).json(toSnakeCase(created));
|
||||
});
|
||||
|
||||
router.patch('/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as Partial<
|
||||
typeof showEvents.$inferInsert
|
||||
>;
|
||||
values.updatedAt = nowIso();
|
||||
|
||||
const updated = db
|
||||
.update(showEvents)
|
||||
.set(values)
|
||||
.where(eq(showEvents.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(toSnakeCase(updated));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.select().from(showEvents).where(eq(showEvents.id, id)).get();
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
// show_event_shows cascades via the FK.
|
||||
db.delete(showEvents).where(eq(showEvents.id, id)).run();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Add an ARBA show to an event. The request body is an ARBA show object (the
|
||||
// snake_case shape GET /api/shows/arba returns); we snapshot the relevant
|
||||
// fields rather than storing a reference, since the ARBA listing is transient.
|
||||
// Idempotent per (event, sanction_number): re-adding returns the existing row.
|
||||
router.post('/:id/shows', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const event = db.select().from(showEvents).where(eq(showEvents.id, eventId)).get();
|
||||
if (!event) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body ?? {};
|
||||
const sanctionNumber = body.sanction_number;
|
||||
const clubName = body.club_name;
|
||||
if (typeof sanctionNumber !== 'string' || sanctionNumber.trim() === '') {
|
||||
res.status(400).json({ error: 'sanction_number is required' });
|
||||
return;
|
||||
}
|
||||
if (typeof clubName !== 'string' || clubName.trim() === '') {
|
||||
res.status(400).json({ error: 'club_name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(showEventShows)
|
||||
.where(
|
||||
and(
|
||||
eq(showEventShows.showEventId, eventId),
|
||||
eq(showEventShows.sanctionNumber, sanctionNumber)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (existing) {
|
||||
res.status(200).json(toSnakeCase(existing));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: typeof showEventShows.$inferInsert = {
|
||||
showEventId: eventId,
|
||||
sanctionNumber,
|
||||
showDate: body.date || body.raw_date || null,
|
||||
clubName,
|
||||
showType: body.show_type_full || body.show_type || null,
|
||||
specialtyBreed: body.special_breed || null,
|
||||
city: body.city || null,
|
||||
state: body.state || null,
|
||||
address: body.address || null,
|
||||
secretary: body.secretary || null,
|
||||
secretaryEmail: body.email || null,
|
||||
catalogUrl: body.catalog_url || null,
|
||||
mapsUrl: body.maps_url || null,
|
||||
};
|
||||
|
||||
const created = db.transaction((tx) => {
|
||||
const row = tx.insert(showEventShows).values(snapshot).returning().get();
|
||||
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
|
||||
return row;
|
||||
});
|
||||
|
||||
res.status(201).json(toSnakeCase(created));
|
||||
});
|
||||
|
||||
router.delete('/:id/shows/:showId', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(showEventShows)
|
||||
.where(and(eq(showEventShows.id, showId), eq(showEventShows.showEventId, eventId)))
|
||||
.get();
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.delete(showEventShows).where(eq(showEventShows.id, showId)).run();
|
||||
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---- Entries (a rabbit entered into a specific show within the event) ----
|
||||
|
||||
// Returns the entry joined to its rabbit's display fields (the same shape the
|
||||
// detail endpoint embeds), so the client can render it without a refetch.
|
||||
function entryWithRabbit(entryId: number) {
|
||||
const [row] = entriesByShowEntryIds([entryId]);
|
||||
return row;
|
||||
}
|
||||
function entriesByShowEntryIds(ids: number[]): Record<string, unknown>[] {
|
||||
if (ids.length === 0) return [];
|
||||
return db
|
||||
.select({
|
||||
id: showEventEntries.id,
|
||||
showEventShowId: showEventEntries.showEventShowId,
|
||||
rabbitId: showEventEntries.rabbitId,
|
||||
entryClass: showEventEntries.entryClass,
|
||||
entryNumber: showEventEntries.entryNumber,
|
||||
notes: showEventEntries.notes,
|
||||
createdAt: showEventEntries.createdAt,
|
||||
rabbitName: rabbits.name,
|
||||
rabbitFarm: rabbits.farm,
|
||||
rabbitEarNumber: rabbits.earNumber,
|
||||
rabbitBreed: rabbits.breed,
|
||||
rabbitColorName: rabbits.colorName,
|
||||
rabbitSex: rabbits.sex,
|
||||
})
|
||||
.from(showEventEntries)
|
||||
.leftJoin(rabbits, eq(showEventEntries.rabbitId, rabbits.id))
|
||||
.where(inArray(showEventEntries.id, ids))
|
||||
.all();
|
||||
}
|
||||
|
||||
router.post('/:id/shows/:showId/entries', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rabbitId = req.body?.rabbit_id;
|
||||
if (typeof rabbitId !== 'number') {
|
||||
res.status(400).json({ error: 'rabbit_id is required' });
|
||||
return;
|
||||
}
|
||||
const rabbit = db.select().from(rabbits).where(eq(rabbits.id, rabbitId)).get();
|
||||
if (!rabbit) {
|
||||
res.status(400).json({ error: 'Rabbit not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const values: typeof showEventEntries.$inferInsert = {
|
||||
showEventShowId: showId,
|
||||
rabbitId,
|
||||
entryClass: req.body.entry_class ?? null,
|
||||
entryNumber: req.body.entry_number ?? null,
|
||||
notes: req.body.notes ?? null,
|
||||
};
|
||||
|
||||
const created = db.transaction((tx) => {
|
||||
const row = tx.insert(showEventEntries).values(values).returning().get();
|
||||
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
|
||||
return row;
|
||||
});
|
||||
|
||||
res.status(201).json(toSnakeCase(entryWithRabbit(created.id)));
|
||||
});
|
||||
|
||||
const ENTRY_WRITABLE = ['entryClass', 'entryNumber', 'notes'];
|
||||
|
||||
router.patch('/:id/shows/:showId/entries/:entryId', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
const entryId = Number(req.params.entryId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = db
|
||||
.select()
|
||||
.from(showEventEntries)
|
||||
.where(and(eq(showEventEntries.id, entryId), eq(showEventEntries.showEventShowId, showId)))
|
||||
.get();
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Entry not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the editable fields — never the rabbit or which show it's in.
|
||||
const values = pick(toCamelCase(req.body), ENTRY_WRITABLE);
|
||||
db.update(showEventEntries).set(values).where(eq(showEventEntries.id, entryId)).run();
|
||||
|
||||
res.json(toSnakeCase(entryWithRabbit(entryId)));
|
||||
});
|
||||
|
||||
router.delete('/:id/shows/:showId/entries/:entryId', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
const entryId = Number(req.params.entryId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = db
|
||||
.select()
|
||||
.from(showEventEntries)
|
||||
.where(and(eq(showEventEntries.id, entryId), eq(showEventEntries.showEventShowId, showId)))
|
||||
.get();
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Entry not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.delete(showEventEntries).where(eq(showEventEntries.id, entryId)).run();
|
||||
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---- Entries report (plain text, for emailing a show secretary) ----
|
||||
|
||||
// ISO YYYY-MM-DD → MM/DD/YYYY (the format show secretaries expect); pass through
|
||||
// anything that isn't a clean ISO date.
|
||||
function reportDate(value: string | null): string {
|
||||
const m = (value ?? '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
return m ? `${m[2]}/${m[3]}/${m[1]}` : (value ?? '');
|
||||
}
|
||||
|
||||
router.get('/:id/report', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const event = db.select().from(showEvents).where(eq(showEvents.id, id)).get();
|
||||
if (!event) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const shows = db
|
||||
.select()
|
||||
.from(showEventShows)
|
||||
.where(eq(showEventShows.showEventId, id))
|
||||
.orderBy(asc(showEventShows.showDate), asc(showEventShows.id))
|
||||
.all();
|
||||
const entriesMap = entriesByShow(shows.map((s) => s.id));
|
||||
|
||||
// Owner line comes from the rabbitry name in settings (per the resolved
|
||||
// decision: no ARBA membership number is collected, so it's omitted).
|
||||
const rabbitryRow = db.select().from(settings).where(eq(settings.key, 'rabbitry_name')).get();
|
||||
const rabbitryName = rabbitryRow?.value ?? '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`SHOW ENTRIES — ${event.name}`);
|
||||
lines.push(`Generated: ${reportDate(new Date().toISOString().slice(0, 10))}`);
|
||||
lines.push('');
|
||||
|
||||
let anyEntries = false;
|
||||
for (const show of shows) {
|
||||
const entries = entriesMap.get(show.id) ?? [];
|
||||
if (entries.length === 0) continue;
|
||||
anyEntries = true;
|
||||
|
||||
const sep = '='.repeat(46);
|
||||
lines.push(sep);
|
||||
lines.push(`${show.clubName}${show.showType ? ` — ${show.showType}` : ''}`);
|
||||
lines.push(`Sanction #: ${show.sanctionNumber}`);
|
||||
lines.push(`Date: ${reportDate(show.showDate)}`);
|
||||
if (show.secretary || show.secretaryEmail) {
|
||||
const email = show.secretaryEmail ? ` <${show.secretaryEmail}>` : '';
|
||||
lines.push(`Secretary: ${show.secretary ?? ''}${email}`.trim());
|
||||
}
|
||||
lines.push(sep);
|
||||
lines.push('');
|
||||
lines.push('RABBITS ENTERED:');
|
||||
lines.push('');
|
||||
|
||||
entries.forEach((e, idx) => {
|
||||
const tattoo = e.rabbitEarNumber ? ` (${e.rabbitEarNumber})` : '';
|
||||
lines.push(`${idx + 1}. ${e.rabbitName ?? 'Unknown'}${tattoo}`);
|
||||
const breedVariety = [e.rabbitBreed, e.rabbitColorName].filter(Boolean).join(' / ');
|
||||
if (breedVariety) lines.push(` Breed: ${breedVariety}`);
|
||||
if (e.entryClass) lines.push(` Class: ${e.entryClass}`);
|
||||
if (rabbitryName) lines.push(` Owner: ${rabbitryName}`);
|
||||
if (e.notes) lines.push(` Notes: ${e.notes}`);
|
||||
lines.push('');
|
||||
});
|
||||
}
|
||||
|
||||
if (!anyEntries) {
|
||||
lines.push('No rabbits have been entered yet.');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
res.json({ report: lines.join('\n') });
|
||||
});
|
||||
|
||||
// ---- Judges (assigned per show within an event) ----
|
||||
|
||||
const JUDGE_WRITABLE = ['judgeName', 'judgeArbaNumber', 'notes'];
|
||||
|
||||
router.post('/:id/shows/:showId/judges', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
const judgeName = req.body?.judge_name;
|
||||
if (typeof judgeName !== 'string' || judgeName.trim() === '') {
|
||||
res.status(400).json({ error: 'judge_name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const created = db.transaction((tx) => {
|
||||
const row = tx
|
||||
.insert(showEventJudges)
|
||||
.values({
|
||||
showEventShowId: showId,
|
||||
judgeName: judgeName.trim(),
|
||||
judgeArbaNumber: req.body.judge_arba_number ?? null,
|
||||
notes: req.body.notes ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
|
||||
return row;
|
||||
});
|
||||
|
||||
res.status(201).json(toSnakeCase(created));
|
||||
});
|
||||
|
||||
router.patch('/:id/shows/:showId/judges/:judgeId', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
const judgeId = Number(req.params.judgeId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
const judge = db
|
||||
.select()
|
||||
.from(showEventJudges)
|
||||
.where(and(eq(showEventJudges.id, judgeId), eq(showEventJudges.showEventShowId, showId)))
|
||||
.get();
|
||||
if (!judge) {
|
||||
res.status(404).json({ error: 'Judge not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = pick(toCamelCase(req.body), JUDGE_WRITABLE);
|
||||
const updated = db
|
||||
.update(showEventJudges)
|
||||
.set(values)
|
||||
.where(eq(showEventJudges.id, judgeId))
|
||||
.returning()
|
||||
.get();
|
||||
res.json(toSnakeCase(updated));
|
||||
});
|
||||
|
||||
router.delete('/:id/shows/:showId/judges/:judgeId', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
const judgeId = Number(req.params.judgeId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
const judge = db
|
||||
.select()
|
||||
.from(showEventJudges)
|
||||
.where(and(eq(showEventJudges.id, judgeId), eq(showEventJudges.showEventShowId, showId)))
|
||||
.get();
|
||||
if (!judge) {
|
||||
res.status(404).json({ error: 'Judge not found' });
|
||||
return;
|
||||
}
|
||||
db.delete(showEventJudges).where(eq(showEventJudges.id, judgeId)).run();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---- Placement (one per entry; PUT is a full-object upsert) ----
|
||||
|
||||
function clampLegs(value: unknown): number {
|
||||
const n = Math.trunc(Number(value));
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.max(0, Math.min(3, n));
|
||||
}
|
||||
|
||||
router.put('/:id/shows/:showId/entries/:entryId/placement', (req, res) => {
|
||||
const eventId = Number(req.params.id);
|
||||
const showId = Number(req.params.showId);
|
||||
const entryId = Number(req.params.entryId);
|
||||
if (!getEventShow(eventId, showId)) {
|
||||
res.status(404).json({ error: 'Show not found in this event' });
|
||||
return;
|
||||
}
|
||||
const entry = db
|
||||
.select()
|
||||
.from(showEventEntries)
|
||||
.where(and(eq(showEventEntries.id, entryId), eq(showEventEntries.showEventShowId, showId)))
|
||||
.get();
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Entry not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body ?? {};
|
||||
const fields = {
|
||||
placement: typeof body.placement === 'string' && body.placement.trim() ? body.placement.trim() : null,
|
||||
legsEarned: clampLegs(body.legs_earned),
|
||||
bob: Boolean(body.bob),
|
||||
bos: Boolean(body.bos),
|
||||
bis: Boolean(body.bis),
|
||||
ris: Boolean(body.ris),
|
||||
notes: typeof body.notes === 'string' && body.notes.trim() ? body.notes.trim() : null,
|
||||
};
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(showEventPlacements)
|
||||
.where(eq(showEventPlacements.entryId, entryId))
|
||||
.get();
|
||||
|
||||
const saved = db.transaction((tx) => {
|
||||
let row;
|
||||
if (existing) {
|
||||
row = tx
|
||||
.update(showEventPlacements)
|
||||
.set(fields)
|
||||
.where(eq(showEventPlacements.id, existing.id))
|
||||
.returning()
|
||||
.get();
|
||||
} else {
|
||||
row = tx
|
||||
.insert(showEventPlacements)
|
||||
.values({ entryId, ...fields })
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
|
||||
return row;
|
||||
});
|
||||
|
||||
res.json(toSnakeCase(saved));
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Router } from 'express';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { arbaShowsCache } from '../db/schema';
|
||||
import { toSnakeCase } from '../utils/case';
|
||||
import { fetchArbaShows, ArbaShow } from '../lib/arba';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ARBA only updates Mon–Fri during business hours, so the cache is considered
|
||||
// fresh for a day; the first request after that triggers a re-scrape.
|
||||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CacheEntry {
|
||||
fetchedAt: string;
|
||||
shows: ArbaShow[];
|
||||
}
|
||||
|
||||
// Latest (and only) cache row, parsed back into objects — or null if never run.
|
||||
function readCache(): CacheEntry | null {
|
||||
const row = db.select().from(arbaShowsCache).orderBy(desc(arbaShowsCache.id)).limit(1).get();
|
||||
if (!row) return null;
|
||||
return { fetchedAt: row.fetchedAt, shows: JSON.parse(row.data) as ArbaShow[] };
|
||||
}
|
||||
|
||||
// Scrape ARBA and replace the cache with the fresh result (one row only).
|
||||
async function refreshCache(): Promise<CacheEntry> {
|
||||
const shows = await fetchArbaShows();
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const data = JSON.stringify(shows);
|
||||
db.transaction((tx) => {
|
||||
tx.delete(arbaShowsCache).run();
|
||||
tx.insert(arbaShowsCache).values({ fetchedAt, data }).run();
|
||||
});
|
||||
return { fetchedAt, shows };
|
||||
}
|
||||
|
||||
function isStale(entry: CacheEntry): boolean {
|
||||
const fetchedMs = Date.parse(entry.fetchedAt);
|
||||
return Number.isNaN(fetchedMs) || Date.now() - fetchedMs > STALE_MS;
|
||||
}
|
||||
|
||||
function respond(res: import('express').Response, entry: CacheEntry, stale: boolean): void {
|
||||
res.json({
|
||||
fetched_at: entry.fetchedAt,
|
||||
stale,
|
||||
shows: entry.shows.map((show) => toSnakeCase(show as unknown as Record<string, unknown>)),
|
||||
});
|
||||
}
|
||||
|
||||
// Return the cached ARBA listing, re-scraping first if the cache is missing or
|
||||
// older than STALE_MS. If a needed scrape fails but we still hold a cache, we
|
||||
// serve the stale cache (flagged) rather than erroring — the design doc's "cache
|
||||
// remains available until it can be refreshed".
|
||||
router.get('/arba', async (req, res, next) => {
|
||||
try {
|
||||
let entry = readCache();
|
||||
|
||||
if (!entry || isStale(entry)) {
|
||||
try {
|
||||
entry = await refreshCache();
|
||||
respond(res, entry, false);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!entry) {
|
||||
res
|
||||
.status(502)
|
||||
.json({ error: `Could not load ARBA shows: ${(err as Error).message}` });
|
||||
return;
|
||||
}
|
||||
// Fall through and serve the stale cache we already have.
|
||||
console.error('ARBA refresh failed, serving stale cache:', err);
|
||||
respond(res, entry, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
respond(res, entry, false);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Force a re-scrape. On failure the previous cache is left untouched.
|
||||
router.post('/arba/refresh', async (req, res) => {
|
||||
try {
|
||||
const entry = await refreshCache();
|
||||
respond(res, entry, false);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: `ARBA refresh failed: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Reference in New Issue