Initial commit: MyKindlebox rabbitry management app

React + Vite client and Express + SQLite/Drizzle server, plus
memory-bank design docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-06-21 23:29:56 -04:00
commit c961e48953
101 changed files with 16596 additions and 0 deletions

8
.claude/settings.json Normal file
View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(node -e ' *)",
"Bash(curl -s http://localhost:3001/api/rabbits)"
]
}
}

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Runtime data (SQLite DB + uploaded photos) — never commit the herd database
mykindlebox/server/data/
# Scratch data dirs (e.g. throwaway DB used to test imports)
.tmp-*/
# Environment / secrets
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
# Editor / OS
.DS_Store
Thumbs.db
.vscode/
.idea/
# Claude Code local (machine-specific) settings
.claude/settings.local.json

81
CLAUDE.md Normal file
View File

@ -0,0 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# IMPORTANT:
# Always read memory-bank/@architecture.md before writing any code. Include entire database schema.
# Always read memory-bank/@app-design-document.md before writing any code.
# After adding a major feature or completing a milestone, update memory-bank/@architecture.md.
## Project status
The application is built and lives under [mykindlebox/](mykindlebox/) — a `client/` (React + Vite) and a `server/` (Express + SQLite/Drizzle). See [memory-bank/progress.md](memory-bank/progress.md) for the phase-by-phase build log. The source-of-truth design docs are:
- [memory-bank/app-design-document.md](memory-bank/app-design-document.md) — full product spec: pages, components, data models, UX decisions
- [memory-bank/tech-stack.md](memory-bank/tech-stack.md) — the chosen stack (summarized below)
- [memory-bank/architecture.md](memory-bank/architecture.md) — current architecture + database schema
### Commands
Backend (`mykindlebox/server`):
- `npm run dev` — start the API on port 3001 with `tsx watch` (hot reload)
- `npm run build` — typecheck + compile to `dist/`
- `npm start` — run the compiled server (`node dist/index.js`)
- There is no test suite yet (`npm test` is a placeholder).
Frontend (`mykindlebox/client`):
- `npm run dev` — Vite dev server on port 5173 (proxies `/api` and `/uploads` to `localhost:3001`)
- `npm run build` — production build to `dist/`
- `npm run lint` — ESLint
Deployment: `docker compose up --build` from `mykindlebox/` serves the app on port 8080 (nginx serves the built client and reverse-proxies `/api` + `/uploads` to the Node server). Set `MYKINDLEBOX_PASSWORD` (e.g. in `mykindlebox/.env`) to require a login; if unset, auth is disabled.
### Conventions
- The REST API speaks **snake_case** at the boundary; Drizzle columns are camelCase. `server/utils/case.ts` converts both directions — don't hand-write conversions.
- The client talks to the API over **relative URLs** (`/api`, `/uploads`). Never reintroduce a hardcoded `http://localhost:3001`; it breaks LAN/PWA access from other devices.
## What MyKindlebox is
A self-hosted rabbitry (rabbit breeding herd) management web app, replacing an existing PHP app (`belowland.com/rabbitsite`). It is single-user, self-hosted, and out of scope for v1 includes multi-tenancy, public listing pages, show tracking, and payments.
The core differentiator: **adding a rabbit starts from a pedigree tree, not a blank form** — the 3-generation ancestor tree (sire/dam, their sires/dams) is the primary data-entry surface, with inline autocomplete search against existing herd records and graceful fallback to freeform text for unknown/bought-in ancestors.
## Tech stack (planned)
| Layer | Choice |
|---|---|
| Frontend | React + Vite (PWA-ready) |
| Routing | React Router v7 |
| State | Zustand (local UI state) + React Query (server sync) |
| Styling | Tailwind CSS with custom theme tokens |
| Backend | Node.js + Express (REST API) |
| Database | SQLite via Drizzle ORM (single-file DB) |
| Auth | Single-user session token (no OAuth) |
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
## Data model
There are only two core tables — no separate "Kit" table:
- **Rabbit**: name, sex, dob, weight_lbs, status (`active`/`for_sale`/`on_hold`/`sold`/`deceased`/`other`), `sire_id`/`dam_id` (self-referencing FKs), and genetics loci fields (`locus_a/b/c/d/e/en/v`).
- **Litter**: `sire_id`/`dam_id`, `kindle_date`, `kit_count`, `notes`.
Kits are full Rabbit records from birth, linked to a litter implicitly via matching `sire_id`/`dam_id`/DOB — the Litter detail page derives its kit list by querying Rabbits rather than a join table. When adding features touching litters or kits, preserve this derivation rather than introducing a Kit entity.
Ancestor pedigree nodes that aren't in the DB are stored as freeform text rather than null FKs, so entry is never blocked by incomplete ancestor records.
Weight is stored as a decimal float in lbs internally; lb+oz vs decimal display is a per-user setting applied at the read/write boundary, not stored separately.
The genetics calculator's pairings are session-only and never persisted.
## Key UX decisions to preserve
- Pedigree-first entry flow (Step 1: pedigree tree → Step 2: rabbit details → Step 3: genetics loci, collapsible/optional → Step 4: review & save).
- Genetics section defaults collapsed on Add, expanded on Edit if loci data already exists.
- Dashboard herd counts (For Sale/On Hold/Sold/etc.) are derived live from the Rabbit `status` field, not cached counters.
- Mobile bottom nav's "Add" tab opens the rabbit entry flow directly.
</content>
</invoke>

View File

@ -0,0 +1,385 @@
# MyKindlebox — App Design Document
**Version:** 1.0
**Status:** Draft
**Last Updated:** 2026-06-19
---
## Overview
MyKindlebox is a self-hosted rabbitry management web application. It replaces the existing Kindlebox PHP app at `belowland.com/rabbitsite` with a modern, responsive React/Node stack while preserving all the core domain knowledge baked into the original — particularly the genetics system and pedigree-aware entry flow.
The defining improvement over the current site: **adding a new rabbit starts from a pedigree template, not a blank form.** The pedigree tree is the primary data entry surface.
---
## Goals
- Make herd entry fast — one rabbit in under 60 seconds for known bloodlines
- Maintain the full genetics model (A/B/C/D/E/En/V loci)
- Track litters, kits, weights, ages, and sale status in one place
- Mobile-friendly for barn use (phone in hand, cage in front of you)
- Self-hostable with a simple Docker or Node+SQLite setup
---
## Out of Scope (v1)
- Multi-user/multi-rabbitry support
- Public-facing listing pages
- Show tracking (planned for a future release)
- Payments or invoicing
---
## Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Frontend | React + Vite | PWA-ready |
| Routing | React Router v7 | |
| State | Zustand + React Query | Local UI state + server sync |
| Styling | Tailwind CSS | Custom theme tokens |
| Backend | Node.js + Express | REST API |
| Database | SQLite (via Drizzle ORM) | Single-file DB, easy backup |
| Auth | Single-user session token | Self-hosted, no OAuth complexity |
| Hosting | Docker Compose | Nginx + Node + SQLite volume |
---
## Visual Design
### Design Direction
The palette pulls from the working barn — weathered cedar, hay, and deep forest green. Not the typical dark-green-navbar rabbitry website, but a warm neutral base with earthy sage accents and a muted amber highlight. Typography pairs **DM Sans** (headings, data labels) with **Inter** (body, forms) — legible at small sizes and clean on mobile.
The signature element: the **pedigree entry tree** rendered as an actual interactive branching diagram, not a flat form. It should feel like filling out a paper pedigree slip, but faster.
### Color Tokens
| Token | Hex | Usage |
|---|---|---|
| `--surface` | `#F7F3EC` | Page background |
| `--surface-raised` | `#FFFFFF` | Cards, modals |
| `--surface-sunken` | `#EDE8DE` | Input backgrounds, zebra rows |
| `--border` | `#D4C9B4` | Dividers, input borders |
| `--text-primary` | `#1C1A16` | Body text |
| `--text-secondary` | `#6B6354` | Labels, secondary info |
| `--accent` | `#4A6741` | Primary actions (sage green) |
| `--accent-hover` | `#3A5233` | Button hover |
| `--accent-warm` | `#C47D2E` | Highlights, status badges |
| `--danger` | `#B34040` | Delete, warnings |
### Typography Scale
```
Display: DM Sans, 700, 28px / 32px
Heading: DM Sans, 600, 20px / 24px
Subhead: DM Sans, 500, 15px / 20px
Body: Inter, 400, 14px / 21px
Label: Inter, 500, 12px / 16px, uppercase, 0.05em tracking
Data: Inter, 400 / 600, 13px / 18px (tabular nums)
```
---
## Information Architecture
```
/ Dashboard
/herd Herd view (all / bucks / does / other / kits)
/herd/:id Rabbit detail page
/herd/add Add rabbit (pedigree-first form)
/herd/:id/edit Edit rabbit
/litters Litter list
/litters/:id Litter detail (kit list)
/litters/add Record a new litter
/genetics Genetics calculator
/settings App settings (rabbitry name, backup/restore)
```
---
## Navigation
Persistent left sidebar on desktop (240px), bottom nav bar on mobile (5 tabs).
**Sidebar items:**
- Dashboard
- Herd *(with sub-filter: All / Bucks / Does / Kits / Other)*
- Litters
- Genetics Calculator
- Settings
**Mobile bottom nav:** Dashboard · Herd · Litters · Add · Settings
The **Add** tab in mobile nav opens the Rabbit entry flow directly — primary action is always one tap away.
---
## Pages & Components
### Dashboard
Replaces the current dashboard's plain link grid with a live stats bar + action surface.
**Stats bar (top):**
```
┌─────────────┬─────────────┬─────────────┬─────────────┐
│ Herd 14 │ Bucks 7 │ Does 7 │ Kits 20 │
├─────────────┼─────────────┼─────────────┼─────────────┤
│ For Sale 21 │ On Hold 0 │ Sold 69 │ Others 586 │
└─────────────┴─────────────┴─────────────┴─────────────┘
```
Each stat card is a link to the corresponding filtered herd view.
**Quick actions:**
- Add Rabbit
- Record Litter
- View Recent Activity (last 5 added/modified)
**Recent litters widget:** shows the 3 most recent litters with kit count, date, and sire/dam names.
---
### Herd View (`/herd`)
Filterable grid/list of rabbits. Default: card grid, 2-col mobile / 3-col tablet / 4-col desktop.
**Rabbit Card:**
```
┌────────────────────────────┐
│ [photo or breed silhouette]│
│ Jim Bean │
│ Broken Black Tort • Buck │
│ 5y 2m • 3.09 lbs │
│ 6 litters • 18 kits │
└────────────────────────────┘
```
**Filters (top bar):** Gender tabs (All / Bucks / Does / Kits / Other) + sort (Name / Age / Recent / Weight) + search input.
**List view toggle:** compact table view with name, color, sex, age, weight, litter count. Better for large herds.
---
### Rabbit Detail (`/herd/:id`)
Two-column layout on desktop, stacked on mobile.
**Left column:**
- Photo (upload or breed silhouette placeholder)
- Core stats: name, farm, sex, DOB, age, weight, ear number, location
- Registration / GC numbers
- Color with genetics breakdown (loci pills)
- Status badge: Active / For Sale / On Hold / Sold / Deceased
**Right column:**
- Pedigree tree (full 4-generation visual — root + 3 ancestor generations, 15 nodes — read-only, each ancestor with a DB record is a clickable link)
- Litter history table (for bucks and does — all litters they appear in)
**Action bar:** Edit · Add Litter · Mark For Sale · Archive
---
### Add / Edit Rabbit (`/herd/add`, `/herd/:id/edit`)
**This is the core differentiator.** The entry form is organized as a pedigree tree first.
#### Entry Flow
**Step 1 — Pedigree Template**
The user sees a 4-generation pedigree tree with empty nodes (root + 3 ancestor generations: parents, grandparents, great-grandparents). Each node is an inline autocomplete input. Typing searches existing rabbits in the DB and lets you select one, which auto-fills that branch.
```
┌─ Sire's Sire's Sire
┌─ Sire's Sire ─┤
│ └─ Sire's Sire's Dam
┌─ Sire ────┤
│ │ ┌─ Sire's Dam's Sire
│ └─ Sire's Dam ──┤
│ └─ Sire's Dam's Dam
[New Rabbit]─┤
│ ┌─ Dam's Sire's Sire
│ ┌─ Dam's Sire ──┤
│ │ └─ Dam's Sire's Dam
└─ Dam ─────┤
│ ┌─ Dam's Dam's Sire
└─ Dam's Dam ───┤
└─ Dam's Dam's Dam
```
- Selecting an existing rabbit for Sire auto-fills their sire/dam into the next generation if known.
- "Other" and "Unknown" remain as options, just like the original site.
- Clicking any empty ancestor node offers: **Search existing** · **Enter manually** · **Unknown**
**Step 2 — Rabbit Details**
Once the pedigree is set (or skipped), a standard form collects:
| Field | Input Type | Notes |
|---|---|---|
| Name | Text | Required |
| Farm / Prefix | Text | Pre-filled from settings default |
| Sex | Segmented control | Buck / Doe / Unknown |
| Date of Birth | Date picker | |
| Weight | Number + unit toggle (lbs/oz or kg) | |
| Ear Number | Text | |
| Location | Text / select | Cage/hutch label |
| Status | Select | Active / For Sale / On Hold / Sold |
| Color Name | Text | e.g. "Broken Black Tort" |
| Photo | File upload | Optional |
**Step 3 — Genetics**
Collapsible section. Renders the same A/B/C/D/E/En/V loci selectors from the original, but as a clean pill-group toggle UI instead of a dropdown list.
```
A Locus [AA Agouti] [aᵗa Tan] [aa Self]
B Locus [BB] [bb]
…
```
The selected loci auto-generate a color description string (read-only) that can be used as the color field suggestion.
**Step 4 — Review & Save**
Summary card showing all entered data before commit. One "Save Rabbit" button.
---
### Litter List (`/litters`)
Replaces the plain HTML table with a filterable card list.
**Litter Card:**
```
┌──────────────────────────────┐
│ Ragnar × Venus │
│ May 16, 2025 • 1 kit │
│ [View Kits] [Edit] │
└──────────────────────────────┘
```
Filters: year, sire, dam, kit count range. Sort by date (default desc).
---
### Litter Detail (`/litters/:id`)
- Sire + Dam links at top (with ages at time of litter)
- Kindle date, expected wean date, actual wean date
- Kit table: each kit row links to its full Rabbit record — name, sex, color, ear number, status, weight
- "Add Kit" button opens the Add Rabbit flow with sire, dam, and DOB pre-filled from the litter
---
### Genetics Calculator (`/genetics`)
Same functionality as the existing calculator. Modernized as an interactive two-panel form:
- Left: Parent A genotype selectors
- Right: Parent B genotype selectors
- Center: Offspring probability table (live-updating as you change inputs)
---
## Data Models
### Rabbit
```typescript
{
id: number
name: string
farm: string | null
sex: 'buck' | 'doe' | 'unknown'
dob: string | null // ISO date
weight_lbs: number | null
ear_number: string | null
location: string | null
status: 'active' | 'for_sale' | 'on_hold' | 'sold' | 'deceased' | 'other'
color_name: string | null
photo_path: string | null
registration_number: string | null
gc_number: string | null
sire_id: number | null // FK → Rabbit
dam_id: number | null // FK → Rabbit
// Genetics loci
locus_a: string | null // 'AA' | 'aᵗa' | 'aa'
locus_b: string | null
locus_c: string | null
locus_d: string | null
locus_e: string | null
locus_en: string | null
locus_v: string | null
created_at: string
updated_at: string
}
```
### Litter
```typescript
{
id: number
sire_id: number | null // FK → Rabbit
dam_id: number | null // FK → Rabbit
kindle_date: string // ISO date
kit_count: number
notes: string | null
created_at: string
}
```
> Kits are standard Rabbit records linked back to their litter via `sire_id` / `dam_id`. No separate Kit table. The Litter detail page queries all Rabbit records whose sire/dam match the litter's parents and whose DOB matches the kindle date.
---
## Key UX Decisions
### Pedigree-first entry over blank forms
The original site's Add Rabbit form is a flat list of fields. Connecting a rabbit to its lineage is an afterthought. In MyKindlebox, the pedigree tree *is* the form. This mirrors how breeders actually think about a new animal — always in the context of its parents.
### Inline ancestor lookup with graceful fallback
The pedigree tree nodes use an autocomplete that searches the existing rabbit DB. If the ancestor isn't in the system (bought-in or historical), users can type freeform text for the ancestor's name and it stores as a text reference rather than a FK. This avoids blocking entry on having complete records.
### Genetics section is collapsible and optional
Not every breeder uses the full loci system. The genetics section defaults to collapsed on add; open on edit for rabbits that already have genetics data.
### Weight in lb+oz or decimal lbs
The original uses decimal lbs. The app will support both entry modes (3 lbs 2 oz or 3.13 lbs) because breeders use both on show tables.
### Rabbit status drives herd counts
The Dashboard stat cards ("For Sale", "On Hold", etc.) are driven entirely by the `status` field on the Rabbit record. Changing status anywhere instantly updates the counts.
---
## Future Considerations (v2+)
- **Show tracking** — log ARBA show entries and placements per rabbit; mobile-optimized for live entry at shows
- **Health log** — vet visits, treatments, notes per rabbit
- **Breeding calendar** — planned breeding dates, expected kindle dates, reminders
- **Export** — CSV export of herd, printable pedigree PDF generation
- **Photo gallery** — multiple photos per rabbit, breed standard comparison overlay
- **Public herd page** — optional read-only share link for buyers browsing available rabbits
---
## Resolved Decisions
1. **Kit records** — Kits are created as full Rabbit records immediately when added to a litter. No separate "kit row" stage; every kit is a first-class herd entry from birth.
2. **Genetics calculator pairings** — Session-only. Pairings are not persisted to the DB.
3. **Weight storage** — Stored internally as a decimal float in lbs. Display and entry format (decimal lbs vs lb+oz) is controlled by a user setting in the app and converted on read/write.
4. **Backup/restore** — Manual export button in Settings. Exports a JSON or SQLite snapshot; no scheduled automation.

View File

@ -0,0 +1,98 @@
# MyKindlebox — Architecture
**Audience:** AI developers. This file explains what each file/directory does and why, as the codebase grows. Keep it current — update after each major feature or milestone per `CLAUDE.md`.
---
## Repo layout
```
mykindlebox/
server/ — Express + TypeScript REST API, SQLite via Drizzle ORM
client/ — React + Vite frontend
docker-compose.yml — orchestrates both services for self-hosted deployment
```
## `mykindlebox/server`
| File | Purpose |
|---|---|
| `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/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/`. |
| `package.json` scripts | `dev` — `tsx watch index.ts` (hot-reload local dev, no separate build step needed). `build` — `tsc` compiles to `dist/`. `start` — runs the compiled output, used in production/Docker. |
| `Dockerfile` | Builds a Node 20 Alpine image, installs deps, runs `npm run dev` (tsx) inside the container — not yet switched to a production build for the container; revisit when deploying for real. |
| `.dockerignore` | Excludes `node_modules` and `data/` from the Docker build context. |
| `routes/rabbits.ts` | Express `Router` mounted at `/api/rabbits` in `index.ts`. `GET /` (list, with `sex`/`status`/`search`/`sort` filters — `sex=kits` is a special case matching any sex with `dob` in the last 56 days), `GET /search` (autocomplete by *substring* on name only, must be registered before `GET /:id`), `GET /quick-search` (the global top-of-page quick-search bar — distinct from `/search`: a **prefix** match — "wildcard at the end" — across a chooseable subset of `farm`/`name`/`ear_number`/`color` via `?fields=` comma-list, defaulting to all four; returns each hit plus a `matched_fields` array re-derived in JS so the dropdown can label which filter the row flagged on. Empty `q` or no valid fields → `[]`. Also must be registered before `GET /:id`), `GET /:id` (404 if missing), `POST /` (validates `name`, 201 on success), `PATCH /:id` (partial update, refreshes `updated_at`), `DELETE /:id` (nulls referencing `sire_id`/`dam_id` before deleting), `GET /:id/ancestors` (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries `id`/`name`/`farm`/`sex`/`ear_number`/`weight_lbs`/`dob`/`color_name` — `ear_number`/`weight_lbs`/`dob` were added so the printable pedigree slip can show full per-ancestor detail), `POST /:id/photo` (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so `fileFilter` rejections become this route's own 400 rather than the global error handler's 500). |
| `routes/litters.ts` | Express `Router` mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice (via `alias()`) to embed sire/dam `{ id, name }`. `GET /:id` derives its `kits` list from `rabbits` matching `sire_id`/`dam_id`/`dob` (the same no-Kit-table derivation the design doc specifies elsewhere). `POST /` (requires a `breed_date` or `kindle_date`; only generates kits when a `kindle_date` is present), `PATCH /:id`, `DELETE /:id`. Recording a breeding (`POST` with `breed_date`, no `kindle_date`) creates a litter-in-progress with no kits yet. |
| `routes/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. |
| `utils/case.ts` | `toSnakeCase`/`toCamelCase` — generic recursive key-casing converters used at the API boundary. See casing convention below. |
**Why TypeScript:** the implementation plan specifies `db/client.ts` for the Drizzle setup. The server started as plain CommonJS JS in Step 1.2; it was converted to TypeScript at Step 2.1 specifically so the schema/DB layer has type safety, per explicit user decision (plain JS was the lower-effort alternative but not chosen).
## `mykindlebox/client`
| 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/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. |
| `src/components/Card.jsx` | Bordered `surface-raised` container with shadow; just forwards `className`/props. |
| `src/components/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. |
| `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/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/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. |
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
| `src/components/RabbitDetailsForm.jsx` | Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a `weightUnit` prop; photo selection is reported via a separate `onPhotoChange(file)` callback since it's always a second request (`uploadRabbitPhoto`) after the rabbit row exists. |
| `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/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. |
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. |
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
| `Dockerfile` | Builds a Node 20 Alpine image and runs the Vite dev server bound to `0.0.0.0` (required so the container's port mapping works — Vite binds to localhost only by default). |
## Root
| File | Purpose |
|---|---|
| `docker-compose.yml` | Two services, `server` (port 3001) and `client` (port 5173), each built from their own Dockerfile. A named volume `server-data` is mounted at `/app/data` in the server container so the SQLite file persists across container rebuilds. **Not yet validated** — Docker isn't installed on the dev machine; correctness here is unconfirmed until someone runs `docker compose up`. |
---
## Key conventions to remember when extending this code
- **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`.
- **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.
- **URL-synced debounced inputs**: don't sync a prop into local state via a `useEffect` that calls `setState` — this project's ESLint config (`react-hooks/set-state-in-effect`) treats that as an error. `Herd.jsx`'s `SearchBox` is the established pattern: give the input component `key={externalValue}` from the parent so an external change (e.g. browser back/forward updating the URL) remounts the component and re-initializes its local state from props, instead of reconciling it via an effect.
- **One-shot default-fill from async data**: when a query result should seed local state exactly once (e.g. `AddRabbit.jsx` defaulting `details.farm` from `settings.rabbitry_name`, or `EditRabbit.jsx` seeding `details`/`genetics` from the loaded rabbit), call `setState` directly in the render body guarded by a condition that becomes false after the first call (a `useRef` flag, or `details === null`) — this is the React-docs-sanctioned "adjusting state during render" pattern, not the banned `useEffect`-based prop-sync. It's distinct from the `SearchBox` case above because there's nothing to *re*-sync on every external change, only a single seed.
- **Pedigree chain-authority on the client** (`lib/pedigree.js`, used by `PedigreeTree.jsx`/`AddRabbit.jsx`/`EditRabbit.jsx`): mirrors the backend's `resolveAncestorNode` (see the server-side note above) via `resolveEffectiveNode(path, state, cascadeCache)` — a node is "locked" (non-interactive, cascade-derived) whenever its immediate parent's effective node is `mode: 'linked'`, sourcing its value from that ancestor's own `AncestorNode` subtree (fetched via `GET /api/rabbits/:id/ancestors`, cached by id) instead of any state on the subject. Because the `rabbits` table has no FK column below `sire_id`/`dam_id`, an *editable* gen2/gen3 node's "Search existing" option can only ever save freeform text (the matched rabbit's name) — there is no column to link it to. When seeding `PedigreeTree` for editing, `EditRabbit.jsx` must wait for the ancestors fetch to resolve before mounting it: a linked gen1 node's display name comes from `ancestors.sire.name`/`ancestors.dam.name`, not from the raw rabbit record (which only has `sire_id`, no name) — mounting `PedigreeTree` early renders a blank name for an otherwise-correctly-linked Sire/Dam (this was caught by Playwright during Step 7.6 validation, see `progress.md`).
- **Manually-entered ancestors store structured data, not a bare name** (per explicit user request, post-Phase-7): each `_name_text` column is still a single text column (no schema migration), but "Enter manually" now collects Farm Name, Rabbit Name, and Ear Number (all required — the Save button is disabled until all three are non-empty) plus Weight (defaults to `0`) and DOB (defaults to the literal string `"Unknown"`, since a real ancestor record's actual DOB is virtually never known). These five fields are JSON-encoded into the `_name_text` column via `serializeManualAncestor`. Every place that reads a manual node's `name` for *display* (the `PedigreeTree` slot button/locked-node text, `buildPedigreeSummary`'s `display` field used by `ReviewStep`) must run it through `formatManualAncestorDisplay` first, which renders `"Name (Farm) • Ear #X"` — never render `node.name` raw for a manual node, it's JSON. `parseManualAncestor` is defensive: a plain non-JSON string (a legacy entry, or hand-edited in the DB) is treated as just the `name` field with the other four defaulted, so old data doesn't break. Re-opening an already-manual node's editor (`PedigreeTree.jsx`'s button `onClick`) calls `parseManualAncestor` to restore all five fields for further editing, rather than starting blank.
- **DOB is a date picker + separate "Unknown" checkbox in the form, but one string in storage**: the manual-entry form's local state keeps `dob` (a real date-input value) and `dobUnknown` (the checkbox, checked by default) as two separate fields — they only collapse into the single stored string (`"Unknown"` or the real date) at save time. `manualFormFromParsed(parsed)` is the inverse: given a parsed ancestor record, it sets `dobUnknown: true` and blanks the date input whenever the stored `dob` is `"Unknown"` (or missing), otherwise populates the date input and unchecks the box. Don't bind the date `<input type="date">` directly to a value that might be the string `"Unknown"` — it's not a valid date and the input will silently show blank, which is why the split exists.
- **Weight and DOB need real `<label>` elements, not placeholders**: every other manual-entry field (Farm/Name/Ear Number) relies on `placeholder` text since they start empty, but Weight always has a default value (`0`) and `type="date"` inputs don't reliably render placeholders at all — a `placeholder` attribute on either is effectively invisible. Use a `<label>` above the input instead.
- **Focus ring is a global CSS rule, not per-component classes** (Phase 11 Step 11.4): `index.css`'s `:focus-visible { outline: 2px solid theme('colors.accent') !important; ... }` is the single source of the accent-colored keyboard focus ring across the whole app. The `!important` is required — many inputs/buttons already carry `focus:outline-none` (paired with their own `focus:ring-2`), which has higher CSS specificity than a bare `:focus-visible` selector and would otherwise win, leaving keyboard focus invisible on exactly those elements. Don't try to remove this rule or "clean it up" into per-component `focus-visible:` utility classes without re-auditing every interactive element for a visible ring first.
- **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.
- **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.

View File

@ -0,0 +1,655 @@
# MyKindlebox — Implementation Plan
**Version:** 1.1
**Last Updated:** 2026-06-20
**Audience:** AI developers
**Scope:** Base application only — core herd and litter management, no advanced features
Reference documents: `app-design-document.md`, `tech-stack.md`
---
## Conventions
- Each step has a single, concrete objective.
- Each step ends with a **Validation** block — a specific check that confirms the step is complete and correct before moving on.
- Do not proceed to the next step until the current step's validation passes.
- No placeholders or stubs — every step should leave the codebase in a working, testable state.
---
## Deferred: Authentication
The tech stack specifies single-user session token auth. Auth is **intentionally out of scope for this base plan**. All `/api/*` routes are unprotected in this phase. A future plan addendum will cover the login screen, token issuance, and route protection middleware. Do not add auth stubs or placeholders — implement it fully when that phase is added.
---
## Phase 1 — Project Scaffolding
### Step 1.1 — Initialize the monorepo structure
Create a root project folder named `mykindlebox`. Inside it, create two subdirectories: `server` for the backend and `client` for the frontend. Add a root-level `README.md` with the project name and one-line description.
**Validation:** The folder structure `mykindlebox/server` and `mykindlebox/client` exists. The README is present at the root.
---
### Step 1.2 — Initialize the backend project
Inside `server`, initialize a Node.js project. Install Express as a dependency. Create a main entry file (`index.js` or `index.ts`) that starts an HTTP server on port 3001 and responds to `GET /health` with a JSON body of `{ "status": "ok" }`.
**Validation:** Running the server and sending a request to `http://localhost:3001/health` returns HTTP 200 with `{ "status": "ok" }`.
---
### Step 1.3 — Initialize the frontend project
Inside `client`, scaffold a new React + Vite project. Install Tailwind CSS and configure it with a `tailwind.config` file. Install React Router v7. The default page should render the text "MyKindlebox" in the center of the screen.
**Validation:** Running the dev server and opening the browser shows a page with the text "MyKindlebox". No console errors.
---
### Step 1.4 — Configure Tailwind design tokens
In the Tailwind config, extend the theme with the custom color tokens from the design document. Define all ten color values as named tokens matching the CSS variable names (e.g., `surface`, `surface-raised`, `surface-sunken`, `border`, `text-primary`, `text-secondary`, `accent`, `accent-hover`, `accent-warm`, `danger`).
**Validation:** Apply a background of `bg-surface` and text of `text-text-primary` to the root element. The page background should render as `#F7F3EC` and text as `#1C1A16`. Inspect in browser devtools to confirm the computed colors.
---
### Step 1.5 — Load custom fonts
Add DM Sans and Inter via Google Fonts (or self-hosted). Configure Tailwind's font family extension so that `font-display` maps to DM Sans and `font-body` maps to Inter. Apply `font-body` to the root `<body>` element.
**Validation:** In the browser, inspect the computed font of any text element on the page. It should show Inter. Temporarily apply `font-display` to a heading and confirm it shows DM Sans.
---
### Step 1.6 — Set up Docker Compose
Create a `docker-compose.yml` at the project root. Define two services: `server` (Node.js backend on port 3001) and `client` (Vite dev server or Nginx static on port 5173). Mount a named volume for the SQLite database file under `server/data/`.
**Validation:** Running `docker compose up` starts both services without errors. The health endpoint at `http://localhost:3001/health` and the frontend at `http://localhost:5173` are both reachable from the host machine.
---
## Phase 2 — Database
### Step 2.1 — Install and configure Drizzle ORM
Inside `server`, install Drizzle ORM and the `better-sqlite3` driver. Create a `db` directory inside `server`. Add a `db/client.ts` file that opens a SQLite connection to `server/data/mykindlebox.db` and exports the database instance.
**Validation:** Starting the server creates the file `server/data/mykindlebox.db` if it does not already exist. No errors on startup.
---
### Step 2.2 — Define the Rabbit schema
In `server/db/schema.ts`, define the `rabbits` table with all fields from the data model in the design document: `id`, `name`, `farm`, `sex`, `dob`, `weight_lbs`, `ear_number`, `location`, `status`, `color_name`, `photo_path`, `registration_number`, `gc_number`, `sire_id`, `dam_id`, the seven locus fields (`locus_a` through `locus_v`), `created_at`, and `updated_at`.
- `sex` must be constrained to the values: `buck`, `doe`, `unknown`.
- `status` must be constrained to: `active`, `for_sale`, `on_hold`, `sold`, `deceased`, `other`.
- `sire_id` and `dam_id` are self-referencing foreign keys to `rabbits.id`, nullable.
- Add twelve freeform text columns for ancestors not in the database, covering all three ancestor generations:
- Generation 1 (parents): `sire_name_text`, `dam_name_text`
- Generation 2 (grandparents): `paternal_grandsire_name_text`, `paternal_granddam_name_text`, `maternal_grandsire_name_text`, `maternal_granddam_name_text`
- Generation 3 (great-grandparents): `pp_grandsire_name_text`, `pp_granddam_name_text`, `pm_grandsire_name_text`, `pm_granddam_name_text`, `mp_grandsire_name_text`, `mp_granddam_name_text`, `mm_grandsire_name_text`, `mm_granddam_name_text`
All nullable text. The following rule governs when these columns are written and read:
**Chain-authority rule:** A `_name_text` column for a given ancestor slot is written *only* when the immediate parent node in that slot's chain has no FK (i.e., the chain is broken at the parent). Once a node resolves through a FK to a real DB record, all deeper generations in that branch come exclusively from that ancestor's own `sire_id`/`dam_id`/`_name_text` fields — the subject's own flat columns for those deeper slots are never written and never read. They are dead columns in an unbroken chain.
Concretely:
- `sire_name_text` is written only when `sire_id` is null.
- `paternal_grandsire_name_text` is written only when `sire_id` is null (chain broken at Sire). If `sire_id` is set, the Sire's Sire always comes from `Sire.sire_id` or `Sire.sire_name_text` — never from the subject's `paternal_grandsire_name_text`.
- The same rule applies recursively at every generation and every branch.
- `created_at` and `updated_at` default to the current timestamp.
**Validation:** Running the Drizzle migration (or `db.run` equivalent) creates a `rabbits` table in the SQLite file with the correct columns. Open the database with a SQLite browser and confirm all columns and constraints are present, including all fourteen `_name_text` columns (two parents, four grandparents, eight great-grandparents).
---
### Step 2.3 — Define the Litter schema
In the same `schema.ts`, define the `litters` table with fields: `id`, `sire_id` (FK → rabbits, nullable), `dam_id` (FK → rabbits, nullable), `kindle_date`, `kit_count`, `notes`, and `created_at`.
**Validation:** After migration, the `litters` table exists in the database with the correct columns. Confirm the foreign key references to the `rabbits` table are present.
---
### Step 2.4 — Define the Settings schema
Define a `settings` table with fields: `key` (text, primary key) and `value` (text). This is a simple key-value store for app-wide configuration such as rabbitry name and weight display preference.
Seed it with two default rows on first run: `rabbitry_name` with a value of `"My Rabbitry"` and `weight_unit` with a value of `"decimal_lbs"`.
**Validation:** After startup, the `settings` table exists and contains exactly two rows with the keys `rabbitry_name` and `weight_unit`.
---
### Step 2.5 — Run and validate all migrations on startup
Configure the server so that on startup it runs all pending migrations automatically. Migrations should be idempotent — running them twice must not produce errors or duplicate data.
**Validation:** Stop the server, delete the database file, and restart. The database is recreated with all three tables and the two settings seed rows. Stop and restart again without deleting — no errors, no duplicate rows.
---
## Phase 3 — Backend API
### Step 3.1 — Add a global error handler and request logger
Add middleware to the Express app that logs every incoming request (method and path) to the console. Add a global error-handling middleware that catches unhandled errors and returns a JSON response with HTTP 500 and a body of `{ "error": "Internal server error" }`.
**Validation:** Make a request to a non-existent route (e.g., `GET /nonexistent`). The server logs the request and returns HTTP 404. Trigger a deliberate error in a test route and confirm HTTP 500 is returned with the correct JSON body.
---
### Step 3.2 — Rabbits API — GET all rabbits
Create a `GET /api/rabbits` endpoint. It must return an array of all rabbit records. Support the following optional query parameters:
- `sex` — filter by `buck`, `doe`, `unknown`, or the special value `kits` (returns rabbits of any sex whose `dob` is within the last 56 days)
- `status` — filter by any valid status value
- `search` — case-insensitive partial match on the `name` field
- `sort` — one of `name`, `dob`, `weight_lbs`, `created_at` (default: `name`)
**Validation:** With an empty database, the endpoint returns `[]`. After inserting two test rabbits directly into the DB (one buck, one doe), confirm that `GET /api/rabbits` returns both, `GET /api/rabbits?sex=buck` returns only the buck, and `GET /api/rabbits?search=<partial name>` filters correctly.
---
### Step 3.3 — Rabbits API — GET single rabbit
Create a `GET /api/rabbits/:id` endpoint. It must return the full rabbit record for the given ID. If no rabbit with that ID exists, return HTTP 404 with `{ "error": "Rabbit not found" }`.
**Validation:** Requesting a valid ID returns the correct rabbit record with all fields. Requesting ID `99999` (nonexistent) returns HTTP 404 with the error message.
---
### Step 3.4 — Rabbits API — POST create rabbit
Create a `POST /api/rabbits` endpoint. It must accept a JSON body, validate that `name` is present and non-empty, and insert the record. Return the newly created rabbit record with its assigned `id` and HTTP 201.
Reject requests missing `name` with HTTP 400 and `{ "error": "Name is required" }`.
**Validation:** `POST /api/rabbits` with a valid body including at minimum `{ "name": "Test Rabbit", "sex": "buck" }` returns HTTP 201 and the new record with an `id`. A subsequent `GET /api/rabbits/:id` with that ID returns the same record. Posting without a name returns HTTP 400.
---
### Step 3.5 — Rabbits API — PATCH update rabbit
Create a `PATCH /api/rabbits/:id` endpoint. It must accept a partial JSON body and update only the provided fields. Return the full updated rabbit record. Return HTTP 404 if the rabbit does not exist.
**Validation:** Create a rabbit via POST. Send a PATCH with `{ "weight_lbs": 3.5 }`. Confirm the returned record has `weight_lbs: 3.5` and all other fields are unchanged. Send a PATCH to a nonexistent ID and confirm HTTP 404.
---
### Step 3.6 — Rabbits API — DELETE rabbit
Create a `DELETE /api/rabbits/:id` endpoint. Return HTTP 204 on success. Return HTTP 404 if the rabbit does not exist. Before deleting, set `sire_id` and `dam_id` to `null` on any rabbit that references the deleted rabbit, to preserve referential integrity.
**Validation:** Create two rabbits where one is the sire of the other. Delete the sire. Confirm the child rabbit's `sire_id` is now `null`. Confirm the deleted rabbit returns 404 on a subsequent GET.
---
### Step 3.7 — Rabbits API — GET ancestry
Create a `GET /api/rabbits/:id/ancestors` endpoint. It must return a nested object representing the rabbit's full 4-generation pedigree (root + 3 ancestor generations). Each node should include the rabbit's `id`, `name`, `farm`, `sex`, `color_name`, and their own `sire_id` / `dam_id`. Unknown ancestors (no FK and no freeform text) should be represented as `null`.
The endpoint must resolve each node using the chain-authority rule defined in Step 2.2:
- If a node's parent resolved via FK, read the node's data from that parent DB record's own `sire_id`/`dam_id`/`_name_text` fields. Do not read from the subject's flat columns for that slot.
- If a node's parent had no FK (chain broken), read the node's data from the subject's corresponding `_name_text` column.
- Never mix sources within a branch: once the chain is broken, all remaining nodes in that branch come from the subject's flat columns; once the chain is intact, all nodes come from recursive ancestor records.
**Validation:** Create a set of 15 linked rabbit records (root → 2 parents → 4 grandparents → 8 great-grandparents). Call the ancestors endpoint on the root rabbit and confirm the returned tree has the correct nested structure with all 14 ancestors populated at the correct positions.
Then test the broken-chain case: create a root rabbit with `sire_id` null and `sire_name_text = "Bob's Buck"` and `paternal_grandsire_name_text = "Old Bob"`. Call the endpoint and confirm the Sire node returns the freeform text "Bob's Buck" with no `id`, and the Sire's Sire node returns "Old Bob" from the subject's flat column.
Then test the unbroken-chain case: create a root rabbit with a real `sire_id`. Set a value in the subject's `paternal_grandsire_name_text` column directly in the DB (simulating stale or erroneous data). Call the endpoint and confirm the Sire's Sire node comes from `Sire.sire_id` or `Sire.sire_name_text` — not from the subject's flat column. The subject's flat column must be ignored.
---
### Step 3.8 — Rabbits API — GET rabbit search (autocomplete)
Create a `GET /api/rabbits/search?q=<query>` endpoint specifically for the pedigree tree autocomplete. It must return only `id`, `name`, `farm`, and `sex` for up to 10 matching rabbits. Match on any portion of the `name` field, case-insensitive.
Also support an optional `sex` query parameter (`buck`, `doe`, or `unknown`) to restrict results to a single sex. This is required by the Add Litter form, which must restrict the Sire field to bucks and the Dam field to does.
**Validation:** With several rabbits in the database, querying `?q=jim` returns only rabbits whose names contain "jim" (case-insensitive). Querying `?q=&sex=buck` returns only bucks. Querying `?q=&sex=doe` returns only does. The response contains no more than 10 results and only the four specified fields.
---
### Step 3.9 — Litters API — CRUD endpoints
Create the following endpoints for litters:
- `GET /api/litters` — return all litters, sorted by `kindle_date` descending. Include the sire and dam's `name` and `id` in each result (joined, not just IDs).
- `GET /api/litters/:id` — return a single litter. Include sire and dam details. Include all Rabbit records whose `sire_id` and `dam_id` match the litter's parents and whose `dob` matches the `kindle_date`.
- `POST /api/litters` — create a litter. Require `kindle_date`. Return HTTP 201 with the new record.
- `PATCH /api/litters/:id` — update a litter's fields. Return the updated record.
- `DELETE /api/litters/:id` — delete a litter. Return HTTP 204.
**Validation:** Create a litter via POST. Confirm it appears in GET all. Add a rabbit with matching `sire_id`, `dam_id`, and `dob` — confirm it appears in the GET single litter's kit list. Delete the litter and confirm it returns 404.
---
### Step 3.10 — Settings API
Create two endpoints:
- `GET /api/settings` — return all settings as a flat JSON object (e.g., `{ "rabbitry_name": "My Rabbitry", "weight_unit": "decimal_lbs" }`).
- `PATCH /api/settings` — accept a partial JSON object and update the provided keys. Only allow known keys (`rabbitry_name`, `weight_unit`). Reject unknown keys with HTTP 400.
**Validation:** `GET /api/settings` returns the two seeded defaults. `PATCH /api/settings` with `{ "rabbitry_name": "Belowland Rabbitry" }` updates the value. A subsequent GET reflects the change. Sending an unknown key returns HTTP 400.
---
### Step 3.11 — Dashboard stats endpoint
Create a `GET /api/stats` endpoint that returns a single JSON object with the following counts derived from the `rabbits` table:
- `herd` — count of all rabbits with status `active`
- `bucks` — count of active rabbits with sex `buck`
- `does` — count of active rabbits with sex `doe`
- `kits` — count of rabbits with status `active` whose `dob` is within the last 56 days (8 weeks). Use this age-window logic consistently; do not use the litter-join method.
- `for_sale` — count with status `for_sale`
- `on_hold` — count with status `on_hold`
- `sold` — count with status `sold`
- `archived` — count with status `other` or `deceased`. The dashboard stat card for this value must be labeled **"Archived"**, not "Other", to distinguish it from the sex-based "Other" filter tab on the Herd page.
**Validation:** Insert rabbits with known statuses and DOBs. Call `GET /api/stats` and confirm each count matches the expected value. Insert a rabbit with a `dob` of today — confirm it appears in `kits`. Insert one with a `dob` of 57 days ago — confirm it does not. Confirm the `archived` key is present and correctly counts `other` + `deceased` status rabbits.
---
### Step 3.12 — Photo upload endpoint
Create a `POST /api/rabbits/:id/photo` endpoint that accepts a `multipart/form-data` request containing a single image file in a field named `photo`. The endpoint must:
- Validate that the uploaded file is an image (accept `image/jpeg`, `image/png`, `image/webp` only). Reject other types with HTTP 400.
- Save the file to a designated uploads directory on disk (e.g., `server/data/uploads/<rabbit-id>.<ext>`).
- Update the rabbit's `photo_path` field in the database to the saved file path.
- Return the updated rabbit record with HTTP 200.
The `POST /api/rabbits` and `PATCH /api/rabbits/:id` endpoints remain JSON-only. Photo upload is always a separate step after the rabbit record exists.
Serve the uploads directory as static files at `/uploads/*` so the frontend can display photos via a standard `<img src>` URL.
**Validation:** Create a rabbit via `POST /api/rabbits`. Then send a `POST /api/rabbits/:id/photo` request with a valid JPEG file. Confirm the file is saved to disk and the rabbit's `photo_path` is updated in the database. Request the photo URL directly in the browser — it should display the image. Send a non-image file (e.g., a `.txt` file) and confirm HTTP 400 is returned.
---
## Phase 4 — Frontend Foundation
### Step 4.1 — Set up React Router with route definitions
Configure React Router v7 in the client app. Define routes for all paths in the IA:
- `/` → Dashboard page component
- `/herd` → Herd page component
- `/herd/add` → Add Rabbit page component
- `/herd/:id` → Rabbit Detail page component
- `/herd/:id/edit` → Edit Rabbit page component
- `/litters` → Litters page component
- `/litters/add` → Add Litter page component
- `/litters/:id` → Litter Detail page component
- `/genetics` → Genetics Calculator page component
- `/settings` → Settings page component
Each route should render a placeholder component that shows the route name as a heading (e.g., "Dashboard", "Herd", etc.).
**Validation:** Navigating to each URL in the browser renders the corresponding placeholder heading with no errors. Navigating to `/nonexistent` should show a "Not Found" message (add a catch-all route).
---
### Step 4.2 — Build the app shell layout
Create a persistent layout component that wraps all pages. It must include:
- A left sidebar (240px wide) on screens wider than 768px containing: the app name "MyKindlebox" at the top, and navigation links for Dashboard, Herd, Litters, Genetics, and Settings.
- A bottom navigation bar on screens 768px and below with five items: Dashboard, Herd, Litters, Add (links to `/herd/add`), and Settings.
- A main content area to the right of the sidebar (or below the top on mobile) where the page component renders.
The active route should be visually highlighted in the navigation.
**Validation:** On a desktop-width browser, the sidebar is visible and the bottom nav is hidden. On a mobile-width browser (or using devtools), the sidebar is hidden and the bottom nav is visible. Clicking each nav item navigates to the correct route and highlights the active item.
---
### Step 4.3 — Set up React Query
Install and configure React Query. Wrap the app in a `QueryClientProvider`. Create a shared `api.ts` utility file in the client that exports typed fetch functions for each API endpoint defined in Phase 3. All fetch functions should target `http://localhost:3001/api`.
**Validation:** In the Dashboard placeholder component, call `GET /api/stats` using a React Query `useQuery` hook. Confirm the raw response data is logged to the console without errors. The network tab in devtools should show a successful request to the backend.
---
### Step 4.4 — Build shared UI components
Build the following small, reusable components. These will be used throughout the app. Each should accept the appropriate props and apply the correct design tokens from the Tailwind config.
- `Button` — variants: `primary` (accent background), `secondary` (border only), `danger` (danger background). Sizes: `sm`, `md`.
- `Badge` — displays a status label. Color-coded by status value: `active` (accent), `for_sale` (accent-warm), `on_hold` (text-secondary), `sold` (border), `deceased` (danger).
- `Card` — a white (`surface-raised`) container with a border and subtle shadow.
- `PageHeader` — a heading with an optional subtitle and optional right-side action slot.
- `EmptyState` — a centered message with a title, description, and optional action button. Used when lists are empty.
**Validation:** Create a temporary `/dev` route that renders all five components with representative props. Visually confirm colors, sizes, and layout match the design tokens. Remove the dev route after validation.
---
## Phase 5 — Dashboard Page
### Step 5.1 — Build the stats bar
On the Dashboard page, fetch data from `GET /api/stats`. Render eight stat cards in a 4-column grid (2-column on mobile) using the `Card` component. Each card shows the label and count. Each card is a clickable link to the corresponding filtered herd view.
Show a loading skeleton (grey placeholder boxes) while the data is fetching. Show an error message if the request fails.
**Validation:** With rabbits of various statuses in the database, the dashboard stats cards display the correct counts. Clicking a stat card navigates to `/herd` (with the appropriate filter). The loading skeleton is visible briefly before data loads on a slow connection (simulate with devtools throttling).
---
### Step 5.2 — Build the quick actions section
Below the stats bar, render three action buttons: "Add Rabbit" (links to `/herd/add`), "Record Litter" (links to `/litters/add`), and "View Herd" (links to `/herd`). Use the `Button` component with the `primary` variant for "Add Rabbit" and `secondary` for the others.
**Validation:** All three buttons are visible and navigate to the correct routes when clicked.
---
### Step 5.3 — Build the recent litters widget
Below the quick actions, fetch the three most recent litters from `GET /api/litters` and display them as a small list. Each entry shows the sire × dam names, kindle date (formatted as "Month DD, YYYY"), and kit count. Each entry links to its litter detail page. Show the `EmptyState` component if no litters exist yet.
**Validation:** With litters in the database, the three most recent appear in the widget with correct names, dates, and kit counts. With no litters, the empty state renders. Each entry links to the correct `/litters/:id` route.
---
## Phase 6 — Herd Page
### Step 6.1 — Build the rabbit card component
Create a `RabbitCard` component. It accepts a rabbit record as a prop and renders: a placeholder breed silhouette image (SVG or static asset), the rabbit's name, color name and sex on the same line separated by a bullet, age (calculated from DOB as years/months) and weight on the same line, and litter/kit counts (these can be placeholder zeros until the API supports them). The entire card links to `/herd/:id`. Apply the `Card` component as the wrapper.
**Validation:** Render the card with a hardcoded rabbit prop. Confirm all fields display correctly. Confirm the card links to the correct route. A rabbit with no DOB should show "Age unknown" rather than crashing.
---
### Step 6.2 — Build the herd filter bar
Above the rabbit grid on the Herd page, build a filter bar with:
- A segmented tab control for sex: All / Bucks / Does / Kits / Other
- A sort dropdown: Name / Age / Recent / Weight
- A text search input with a small search icon
- A view toggle: grid icon and list icon
> **Clarification on filter axes:** "Kits" filters by `dob` within the last 56 days (age window, same as the dashboard stat). "Other" filters by `sex = unknown` — this is a sex-axis filter, not a status filter. This is intentionally different from the dashboard's "Archived" card, which filters by `status`. Do not conflate them.
Selecting any control should update the URL query parameters (e.g., `?sex=buck&sort=name&q=jim&view=list`) and persist state on page reload.
**Validation:** Selecting "Bucks" updates the URL to include `?sex=buck`. Typing in the search box updates `?q=<value>` after a short debounce (no update on every keystroke). Toggling the view switch updates `?view=list` or `?view=grid`. Refreshing the page with those query params restores the filter state.
---
### Step 6.3 — Build the herd grid view
Fetch rabbits from `GET /api/rabbits` using the active filter and sort state from the URL params. Render results as a responsive grid: 2 columns on mobile, 3 on tablet, 4 on desktop. Use the `RabbitCard` component for each rabbit. Show the `EmptyState` component when no results match the current filter. Show loading skeletons (matching the card size) while fetching.
**Validation:** With 10+ rabbits in the database, all appear in the grid. Applying the "Bucks" filter shows only bucks. Using the search input filters by name in real time. The grid is 2 columns on a 375px viewport and 4 columns on a 1280px viewport.
---
### Step 6.4 — Build the herd list view
When `?view=list` is active, render a compact table instead of the card grid. Columns: Name, Sex, Color, Age, Weight, Status. Each row links to the rabbit detail page. Zebra-stripe rows using the `surface-sunken` token for alternating rows.
**Validation:** Toggling to list view renders the table. All columns are populated correctly. Clicking a row navigates to `/herd/:id`. The same filters (sex, search, sort) applied in grid view also apply in list view.
---
## Phase 7 — Add / Edit Rabbit Flow
### Step 7.1 — Build the pedigree tree input component
Create a `PedigreeTree` component. It renders a 4-generation pedigree layout (root + 3 ancestor generations) with 15 nodes: the subject (new rabbit, non-editable label), 2 parents, 4 grandparents, and 8 great-grandparents. The layout follows standard ARBA pedigree orientation — root on the left, each generation branching right.
Each ancestor node is an interactive input. When focused, it shows a dropdown with a search box. Typing in the search box calls `GET /api/rabbits/search?q=<query>` and displays matching results. Selecting a result fills that node with the rabbit's name and stores the rabbit's ID. The node also shows two non-search options: "Enter manually" (accepts freeform text, saved to the subject's corresponding `_name_text` column with no FK) and "Unknown" (clears both the FK and the text field for that slot).
**Chain-authority rule in the UI:** This rule governs which nodes are editable and where their data is sourced.
- When a node is resolved via FK (a real DB record is selected), all child nodes in that branch are automatically populated from that ancestor's own record and become **read-only / locked**. The user cannot enter freeform text into a child node whose parent resolved via FK. Locked nodes display the ancestor's data in a distinct locked style (e.g., greyed out with a lock icon) and are not interactive.
- When a node has no FK (empty, freeform, or Unknown), its child nodes are **editable**. The user may search for a DB record, enter freeform text, or mark as Unknown. Values entered here are saved to the subject's flat `_name_text` columns.
- This means "Enter manually" and "Unknown" options are only available on a node whose immediate parent has no FK. They must not appear on locked nodes.
When a DB-record ancestor is selected, cascade-populate its branch as deep as that ancestor's own records go. If a grandparent is a DB record but has no sire or dam recorded, the great-grandparent nodes in that branch remain editable (chain is broken at the grandparent level). If a grandparent has freeform text for its own parent, display that text in the locked node in muted style.
**Validation:** Render the component in isolation. Typing in the Sire node triggers a search request and shows results. Selecting a DB rabbit as Sire fills the node, auto-populates grandparent nodes from that rabbit's own record, and locks those grandparent nodes — they must not be interactive. Confirm the locked grandparent nodes do not show the search dropdown when clicked.
Clear the Sire node (set to Unknown). Confirm the grandparent nodes in the sire branch become editable again. Type a freeform name into the Sire's Sire node and confirm it saves to the subject's `paternal_grandsire_name_text` column with no FK.
Select "Unknown" on a parent node and confirm its child nodes are editable. Select "Enter manually" on a parent node and confirm its child nodes are also editable (chain is broken).
---
### Step 7.2 — Build the rabbit details form
Create a `RabbitDetailsForm` component. It renders all fields from the design document's Step 2:
- Name (text input, required, marked with an asterisk)
- Farm / Prefix (text input, pre-filled from the `rabbitry_name` settings value)
- Sex (three-button segmented control: Buck / Doe / Unknown)
- Date of Birth (date input)
- Weight (number input plus a unit toggle: "decimal lbs" or "lbs + oz", driven by the `weight_unit` setting)
- Ear Number (text input)
- Location (text input)
- Status (select dropdown with all six valid values, defaulting to "active")
- Color Name (text input)
- Photo (file input, accepts image files only — `image/jpeg`, `image/png`, `image/webp` — shows a preview thumbnail after selection). Note: the photo is uploaded separately via `POST /api/rabbits/:id/photo` after the rabbit record is created. On the Add Rabbit page, the photo field is uploaded as a second request immediately after the initial `POST /api/rabbits` succeeds. On the Edit Rabbit page, a new photo selection replaces the existing one via the same endpoint.
**Validation:** Render the form in isolation with no initial values. The Farm field is pre-filled with the settings value. The weight unit toggle switches between a single decimal input and two inputs (lbs + oz). Submitting the form without a Name shows a validation error on the Name field without submitting. All other fields are optional.
---
### Step 7.3 — Build the genetics section
Create a `GeneticsForm` component. It renders a collapsible section labeled "Genetics (optional)". When expanded, it shows pill-group toggles for each of the seven loci (A, B, C, D, E, En, V) with the correct allele options from the design document.
Below the loci selectors, render a read-only text field labeled "Generated color string" that displays a human-readable color description assembled from the selected loci (e.g., selecting `aa`, `BB`, `CC`, `DD`, `ee` produces "Self Black Non-Extension").
The section defaults to collapsed when adding a new rabbit. It defaults to expanded when editing a rabbit that already has any locus data set.
**Validation:** On the Add Rabbit page, the genetics section is collapsed by default and expands when clicked. Selecting loci updates the generated color string. On the Edit page for a rabbit with locus data, the section is expanded by default and shows the correct existing selections.
---
### Step 7.4 — Build the review step
Create a `ReviewStep` component. It displays a read-only summary card of all entered data: the pedigree tree (ancestors listed by role), all detail fields with their values, and the selected genetics loci. Fields with no value should show a dash rather than being omitted.
At the bottom, render a "Save Rabbit" button (`primary` variant) and a "Back" button (`secondary` variant) that returns to the details step.
**Validation:** Populate all fields in the form steps and navigate to the review step. All entered values appear correctly. Empty optional fields show a dash. The "Back" button returns to the details form without losing any entered data.
---
### Step 7.5 — Assemble the Add Rabbit multi-step flow
On the `/herd/add` page, compose the three steps (Pedigree Tree → Rabbit Details + Genetics → Review) into a linear wizard. Show a step indicator at the top (Step 1 of 3, Step 2 of 3, Step 3 of 3).
A "Next" button advances to the next step. The "Next" button on Step 1 is always enabled (pedigree is optional). The "Next" button on Step 2 is disabled until the Name field has a value. On Step 3, clicking "Save Rabbit" submits a `POST /api/rabbits` request with all collected data, then redirects to the new rabbit's detail page at `/herd/:id`.
**Validation:** Complete the full flow with valid data. Confirm the POST request is sent with all fields and the correct ancestor IDs. Confirm the redirect lands on the new rabbit's detail page. Attempt to advance past Step 2 without a Name — confirm the button is disabled and/or shows a validation error.
---
### Step 7.6 — Build the Edit Rabbit page
On `/herd/:id/edit`, fetch the existing rabbit record and its ancestors. Pre-populate all form fields and the pedigree tree with the existing data. The flow is a single page (not a wizard) with all three sections visible: pedigree tree at the top, details form in the middle, genetics section (expanded) below.
Submitting sends a `PATCH /api/rabbits/:id` request with only the changed fields. On success, redirect to the rabbit's detail page.
**Validation:** Open the edit page for a rabbit with full data. All fields are pre-filled correctly. Change the weight and save. Confirm the detail page shows the updated weight and all other fields are unchanged. Confirm only the changed field appears in the PATCH request body (check network tab).
---
## Phase 8 — Rabbit Detail Page
### Step 8.1 — Build the rabbit detail left column
On `/herd/:id`, fetch the rabbit record. Render the left column with:
- Photo (if `photo_path` is set, display the image; otherwise show the breed silhouette placeholder)
- Name as a Display-size heading
- Farm name as a secondary subheading (omit if null)
- Status badge using the `Badge` component
- A stats grid: Sex, DOB (formatted as "Month DD, YYYY"), Age (calculated), Weight, Ear Number, Location
- Registration Number and GC Number (only rendered if non-null)
- Color Name, followed by loci pills for any locus fields that are set
**Validation:** View the detail page for a rabbit with all fields populated. Confirm every field renders. View a rabbit with minimal data (name and sex only) — confirm no crashes and null fields are omitted gracefully. The status badge shows the correct color for each status value.
---
### Step 8.2 — Build the read-only pedigree tree
In the right column of the rabbit detail page, fetch from `GET /api/rabbits/:id/ancestors`. Render the full 4-generation pedigree tree (root + 3 ancestor generations, 15 nodes) in the same visual layout as the entry tree, but read-only. Each ancestor node shows the rabbit's name and farm. Nodes with a database record are clickable links to that rabbit's detail page. Nodes with a freeform text value (stored in a `_name_text` column) display that text in a muted style with no link. Nodes where both the FK and text field are null display "Unknown" in a muted style.
**Validation:** On a rabbit with a full 4-generation tree in the database, all 14 ancestor nodes are populated and link to the correct detail pages. On a rabbit with unknown parents, those nodes display "Unknown" in muted text with no link. A node backed by freeform text displays the text in muted style with no link. Clicking a linked ancestor node navigates correctly.
---
### Step 8.3 — Build the litter history section
Below the pedigree tree in the right column, query `GET /api/litters` filtered by the current rabbit appearing as sire or dam. Display matching litters in a compact table: partner name, kindle date, and kit count. Each row links to the litter detail page. Show nothing (no section heading) if the rabbit has no litter history.
**Validation:** For a doe with 3 recorded litters, the litter history table shows all 3 with correct dates and kit counts. For a rabbit with no litters, the section is absent.
---
### Step 8.4 — Build the detail page action bar
At the top of the detail page, render an action bar with:
- "Edit" button — links to `/herd/:id/edit`
- "Add Litter" button — links to `/litters/add` with the rabbit's ID pre-filled as a query param (as sire or dam based on sex)
- "Mark For Sale" button — sends `PATCH /api/rabbits/:id` with `{ "status": "for_sale" }` and refreshes the page. If the rabbit's status is already `for_sale`, show "Mark Active" instead, which sets status back to `active`.
- "Archive" button — shows a confirmation prompt before sending `PATCH /api/rabbits/:id` with `{ "status": "deceased" }`. Uses the `danger` style.
**Validation:** Clicking "Edit" navigates correctly. Clicking "Mark For Sale" updates the status badge on the page without a full reload. Clicking "Archive" shows a confirmation prompt; cancelling does nothing; confirming updates the status.
---
## Phase 9 — Litters
### Step 9.1 — Build the litter card component
Create a `LitterCard` component. It renders: sire name × dam name (with "×" as a separator), kindle date formatted as "Month DD, YYYY", kit count (e.g., "3 kits"), and two buttons: "View Kits" linking to `/litters/:id` and "Edit" linking to the litter's edit state. Use the `Card` component as the wrapper.
**Validation:** Render the card with a hardcoded litter prop. Confirm all fields display correctly. A litter with no sire should show "Unknown × [dam name]". Both buttons link to the correct routes.
---
### Step 9.2 — Build the Litters list page
On `/litters`, fetch all litters from `GET /api/litters`. Render them as a vertical list of `LitterCard` components, newest first. Add a "Record Litter" button in the page header action slot. Show the `EmptyState` component if no litters exist.
**Validation:** With litters in the database, they appear newest first. The "Record Litter" button links to `/litters/add`. With no litters, the empty state is shown.
---
### Step 9.3 — Build the Add Litter form
On `/litters/add`, render a form with the following fields:
- Sire (autocomplete search using `GET /api/rabbits/search?q=&sex=buck`, restricted to bucks only — or "Unknown")
- Dam (autocomplete search using `GET /api/rabbits/search?q=&sex=doe`, restricted to does only — or "Unknown")
- Kindle Date (date input, required)
- Kit Count (number input, minimum 0)
- Notes (textarea, optional)
If the page is loaded with a `?sire=<id>` or `?dam=<id>` query param (coming from a rabbit's detail page), pre-fill the corresponding field.
Submitting sends `POST /api/litters`. On success, redirect to the new litter's detail page.
**Validation:** Submit the form with a sire, dam, and kindle date. Confirm the POST is sent and the redirect lands on the new litter's detail page. Load the page with `?dam=<id>` and confirm the dam field is pre-filled. Submit without a kindle date and confirm a validation error appears.
---
### Step 9.4 — Build the Litter detail page
On `/litters/:id`, fetch the litter from `GET /api/litters/:id`. Render:
- Page header: "Sire × Dam" as the title, kindle date as subtitle
- Sire and Dam name links (to their respective rabbit detail pages)
- Kindle date, and an expected wean date calculated as 8 weeks after kindle date, formatted as "Month DD, YYYY"
- Kit table: Name, Sex, Color, Ear Number, Status badge, Weight. Each row's name links to the kit's rabbit detail page.
- "Add Kit" button that links to `/herd/add` with `?sire=<id>&dam=<id>&dob=<kindle_date>` pre-filled
> **Note (v1):** The litter detail page derives its kit list by matching Rabbit records where `sire_id` and `dam_id` match the litter's parents and `dob` equals the litter's `kindle_date` exactly. This is intentional for v1. To prevent mismatches, the Add Rabbit form must default the DOB field to the litter's kindle date when launched from the "Add Kit" button — the user should not have to type it. A date-range tolerance is deferred to a future release.
**Validation:** View a litter with 3 kits. All kit rows appear with correct data and link to the correct rabbit detail pages. The expected wean date is exactly 56 days after the kindle date. The "Add Kit" button pre-fills the Add Rabbit form with the correct sire, dam, and DOB. Manually clear the DOB field on the Add Rabbit form before saving — confirm that kit does not appear in the litter's kit list (exact match enforcement).
---
## Phase 10 — Settings Page
### Step 10.1 — Build the Settings form
On `/settings`, fetch settings from `GET /api/settings`. Render a form with:
- Rabbitry Name (text input, pre-filled from `rabbitry_name`)
- Weight Display (segmented control: "Decimal lbs" / "lbs + oz", driven by `weight_unit`)
A "Save Settings" button sends `PATCH /api/settings` with the changed values and shows a success toast notification on completion.
**Validation:** Load the settings page and confirm the fields are pre-filled with the current values. Change the rabbitry name and save. Reload the page and confirm the new name persists. Switch the weight unit and confirm it persists across reload.
---
### Step 10.2 — Build the database export
Add a "Export Database" button on the settings page. Clicking it sends a `GET /api/export` request. The backend should return the raw SQLite database file as a binary download with the filename `mykindlebox-backup-<YYYY-MM-DD>.db` and the content type `application/octet-stream`.
**Validation:** Clicking the export button triggers a file download in the browser. The downloaded file can be opened with a SQLite browser (e.g., DB Browser for SQLite) and contains the correct tables and data.
---
## Phase 11 — Polish and PWA
### Step 11.1 — Add a PWA manifest
Add a `manifest.json` to the client's public directory. Include: `name` ("MyKindlebox"), `short_name` ("Kindlebox"), `start_url` ("/"), `display` ("standalone"), `background_color` ("#F7F3EC"), `theme_color` ("#4A6741"), and at least one icon (192×192 PNG).
**Validation:** In Chrome devtools, open the Application tab → Manifest. All fields appear correctly with no warnings. On a mobile device (or devtools mobile emulation), the browser offers an "Add to Home Screen" prompt.
---
### Step 11.2 — Add a service worker for offline shell caching
Register a service worker that caches the app shell (HTML, JS, CSS bundles, and the static silhouette image). When the device is offline, the app should load and display cached pages rather than a browser error. API requests that fail offline should show an inline "You're offline" notice rather than a crash.
**Validation:** Load the app in Chrome. Go to devtools → Application → Service Workers and confirm the service worker is registered. Enable "Offline" mode in devtools Network tab. Reload the page — the app shell loads. Navigate to `/herd` — the page renders (even if the rabbit list shows the offline notice).
---
### Step 11.3 — Add reduced motion support
Wrap all CSS transitions and animations in a `@media (prefers-reduced-motion: reduce)` media query so they are disabled when the user has this OS-level preference enabled.
**Validation:** In devtools, enable "Emulate CSS prefers-reduced-motion: reduce" in the Rendering panel. Confirm that any hover transitions and loading skeleton animations are absent.
---
### Step 11.4 — Keyboard navigation audit
Verify that all interactive elements — buttons, links, form inputs, autocomplete dropdowns, segmented controls — are reachable and operable via keyboard alone (Tab to focus, Enter/Space to activate). All focused elements must have a visible focus ring using the accent color.
**Validation:** Starting from the top of any page, tab through all interactive elements. Every element receives a visible focus ring. No interactive element is skipped. The pedigree tree autocomplete dropdown can be opened, navigated with arrow keys, and confirmed with Enter, entirely without a mouse.
---
*End of base implementation plan. Advanced features (genetics calculator, show tracking, health log, exports, public listing) are documented separately.*

247
memory-bank/progress.md Normal file
View File

@ -0,0 +1,247 @@
# MyKindlebox — Progress Log
**Audience:** AI developers picking up this codebase later.
---
## Phase 1 — Project Scaffolding (complete, validated)
- **Step 1.1**: Created `mykindlebox/server`, `mykindlebox/client`, and root `README.md`.
- **Step 1.2**: Initialized the backend with Express. Originally `index.js` (CommonJS), later converted to TypeScript in Step 2.1 prep (see below). `GET /health` returns `{ "status": "ok" }`. Validated against a running server on port 3001.
- **Step 1.3**: Scaffolded the frontend with `npm create vite@latest -- --template react`, removed the default Vite boilerplate (counter button, logos, docs/social sections) from `App.jsx`/`App.css`, and replaced with a minimal centered "MyKindlebox" heading. Installed React Router v7 (`react-router-dom@7`) — not yet wired into routes.
- **Step 1.4**: Installed **Tailwind v3** (not v4) deliberately — v4 moved theme config into CSS (`@theme`), but the plan's Step 1.4/1.5 assume a classic `tailwind.config.js` object for custom tokens, so v3 was chosen to match. Added the ten design tokens (`surface`, `surface-raised`, `surface-sunken`, `border`, `text-primary`, `text-secondary`, `accent`, `accent-hover`, `accent-warm`, `danger`) to `tailwind.config.js` theme.extend.colors, sourced from `app-design-document.md`. Validated visually (cream background, dark text).
- **Step 1.5**: Loaded DM Sans + Inter via Google Fonts `<link>` tags in `client/index.html`. Mapped `font-display` → DM Sans and `font-body` → Inter in `tailwind.config.js`. Applied `font-body` to `<body>`. Validated both fonts render correctly via devtools computed style.
- **Step 1.6**: Added `docker-compose.yml` at the repo root with `server` and `client` services, plus a named volume (`server-data`) mounted at `/app/data` in the server container for the SQLite file. Added a `Dockerfile` to each of `server/` and `client/`, plus `.dockerignore` files excluding `node_modules`.
- **Not validated** — Docker is not installed on this development machine. The user will validate `docker compose up` manually later. Do not assume this step is confirmed working until that happens.
## Phase 2 — Database (in progress)
- **Step 2.1**: Installed `drizzle-orm` and `better-sqlite3` (+ `@types/better-sqlite3`) in `server`.
- **TypeScript decision**: The plan names the file `db/client.ts`, which doesn't fit a plain-JS server. Per explicit user choice, the backend was converted to TypeScript at this point: `index.js` → `index.ts`, added `typescript`, `tsx` (used for `npm run dev` via `tsx watch`), `@types/node`, `@types/express`, and a `tsconfig.json` (CommonJS module output, `strict: true`, builds to `dist/`). `npm run build` (tsc) + `npm run start` (node dist/index.js) are the production path; `npm run dev` is tsx watch mode for local dev.
- Created `server/db/client.ts`: ensures `server/data/` exists (`fs.mkdirSync(..., { recursive: true })` — better-sqlite3 does **not** create missing parent directories itself), opens `server/data/mykindlebox.db`, and exports a Drizzle `db` instance.
- `index.ts` imports `./db/client` so the DB file is created/opened on server startup.
- Validated: deleted `data/`, restarted server, confirmed `data/mykindlebox.db` was recreated with no errors.
- **Step 2.2**: Defined the `rabbits` table in `server/db/schema.ts` — all 24 base fields from the data model (including self-referencing `sire_id`/`dam_id` FKs and `created_at`/`updated_at` timestamp defaults), the 7 genetics loci columns, and all 14 chain-authority `_name_text` ancestor columns (2 parents, 4 grandparents, 8 great-grandparents). `sex` and `status` are enforced with SQLite `CHECK` constraints rather than just TypeScript types, so invalid values are rejected at the DB layer too.
- **Migration tooling decision**: Installed `drizzle-kit` (not previously in the plan's Step 2.1 install list) and added `server/drizzle.config.ts` pointing at `./db/schema.ts` with migrations output to `server/db/migrations/`. Generated the initial migration (`0000_modern_power_pack.sql`) via `npx drizzle-kit generate` and applied it via `npx drizzle-kit migrate`. This establishes the migration-file workflow that Step 2.5 (auto-run migrations on startup) will hook into — `db/client.ts` does not yet run migrations automatically.
- Validated: inspected `server/data/mykindlebox.db` directly — all 38 columns present, FKs on `sire_id`/`dam_id` present, and the `sex_check`/`status_check` CHECK constraints correctly reject invalid values while accepting valid ones (verified with manual inserts, then cleaned up).
- **Step 2.3**: Added the `litters` table to `server/db/schema.ts` (`id`, `sire_id`/`dam_id` FKs to `rabbits.id`, `kindle_date`, `kit_count`, `notes`, `created_at`). Generated migration `0001_military_lord_tyger.sql` and applied via `npx drizzle-kit migrate`.
- Validated: inspected the DB directly — `litters` table present with both FK constraints registered against `rabbits.id`.
- **Step 2.4**: Added the `settings` table (`key` text primary key, `value` text) to `server/db/schema.ts`. Generated migration `0002_luxuriant_the_stranger.sql`.
- Seeding logic (raw `INSERT OR IGNORE`, since Drizzle has no native upsert-on-conflict-do-nothing seed helper used elsewhere in this codebase yet) lives in `db/client.ts`, added as part of Step 2.5 below — seeds `rabbitry_name` → `"My Rabbitry"` and `weight_unit` → `"decimal_lbs"`.
- **Step 2.5**: Wired `drizzle-orm/better-sqlite3/migrator`'s `migrate()` into `db/client.ts` so all pending migrations run automatically on startup, before the settings seed step. Both `drizzle-kit migrate` (CLI, used manually in Steps 2.2/2.3) and this in-code `migrate()` share the same `__drizzle_migrations` tracking table, so there's no double-application risk between the manual and automatic paths.
- Validated: deleted `server/data/`, ran the startup path — `rabbits`, `litters`, and `settings` tables were all recreated and `settings` contained exactly the two seed rows. Ran it again without deleting — no errors, settings still exactly two rows (seed uses `INSERT OR IGNORE` keyed on the `key` primary key, so it's idempotent). Confirmed `GET /health` still returns `{ "status": "ok" }` with this startup logic in place.
**Phase 2 — Database is now complete.**
## Phase 3 — Backend API (in progress)
- **Step 3.1**: Added a request logger middleware (`${method} ${path}` to console) as the first `app.use` in `index.ts`, a catch-all 404 handler (`{ "error": "Not found" }`) after all routes, and a 4-arg Express error-handling middleware last in the chain returning HTTP 500 with `{ "error": "Internal server error" }`.
- Validated: added a temporary `GET /__test-error` route that synchronously threw, confirmed it produced HTTP 500 with the correct JSON body and was logged via `console.error`, then removed the route. Confirmed `GET /nonexistent` returns HTTP 404 with the correct body and is logged by the request logger. Confirmed `GET /health` still returns 200 after the test route was removed.
- **Step 3.2**: Created `server/routes/rabbits.ts` (an Express `Router`) with `GET /api/rabbits`, mounted at `/api/rabbits` in `index.ts`. Supports `sex` (`buck`/`doe`/`unknown`, or the special `kits` value — matches any sex with `dob` within the last 56 days, computed in JS as an ISO cutoff date and compared with `gte`), `status` (exact match), `search` (SQLite `LIKE '%...%'` on `name`, case-insensitive by default for ASCII), and `sort` (`name`/`dob`/`weight_lbs`/`created_at`, default `name`, always ascending). Conditions are combined with `and(...)` only when present, so an empty query returns all rows.
- Validated: with an empty DB, the endpoint returned `[]`. Inserted two rabbits directly via `better-sqlite3` (one buck "Jimmy", one doe "Daisy") — `GET /api/rabbits` returned both, `?sex=buck` returned only Jimmy, and `?search=jim` (case-insensitive) returned only Jimmy. Cleaned up the test rows afterward.
- **Step 3.3**: Added `GET /api/rabbits/:id` to `server/routes/rabbits.ts`. Returns the full rabbit record for a valid ID, or HTTP 404 with `{ "error": "Rabbit not found" }` if no row matches.
- Validated: inserted a test rabbit directly, confirmed `GET /api/rabbits/:id` with its real ID returned the full record, and `GET /api/rabbits/99999` returned 404 with the expected body. Cleaned up the test row afterward.
- **Step 3.4**: Added `POST /api/rabbits`. Validates `name` is present and non-empty (HTTP 400 + `{ "error": "Name is required" }` if not), inserts the record, and returns it with HTTP 201.
- **JSON casing decision**: Drizzle's schema field names are camelCase (`weightLbs`, `sireId`, etc. — see `db/schema.ts`), but the implementation plan's API examples and the design doc use snake_case (`weight_lbs`, `sire_id`). Per explicit user choice, the REST API boundary uses snake_case for both request and response bodies, matching the plan/design doc literally. Added `server/utils/case.ts` (`toSnakeCase`/`toCamelCase`, generic key-mapping, not a per-field allowlist) and applied it: responses are converted camelCase → snake_case before `res.json()`, incoming bodies are converted snake_case → camelCase before being passed to Drizzle. Retrofitted the Step 3.2/3.3 GET endpoints' responses to use this too — they were not yet snake_case before this step.
- `express.json()` body parsing was added globally in `index.ts` (not per-route), since later POST/PATCH endpoints across rabbits/litters/settings will all need it.
- Validated: `POST /api/rabbits` with `{ "name": "Test Rabbit", "sex": "buck" }` returned HTTP 201 with the new record (snake_case keys, `id` assigned). A subsequent `GET /api/rabbits/:id` with that ID returned the identical record. `POST /api/rabbits` with `{ "sex": "buck" }` (no name) returned HTTP 400 with the expected error body. Cleaned up the test row afterward.
- **Step 3.5**: Added `PATCH /api/rabbits/:id`. Accepts a partial snake_case body, converts it via `toCamelCase`, and updates only the provided fields plus `updated_at` (set to the current ISO timestamp on every PATCH — the column has an insert-time default but nothing previously kept it current on update). Returns the full updated record, or HTTP 404 with `{ "error": "Rabbit not found" }` if `update(...).returning().get()` finds no matching row.
- Validated: created a rabbit via POST, sent `PATCH { "weight_lbs": 3.5 }`, confirmed the response had `weight_lbs: 3.5` with every other field unchanged (and `updated_at` advanced past `created_at`). `PATCH` to a nonexistent ID returned 404 with the expected body. Cleaned up the test row afterward.
- **Step 3.6**: Added `DELETE /api/rabbits/:id`. Checks existence first (404 if missing), then nulls out `sire_id`/`dam_id` on any rabbit referencing the target via two `update` calls, then deletes the row, returning HTTP 204.
- Validated: created a sire and a child rabbit (child's `sire_id` pointing at the sire). Deleted the sire — got 204. Confirmed the child's `sire_id` was now `null` and the sire 404s on a subsequent GET. Confirmed deleting a nonexistent ID also returns 404. Cleaned up test rows afterward.
- **Step 3.7**: Added `GET /api/rabbits/:id/ancestors` to `server/routes/rabbits.ts`, returning a nested 4-generation pedigree tree (`{ ...root fields, sire: AncestorNode|null, dam: AncestorNode|null }`, each `AncestorNode` itself having `sire`/`dam` children down to depth 3).
- **Implementation approach**: `resolveAncestorNode(record, path)` takes a rabbit row and a direction path (e.g. `['sire','sire','dam']` = paternal grandsire's dam) and walks it generically: at each step, if the current record's FK for that direction is set, it fetches that DB row and recurses into *it* with the remaining path; only when an FK is unset and the path still has remaining depth does it fall back to the current record's own flat `_name_text` column (via `flatAncestorText`, a switch on the joined path matching the 12 gen2/gen3 column names). This means the chain-authority rule falls directly out of the FK-presence check at each recursive step, rather than needing separately-tracked "is this branch broken" state — once a path recurses into a real DB row, that row's own columns are authoritative for everything deeper in that branch, and the root's flat columns are structurally unreachable from that point on. The full tree is assembled by calling this resolver independently for all 14 ancestor slot paths, then wiring each computed node's `.sire`/`.dam` to the next-deeper computed node.
- Validated all three scenarios from the plan: (1) built a full 15-rabbit unbroken chain (root → 2 parents → 4 grandparents → 8 great-grandparents) via direct DB inserts and confirmed all 14 ancestors appeared at the correct nested positions; (2) broken-chain case — root with `sire_id` null, `sire_name_text = "Bob's Buck"`, `paternal_grandsire_name_text = "Old Bob"` — confirmed `sire` resolved to `{ id: null, name: "Bob's Buck" }` and `sire.sire` resolved to `{ id: null, name: "Old Bob" }`; (3) unbroken-chain-with-stale-data case — root with a real `sire_id` pointing at a sire with no further ancestors, plus a stale `paternal_grandsire_name_text` directly on the root — confirmed `sire.sire` was `null`, correctly ignoring the root's stale flat column and reading from the real Sire record's (empty) ancestry instead. Ran `npx tsc --noEmit` to confirm no type errors (tsx watch only transpiles, doesn't typecheck). Cleaned up all test rows afterward.
- **Step 3.8**: Added `GET /api/rabbits/search` (registered *before* `GET /:id` in `routes/rabbits.ts` — Express matches routes in registration order, and `/:id` would otherwise greedily match the literal string `"search"` as an id). Selects only `id`/`name`/`farm`/`sex` via Drizzle's partial `.select({...})`, filters on `q` (case-insensitive `LIKE` on `name`) and optional `sex`, sorted by name, capped at `.limit(10)`.
- Validated: with "Jimmy", "Jimbo", "Daisy", and an unrelated rabbit in the DB, `?q=jim` returned only the two Jim-prefixed bucks; `?q=&sex=buck` and `?q=&sex=doe` correctly restricted to each sex; each result had exactly the four specified fields. Inserted 12 additional matching rabbits and confirmed the response was capped at exactly 10. Cleaned up all test rows afterward.
- **Step 3.9**: Created `server/routes/litters.ts`, mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice via two `alias()`'d copies of the table (`sireRabbits`/`damRabbits`) to embed `{ id, name }` for sire/dam (`null` if unset) rather than bare IDs, sorted by `kindle_date` descending for the list. `GET /:id` additionally derives its `kits` array by querying `rabbits` for rows matching the litter's `sire_id`/`dam_id` (using `isNull` when a parent is unset, since `eq(col, null)` is always false in SQL) and `dob` equal to the litter's `kindle_date` — same derivation approach as the design doc's no-Kit-table convention, just reused here instead of in the dashboard stats. `POST /` requires `kindle_date` (400 + `{ "error": "Kindle date is required" }` otherwise), `PATCH /:id` does a partial update, `DELETE /:id` returns 204 (404 if missing). POST/PATCH return the plain litter row (snake_case, no joined names) rather than the joined shape — only the GET endpoints need the embedded sire/dam objects.
- Validated: created a sire and dam rabbit, then a litter referencing both via `POST /api/litters` — appeared correctly in `GET /api/litters` with joined `sire`/`dam` name+id objects. Added a rabbit with matching `sire_id`/`dam_id`/`dob` and confirmed it appeared in `GET /api/litters/:id`'s `kits` array. Deleted the litter and confirmed a subsequent `GET` 404s. Ran `npx tsc --noEmit` clean. Cleaned up all test rows afterward.
- **Step 3.10**: Created `server/routes/settings.ts`, mounted at `/api/settings`. `GET /` flattens the `settings` key-value rows into a single JSON object. `PATCH /` rejects the whole request with HTTP 400 + `{ "error": "Unknown setting: <key>" }` if any key isn't in the `rabbitry_name`/`weight_unit` allowlist, otherwise updates each provided key and returns the full flattened settings object. No snake_case/camelCase conversion needed here — `settings.key` values are already the snake_case strings used at the API boundary, unlike the `rabbits`/`litters` tables which have real typed columns.
- Validated: `GET /api/settings` returned the two seeded defaults. `PATCH { "rabbitry_name": "Belowland Rabbitry" }` updated the value, confirmed by a subsequent GET. `PATCH { "bogus_key": "x" }` returned 400 with the expected error. Reset `rabbitry_name` back to the seeded default afterward.
- **Step 3.11**: Created `server/routes/stats.ts`, mounted at `/api/stats`. Returns `herd`/`bucks`/`does`/`kits` (all scoped to `status = 'active'`; `kits` additionally uses the same 56-day `dob` age-window logic as the rabbits-list `sex=kits` filter, not the litter-join method), `for_sale`/`on_hold`/`sold` (status-only counts), and `archived` (status `other` OR `deceased` combined, surfaced to the frontend as "Archived" per the design doc to avoid confusion with the Herd page's sex-based "Other" tab).
- Validated: inserted rabbits spanning every status, plus one with `dob` of today and one with `dob` 57 days ago. `GET /api/stats` returned exact expected counts for every field — `herd: 4, bucks: 2, does: 2, kits: 1, for_sale: 1, on_hold: 1, sold: 1, archived: 2` — confirming the 57-day-old rabbit was correctly excluded from `kits` and `archived` correctly summed `deceased` + `other`. Cleaned up all test rows afterward.
- **Step 3.12**: Added `POST /api/rabbits/:id/photo` to `routes/rabbits.ts`, plus static serving of `/uploads/*` in `index.ts`. Installed `multer` + `@types/multer`. Checks the rabbit exists first (404 before touching the upload, so no orphan files get written for nonexistent IDs), then runs a `multer` disk-storage upload (manually invoked as `photoUpload(req, res, callback)` inside the route rather than as ordinary middleware, so `fileFilter` rejections surface as this route's own HTTP 400 instead of falling through to the global 500 handler). Only `image/jpeg`/`image/png`/`image/webp` are accepted; the saved filename is `<rabbit-id>.<ext>`. On success, updates `photo_path` to `/uploads/<filename>` and returns the full updated rabbit record.
- **Shared uploads path**: Exported `uploadsDir` (alongside the existing `dataDir`) from `db/client.ts` so both `routes/rabbits.ts` (multer's destination) and `index.ts` (`express.static`) reference the same directory rather than each computing their own relative path.
- Validated: created a rabbit, uploaded a real JPEG via `multipart/form-data` — confirmed the file was saved to `data/uploads/`, `photo_path` was set to `/uploads/<id>.jpg`, and fetching that URL directly returned the image with `Content-Type: image/jpeg`. Uploading a `.txt` file returned HTTP 400 with `{ "error": "Invalid file type" }`. Ran `npx tsc --noEmit` clean. Cleaned up the test rabbit, uploaded file, and local test fixtures afterward.
**Phase 3 — Backend API is now complete.**
## Phase 4 — Frontend Foundation (complete, pending user validation)
- **Step 4.1**: Configured React Router v7 (already installed in Step 1.3) in `client/src/App.jsx`. Added one placeholder page component per IA route under `client/src/pages/` (`Dashboard`, `Herd`, `AddRabbit`, `RabbitDetail`, `EditRabbit`, `Litters`, `AddLitter`, `LitterDetail`, `Genetics`, `Settings`), each rendering just a heading, plus a `NotFound` page wired to a catch-all `*` route.
- Validated: confirmed via the Vite dev server's module endpoint (`curl`'ing `/src/App.jsx` and each page through Vite's transform pipeline) that every import resolves and the bundle has no syntax/transform errors. Browser-based click-through wasn't done at this step since the shell/nav didn't exist yet — visual nav validation happened in Step 4.2.
- **Step 4.2**: Created `client/src/components/Layout.jsx`, a layout route (`<Route element={<Layout />}>` wrapping all page routes in `App.jsx`) using `react-router-dom`'s nested-route `<Outlet />`. Renders a 240px-wide desktop sidebar (`hidden md:flex`, app name + `NavLink`s for Dashboard/Herd/Litters/Genetics/Settings) and a fixed-bottom mobile nav (`md:hidden`, Dashboard/Herd/Litters/Add/Settings — "Add" points at `/herd/add` per the design doc). Active-route highlighting uses `NavLink`'s `isActive` render-prop callback (accent-green fill on desktop, accent text color on mobile) rather than manual `useLocation` path-matching.
- Validated visually via Playwright screenshot (installed temporarily, then removed — not a project dependency) at both a 1280px desktop viewport and a 375px mobile viewport: confirmed the sidebar is visible/bottom nav hidden on desktop and vice versa on mobile, and that navigating to `/herd` highlights the "Herd" sidebar link in accent green with white text.
- **Step 4.3**: Installed `@tanstack/react-query`, wrapped `App.jsx` in a `QueryClientProvider`. Created `client/src/api.js` (not `api.ts` — the client was deliberately scaffolded as plain JS in Step 1.3, so `.ts` from the plan's literal wording doesn't fit; kept consistent with the rest of the client) exporting one fetch function per Phase 3 endpoint (rabbits CRUD + search + ancestors + photo upload, litters CRUD, settings get/patch, stats), all targeting `http://localhost:3001/api`. A shared `request()` helper throws on non-2xx responses (reading the `{ error }` body) and only sets `Content-Type: application/json` when the body is a JSON string (not `FormData`, used by the photo upload).
- **CORS decision**: the backend had no CORS middleware (Phase 3 never needed it — only `curl`/direct DB access were validated against it). Cross-origin `fetch` calls from the Vite dev server (port 5173) to the API (port 3001) are blocked by the browser without it. Added the `cors` package (+ `@types/cors`) to `server`, applied as `app.use(cors())` (allow-all) in `index.ts`, ahead of `express.json()`. This is a backend change made as a dependency of this frontend step, not a separate plan step — revisit with a stricter origin allowlist when auth is added.
- Validated: `Dashboard.jsx` calls `useQuery({ queryKey: ['stats'], queryFn: getStats })` and `console.log`s the result. Confirmed via `curl -H "Origin: http://localhost:5173"` against `/api/stats` that the response includes `Access-Control-Allow-Origin`, and confirmed the Dashboard module compiles cleanly through Vite's transform pipeline.
- **Step 4.4**: Built five components under `client/src/components/`: `Button` (variant × size class maps for `primary`/`secondary`/`danger` × `sm`/`md`), `Badge` (status → color/label maps covering all six Rabbit status values, defaulting to the `other` style for unrecognized values), `Card` (bordered `surface-raised` container), `PageHeader` (title/subtitle/optional right-side action slot), `EmptyState` (centered title/description/optional action).
- Validated visually: added a temporary `/dev` route rendering all five components with representative props, screenshotted with Playwright (installed temporarily for this one check, then `npm uninstall`'d — it is not a project dependency), confirmed colors/spacing/typography matched the design tokens, then deleted `pages/Dev.jsx` and removed the route from `App.jsx`.
**Phase 4 — Frontend Foundation is now complete, pending user validation per their request before starting Phase 5.**
## Phase 5 — Dashboard Page (complete, pending user validation)
- **Step 5.1**: Built the stats bar in `client/src/pages/Dashboard.jsx`. `useQuery(['stats'], getStats)` feeds 8 `Card`-wrapped stat tiles in a `grid-cols-2 md:grid-cols-4` layout (Herd, Bucks, Does, Kits, For Sale, On Hold, Sold, Archived — matching `GET /api/stats`'s response keys exactly). Each card is a `Link` to `/herd` with a query param: `sex=buck`/`sex=doe`/`sex=kits` for the sex-axis cards, `status=for_sale`/`on_hold`/`sold`/`archived` for the status-axis cards, and `/herd` with no params for the plain "Herd" card. **Note for Phase 6**: `archived` isn't a real `status` column value (the backend's `archived` stat sums `status IN ('other', 'deceased')`) — the Herd filter bar (Step 6.2) must special-case `?status=archived` to match both, the same way `routes/stats.ts` does, rather than treating it as a literal exact-match status filter. While `isLoading`, each tile renders as a `Card` with `animate-pulse bg-surface-sunken` instead of the linked content. On `isError`, a plain error message renders in place of the grid.
- Validated: seeded rabbits via direct `POST /api/rabbits` calls (one buck, one doe, one for_sale, one sold) and confirmed via Playwright screenshot (installed temporarily, then `npm uninstall`'d) that all 8 tiles showed the correct live counts, at both a 1280px desktop viewport (4-column grid, sidebar visible) and a 375px mobile viewport (2-column grid, bottom nav visible). Clicked the "Bucks" tile via a Playwright script and confirmed the browser URL became `/herd?sex=buck`. Cleaned up all test rows afterward.
- **Step 5.2**: Built the quick actions row below the stats bar — "Add Rabbit" (`Button` `primary`, links to `/herd/add`), "Record Litter" (`Button` `secondary`, links to `/litters/add`), "View Herd" (`Button` `secondary`, links to `/herd`). Per the implementation plan's literal Step 5.2 wording ("View Herd"), not the older app-design-document wording ("View Recent Activity") — the plan is the more current/authoritative spec for what's actually being built.
- Validated: confirmed visually via the same screenshot pass as Step 5.1 — all three buttons render with correct variants and labels, wrap correctly at the 375px mobile width.
- **Step 5.3**: Built the recent litters widget. `useQuery(['litters'], getLitters)` fetches all litters, sorts client-side by `kindle_date` descending, and slices to the first 3. Each row (in a single `divide-y` `Card`) shows `sire.name × dam.name` (falling back to "Unknown" for either side if the litter has no sire/dam — reusing the joined `{ id, name }` shape `GET /api/litters` already returns), the kindle date formatted as "Month D, YYYY" via `toLocaleDateString`, and a singular/plural-aware kit count. Renders nothing (returns `null`) while loading or on error — the section heading "Recent Litters" stays above it regardless. With zero litters, renders the `EmptyState` component inside a `Card` instead.
- Validated: created a sire/dam rabbit pair and one litter referencing them via direct API calls, confirmed the widget showed "Jimmy × Daisy", "May 1, 2026", "4 kits", and that it linked to `/litters/2`. Did not separately re-screenshot the empty-state path — `EmptyState` itself was already validated in Step 4.4 and the conditional here is a straightforward zero-length check. Cleaned up the test litter/rabbits afterward.
- **Layout padding (post-Step-5.1 fix)**: `Layout.jsx`'s `<main>` had no padding around page content (only `pb-16`/`pb-0` for the mobile bottom-nav clearance), so every page's content sat flush against the sidebar/top edge. Changed to `p-4 pb-20 md:p-8 md:pb-8`. This is a shared layout change, not Dashboard-specific — it affects every page rendered through `<Outlet />`.
- Validated visually via a temporary Playwright screenshot (installed, used, `npm uninstall`'d again) at the 1280px desktop viewport — confirmed visible spacing around the stats grid and sidebar.
- **Upcoming Birthdays widget (user-requested addition, not in the original implementation plan)**: Added a second widget beside Recent Litters, in a `grid-cols-1 md:grid-cols-2` section (stacked on mobile, side-by-side on desktop). `UpcomingBirthdays` (in `Dashboard.jsx`) fetches `GET /api/rabbits?status=active` and computes upcoming birthdays **client-side** — there is no backend endpoint for this. For each rabbit with a `dob`, it re-anchors the month/day onto the current (or next, if already passed) year to find the next occurrence, computes `daysUntil` and the age the rabbit will be turning, filters to a `BIRTHDAY_WINDOW_DAYS = 30`-day window, and sorts ascending by `daysUntil`. Each row links to `/herd/:id`, shows "Month D · turning N", and a right-aligned "Today" / "Tomorrow" / "in N days" label. Falls back to `EmptyState` when nothing is in the window.
- Validated: seeded three active rabbits with `dob` set to today, 5 days out, and ~6 months out (outside the window). Confirmed via Playwright screenshot that the today/5-day rabbits appeared correctly sorted with "Today"/"in 5 days" labels and correct "turning N" ages, and the far-out rabbit was correctly excluded. Checked both desktop (side-by-side with Recent Litters) and mobile (stacked) layouts. Cleaned up all test rows and the temporary Playwright dependency afterward.
**Phase 5 — Dashboard Page is now complete, pending user validation. Both the backend (`localhost:3001`) and frontend (`localhost:5173`) dev servers were left running for the user to validate Step 5 directly in a browser before Phase 6 begins.**
## Phase 6 — Herd Page (complete, pending user validation)
- **Step 6.1**: Created `client/src/components/RabbitCard.jsx`. Wraps `Card`, links to `/herd/:id`. Shows the rabbit's photo (`${SERVER_BASE}${photo_path}`) or an inline SVG breed-silhouette placeholder if `photo_path` is null, the name, a `color_name • Sex` subtitle line (omitted entirely if both are empty), an `age • weight` line, and a `0 litters • 0 kits` line — litter/kit counts are hardcoded placeholders per the plan's literal wording, since no API currently aggregates them per-rabbit. Added `client/src/lib/format.js` exporting `formatAge` (returns "Xy Ym", "Xm", "<1m", or "Age unknown" for a null/invalid `dob`) and `formatWeight` (returns `"X.XX lbs"` or `null` for a null `weight_lbs`) plus a shared `SEX_LABELS` map — used by both the card and the Step 6.4 list view. Exported `SERVER_BASE` (`http://localhost:3001`, the API origin without the `/api` suffix) from `api.js` so photo URLs and any future asset URLs share one constant instead of each computing their own.
- Validated: rendered inside the full Herd grid (Step 6.3) with a mix of seeded rabbits — confirmed the silhouette placeholder renders when no photo, name/color/sex/age/weight all display correctly, a rabbit with no `dob` shows "Age unknown" without crashing, and each card links to the correct `/herd/:id`.
- **Step 6.2**: Built the filter bar inside `client/src/pages/Herd.jsx` (`FilterBar`/`SearchBox` sub-components, not split into their own files — page-specific UI, same pattern `Dashboard.jsx` already uses). Sex segmented tabs (All/Bucks/Does/Kits/Other — "Other" maps to `sex=unknown`), a `sort` `<select>` (Name/Age/Recent/Weight, mapped to the backend's `name`/`dob`/`created_at`/`weight_lbs` sort columns respectively), a debounced search input, and a grid/list view toggle. All four read from and write to URL query params (`sex`, `sort`, `q`, `view`) via `useSearchParams`, using `replace: true` so filter changes don't spam browser history. The search box is its own component (`SearchBox`) keyed by `key={q}` in the parent so that external URL changes (e.g. back/forward navigation) reset its local input state by remounting rather than via a prop-syncing `useEffect` — the latter is now an ESLint error (`react-hooks/set-state-in-effect`) under this project's lint config, so the key-remount pattern is the required approach for any future "local input state debounced into a URL/query param" UI.
- **Archived special-case**: per the note left in Step 5.1, `?status=archived` (as linked from the Dashboard's "Archived" stat card) is not a literal backend status value — `routes/rabbits.ts`'s `GET /` only does exact-match status filtering. `Herd.jsx` special-cases this: when `status === 'archived'`, the `status` param is omitted from the backend request entirely, and the returned rows are filtered client-side to `status === 'other' || status === 'deceased'` after fetching. All other status values (`for_sale`, `on_hold`, `sold`) pass straight through to the backend as exact matches. The Herd filter bar itself has no status UI control — `status` is only ever set by arriving from a Dashboard link.
- **Recent sort**: the backend's `sort` param is always ascending (Step 3.2), so for `sort=recent` (mapped to `created_at`), the client reverses the fetched array after the request to show newest-first, since "Recent" implies newest activity, not oldest.
- Validated via Playwright (installed temporarily, then `npm uninstall`'d): clicking each sex tab updated the URL (`?sex=buck` etc.) and confirmed it survived through `setSearchParams`; typing in the search box updated `?q=...` only after the debounce window, not on every keystroke; toggling grid/list updated `?view=`; reloading the page with `?view=list` in the URL correctly restored list view (confirmed via a real page reload, not just state inspection).
- **Step 6.3**: Built `RabbitGrid` (2-col mobile, `md:grid-cols-3`, `lg:grid-cols-4`) inside `Herd.jsx`, fed by a single `useQuery(['rabbits', {...filters}], () => getRabbits({...}))` shared with the Step 6.4 list view so toggling `view` doesn't refetch. Shows `GridSkeleton` (8 pulsing `Card`s matching the real card's aspect-square + text-line proportions) while loading, and the shared `EmptyState` (with an "Add Rabbit" action button) when the filtered result set is empty.
- Validated: with 10 seeded rabbits across all sexes/statuses, all appeared in the unfiltered grid; the Bucks tab correctly narrowed to only buck rabbits; the Kits tab correctly matched only the one rabbit with a `dob` inside the 56-day window; the Archived link from a fresh `/herd?status=archived` URL correctly showed only the `deceased`+`other` rabbits; an unmatched search term rendered the empty state; sorting by Weight correctly ordered ascending with null-weight rabbits first (SQLite's null-ordering). Confirmed 2-column layout at a 375px viewport and 4-column at 1280px via Playwright (installed temporarily, then removed).
- **Step 6.4**: Built `RabbitListView` inside `Herd.jsx` for `?view=list`, rendering a `Card`-wrapped `<table>` with Name/Sex/Color/Age/Weight/Status columns, zebra-striped via `bg-surface-sunken` on odd-indexed rows (CSS `:nth-child` wasn't used since rows are already mapped in JS — indexing the map callback was simpler than relying on `odd:`/`even:` Tailwind variants). The Name cell links to `/herd/:id`; Status uses the existing `Badge` component. Reuses the exact same filtered/sorted dataset as `RabbitGrid` — no separate fetch or separate filter logic.
- Validated: toggling to list view rendered all expected columns with correct data (including the `Badge` component's correct color per status, e.g. orange "For Sale", red "Deceased"); clicking a name row navigated to the correct detail route; the same sex/search/sort filters applied identically in both views since they share one query.
**Phase 6 — Herd Page is now complete, pending user validation. Both dev servers were left running. All Playwright-based visual checks used a temporary `npm install -D playwright` + `npx playwright install chromium`, removed via `npm uninstall playwright` afterward — not a project dependency.**
## Phase 7 — Add / Edit Rabbit Flow (complete, pending user validation)
- **Step 7.1**: Created `client/src/components/PedigreeTree.jsx` plus a new `client/src/lib/pedigree.js` holding the slot/path definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapped 1:1 to the schema's `_name_text` columns) and the chain-authority resolution logic. `resolveEffectiveNode(path, state, cascadeCache)` mirrors the backend's `resolveAncestorNode` recursively client-side: gen1 nodes (`sire`/`dam`) are always sourced from their own editable state; any deeper node is "locked" (cascade-derived, non-interactive) whenever its immediate parent's effective node is `mode: 'linked'`, in which case its value comes from that ancestor's own fetched `AncestorNode` subtree (via `GET /api/rabbits/:id/ancestors`, cached in a `Map` keyed by rabbit id) rather than from any state stored on the subject.
- **No FK exists below gen1**: the `rabbits` table only has `sire_id`/`dam_id` — there is no FK column for grandparent+ slots. So when a gen2/gen3 node is *editable* (chain broken above it) and the user picks "Search existing" instead of "Enter manually", the selection still degrades to plain freeform text (the matched rabbit's name) — there is nowhere to persist a real link at that depth. Locking only ever depends on the immediate parent's resolved mode, never on whether the current node itself was searched vs. typed.
- `buildPedigreePayload`/`buildPedigreeSummary` (also in `lib/pedigree.js`) walk all 14 slots once and produce, respectively, the flat snake_case fields ready to merge into a `POST`/`PATCH /api/rabbits` body (locked slots are skipped entirely — left `null`, since they're dead columns per the chain-authority rule) and a display-only `{ slot, label, display }` array consumed by `ReviewStep`.
- Validated via Playwright (installed temporarily, removed after): searching "jim" in the Sire slot returned the seeded "Jimmy"; selecting it auto-populated and locked both grandparent nodes (`data-locked="true"`, showing "Old Bob" for the linked grandsire's own sire and "Unknown" for the granddam with no further ancestry) and confirmed clicking a locked node renders no search input; clearing Sire to "Unknown" unlocked the grandparent nodes again; entering freeform text in the now-unlocked grandsire node and saving produced a `paternal_grandsire_name_text` of exactly that text with `sire_id`/`sire_name_text` both null (since Sire itself was Unknown) — confirming the chain-authority write rule end-to-end.
- **Step 7.2**: Created `client/src/components/RabbitDetailsForm.jsx`, a controlled component (`value`/`onChange` over the same snake_case field names as the API) covering all Step 2 fields. Sex is a 3-button segmented control; Status a `<select>` over all six values; Weight switches between a single decimal input and an lbs+oz pair (converted to/from the stored decimal via `lbsToOz`/`ozToLbs` helpers local to the component) based on a `weightUnit` prop sourced from `GET /api/settings`. Photo is handled via a separate `onPhotoChange(file)` callback (not part of `value`) since upload is always a second request after the rabbit exists, per the plan — the form just shows a local object-URL preview.
- Validated: rendered inside the Step 7.5 wizard with no initial values — Farm pre-filled from settings, weight toggle switching layouts correctly, submitting Step 2 without a Name shows the red validation message and the Name field's red border without advancing.
- **Step 7.3**: Created `client/src/lib/genetics.js` (the `LOCI` config — option labels/values for all seven loci, sourced from standard rabbit-color genetics since the design doc only spelled out A/B Locus examples — and `generateColorString(loci)`) and `client/src/components/GeneticsForm.jsx` (collapsible pill-group UI, `defaultExpanded` prop). `generateColorString` combines the A-locus pattern, B+D-derived base color, E-locus extension modifier, and C/En overrides into a single string; `vv` (Vienna homozygous) short-circuits to "BEW", `cc` (true albino) short-circuits to "REW", both skipping the rest of the combination since those override the rest of the visible phenotype.
- Validated: selecting `aa`/`BB`/`DD`/`e` produced exactly `"Self Black Non-Extension"` — the literal example from the implementation plan's Step 7.3 validation — confirmed via the same Playwright pass as Step 7.1/7.5.
- **Step 7.4**: Created `client/src/components/ReviewStep.jsx` — three read-only `Card`s (Pedigree from the `pedigreeSummary` array, Details, Genetics + generated color string) with a shared `dash()` helper rendering "—" for any null/empty value, plus Back/Save Rabbit buttons.
- Validated as part of the Step 7.5 end-to-end Playwright pass: all entered values appeared correctly, freeform pedigree text appeared under its role label, empty fields showed a dash.
- **Step 7.5**: Assembled `client/src/pages/AddRabbit.jsx` as a 3-step wizard (`step` state, `STEP_LABELS` driving the "Step N of 3" subtitle). Step 1 renders `PedigreeTree` uncontrolled (it manages its own internal state/cascade and reports out via `onChange(fields, summary)`); Step 2 renders `RabbitDetailsForm` + `GeneticsForm` over wizard-level `details`/`genetics` state; Step 3 renders `ReviewStep`. "Next" on Step 2 is disabled via `details.name.trim().length > 0`. Saving calls `createRabbit({ ...details, ...pedigreeFields, ...genetics })` then, if a photo was selected, a follow-up `uploadRabbitPhoto`, then redirects to `/herd/:id`.
- **Farm default-fill**: `settings.rabbitry_name` is applied to `details.farm` exactly once via a guarded direct `setState` call in the render body (not a `useEffect` — there's nothing to clean up and effect-based prop-to-state syncing is the banned pattern here per the Step 6.2 convention; this is the React-docs-sanctioned "adjusting state during render" pattern, gated by a `useRef` flag so it only fires once when the settings query resolves).
- Validated end-to-end via Playwright (installed temporarily, removed after — see Step 7.1's note): completed the full flow (linked Sire, freeform grandsire text, name, all four loci above), confirmed the POST body's resulting record had the correct `paternal_grandsire_name_text` and loci values, and the redirect landed on `/herd/<new-id>`. No console errors during the run.
- **Step 7.6**: Created `client/src/pages/EditRabbit.jsx` — a single page (not a wizard) fetching the rabbit (`GET /api/rabbits/:id`) and its ancestors (`GET /api/rabbits/:id/ancestors`), pre-populating `details`/`genetics` local state via a guarded direct `setState` in the render body (same pattern as Step 7.5's farm default, gated on `details === null` so it only seeds once) and the `PedigreeTree` via `lib/pedigree.js`'s `seedPedigreeStateFromRabbit(rabbit, ancestors)` / `cascadeFromAncestors(ancestors)`.
- **Ancestors must be loaded before the pedigree tree mounts**: `seedPedigreeStateFromRabbit` needs `ancestors.sire.name`/`ancestors.dam.name` to give a linked Sire/Dam node a display label (the raw rabbit record only has `sire_id`/`dam_id`, not the name) — discovered via Playwright when a linked Sire rendered with a blank name. Fixed by adding `ancestorsLoading` to the page's loading guard so `PedigreeTree` (which only reads `initialState`/`initialCascade` once, at mount, via `useState(() => ...)`) never mounts before the ancestors response is available. `PedigreeTree` itself also self-heals via a mount-only effect that calls `fetchCascade` for any initially-linked gen1 node not already in `initialCascade`, so a slow/failed ancestors fetch degrades to an extra `GET /api/rabbits/:id/ancestors` call rather than a permanently blank node.
- Saving diffs `details`/`genetics`/`pedigreeFields` against the originally-loaded `rabbit` record field-by-field and only includes changed keys in the `PATCH /api/rabbits/:id` body (verified by intercepting the actual network request, not just the resulting DB state).
- Validated: opened the edit page for a rabbit with a real `sire_id` (whose own sire was also a real DB record) — Sire showed "Jimmy" and was *not* locked (gen1 is always editable), the paternal grandsire showed "Old Bob" and *was* locked. Changed only the weight and saved — confirmed via a Playwright network listener that the PATCH body contained exactly `{"location":"Hutch 3"}` (and separately `{"weight_lbs":4.1}`) with no other keys, and the subsequent `GET` showed the updated value with everything else unchanged.
**Phase 7 — Add / Edit Rabbit Flow is now complete, pending user validation. Both dev servers were left running. All Playwright-based checks used a temporary `npm install -D playwright` + `npx playwright install chromium`, removed via `npm uninstall playwright` afterward — not a project dependency. All seeded test rabbits were deleted afterward.**
- **Manual ancestor fields (user-requested addition, post-Phase-7)**: "Enter manually" in `PedigreeTree.jsx` previously captured only a name. Per explicit user request, it now collects Farm Name, Rabbit Name, and Ear Number (all three required to enable Save) plus Weight (defaults to `0`, never required) and DOB (defaults to the literal string `"Unknown"`, never required). Asked the user how to persist this since the `rabbits` schema only has a single `_name_text` text column per ancestor slot — chose **structured JSON in the existing column** (`lib/pedigree.js`'s `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay`) over a composite display string or a real schema migration, since it's the only option that lets a manual node's fields be individually re-edited later without adding 56 new columns. Every read site that previously rendered a manual node's `name` directly (the slot button, the locked-descendant display, `buildPedigreeSummary`'s `display` field) now runs it through `formatManualAncestorDisplay` (`"Name (Farm) • Ear #X"`); `parseManualAncestor` falls back to treating a non-JSON string as a legacy plain name so old test data / hand-edited rows don't break.
- Validated via Playwright (installed temporarily, removed after): Save stayed disabled with Farm/Name/Ear Number empty or partially filled, enabled once all three were set; Weight/DOB inputs showed `0`/`"Unknown"` without being touched; after saving, the slot displayed `"Old Bob (Sunnyside) • Ear #B-12"`; re-opening the node restored all three text fields from the stored JSON; the Review step showed the same formatted string; the saved record's `sire_name_text` column contained the exact JSON (`{"name":"Old Bob","farm":"Sunnyside","ear_number":"B-12","weight_lbs":4.5,"dob":"Unknown"}`). Cleaned up the test rabbit afterward.
- **Pedigree node text-overflow fix (bug, post-Phase-7)**: a screenshot showed a long Farm/Name combination ("Seabreeze (Lund Lop's)") rendering outside the rounded card border instead of truncating. Root cause: the label/value `<span>`s inside the `flex-col` button (and the label/value `<p>`s inside the locked-node `flex` row) had `truncate` but no `w-full`/`min-w-0`/`flex-1` — in a flex container a child sizes to its content by default, so `truncate`'s `overflow-hidden` had no bounded width to clip against. Fixed by adding `min-w-0` to the flex containers and `w-full` to the text spans (`overflow-hidden` added to the button itself too). Verified via a bounding-box comparison in Playwright (span width ≤ button width after the fix) rather than just eyeballing a screenshot.
- **Pedigree tree full-width layout (user-requested, post-Phase-7)**: the tree previously packed all columns to the left with `gap-6`, leaving dead space on wide viewports. `PedigreeTree.jsx`'s root container is now `w-full`, and each `Generation` column is `flex-1` so the three generations (plus the fixed-width subject card) share the available width evenly; individual node buttons changed from a fixed `w-44` to `w-full min-w-[140px]`, so they stretch to fill their column instead of staying a fixed pixel width. The `min-w-[900px]` + `overflow-x-auto` on the root is unchanged, so narrow viewports still scroll horizontally rather than crushing nodes below a usable width.
- **Pedigree tree extra bottom padding (user-requested, post-Phase-7)**: clicking a bottom-row node (e.g. a great-grandparent) opens its dropdown *below* the button via `position: absolute`; for nodes near the bottom of the tree, the dropdown's height extended past the page's existing `scrollHeight`, so a scrollbar would suddenly appear the instant a bottom-row dropdown opened. Measured the tallest dropdown (the 5-field manual-entry form) at 286px via a Playwright bounding box, then added `pb-[300px]` to the tree's root container to permanently reserve that much space — opening any dropdown, anywhere in the tree, no longer changes `document.body.scrollHeight` (verified before/after in Playwright), so there's no layout jump, at the cost of the page always reserving that space below the tree.
- **Wizard Next/Back buttons moved into the page header (user-requested, post-Phase-7)**: `AddRabbit.jsx`'s Step 1 "Next" and Step 2 "Back"/"Next" buttons previously sat below the (now full-width, taller) pedigree tree and details form, requiring a scroll to reach. Moved them into `PageHeader`'s existing `action` slot (top-right, next to the title) via a `headerAction` variable computed per-step, removing the old bottom button rows for steps 1 and 2. Step 3 (Review) keeps its own Back/Save Rabbit buttons at the bottom of `ReviewStep` — reviewing before saving is the point of that step, so those weren't moved. Verified via Playwright that the Next button is in the viewport without scrolling at a 1280×700 window and that Step 2 shows both Back and Next in the header.
- **Manual ancestor DOB redesigned as a date picker + "Unknown" checkbox (user-requested, post-Phase-7)**: the DOB field previously was a plain text input defaulting to the literal string `"Unknown"`, which doesn't read as a real field (no visible label, looks like placeholder text that happens to be real content). Replaced with a `type="date"` input labeled "Date of Birth" plus an "Unknown" checkbox below it, checked by default — checking it disables/greys out the date input and forces the stored value to `"Unknown"`; unchecking it enables picking a real date. `PedigreeTree.jsx`'s manual form state now tracks `dob` (the raw date input value) and `dobUnknown` (the checkbox) separately, collapsing to one string only at save time (`manualForm.dobUnknown ? 'Unknown' : manualForm.dob`) before handing off to `serializeManualAncestor`. A new `manualFormFromParsed(parsed)` helper derives both fields when re-opening an existing manual node, correctly distinguishing a real stored date from `"Unknown"`.
- **Visible labels on Weight/DOB**: discovered while testing the above that the Weight field showed a bare `0` with no indication of what it was — its `placeholder="Weight (lbs)"` never rendered because the field always has a default value (`0`), and placeholders only show on empty inputs (`type="date"` inputs also don't reliably render placeholders at all). Fixed by giving both Weight and DOB a real `<label>` element above the input, matching how Farm Name/Rabbit Name/Ear Number read (those work fine as placeholders only because they start empty and are required, so the placeholder is always visible until filled).
- Validated via Playwright: checkbox checked + date input disabled by default; unchecking enables the date input; filling a date, saving, then re-opening correctly restored the real date with the checkbox unchecked; re-checking the box and saving again persisted `"dob":"Unknown"` to the DB (confirmed via direct API read) even though a real date had been entered moments earlier. Cleaned up the test rabbit afterward.
## Phase 8 — Rabbit Detail Page (complete, pending user validation)
- **Step 8.1**: Replaced the `RabbitDetail.jsx` placeholder with a `DetailColumn` component fed by `useQuery(['rabbit', id], () => getRabbit(id))`. Photo or `Silhouette` placeholder (the inline SVG previously private to `RabbitCard.jsx` was extracted to `client/src/components/Silhouette.jsx` and both now share it), name as a Display heading, farm subheading (omitted if null), status `Badge`, a 2-column stats grid (Sex, DOB, Age, Weight, Ear Number, Location — each cell rendered via a `StatRow` helper that returns `null` for empty values so the grid degrades gracefully), Registration/GC Number row (only rendered if at least one is non-null), and Color Name + a pill per set locus (reusing `lib/genetics.js`'s `LOCI` option labels via a local `locusLabel` lookup).
- Validated via Playwright (installed temporarily, removed after): a rabbit with every field populated (photo-less, so silhouette shown) rendered all fields correctly including two loci pills' labels matching the selected allele option text; a minimal rabbit (name + sex only) rendered with no console errors, only Sex/Age present, and Registration/GC/Color/loci sections entirely absent (not blank fields).
- **Step 8.2**: Added `client/src/components/PedigreeTreeReadOnly.jsx`, visually matching the editable `PedigreeTree.jsx` (same `GEN1`/`GEN2`/`GEN3` slot layout, subject box, node sizing) but driven directly by the `GET /api/rabbits/:id/ancestors` response tree instead of client-side pedigree state. Added `getAncestorAtPath(ancestors, path)` to `lib/pedigree.js` — walks the nested `AncestorNode` tree along a slot's `path` (e.g. `['sire','sire']` → `ancestors.sire.sire`), returning `null` if the chain ends early. Each node renders one of three ways: `null` → muted "Unknown"; `id != null` → a `Link` to `/herd/:id` showing `name (farm)`; `id == null` with a `name` → muted freeform text, passed through `formatManualAncestorDisplay` (already handles both the structured-JSON manual-ancestor format and legacy plain-text names, reused as-is from the entry-flow code).
- Validated: a rabbit with a real `sire_id`/`dam_id` (pointing at ancestors with no further ancestry of their own) showed "Old Bob"/"Granny" as clickable links and all 12 deeper-generation nodes as muted "Unknown" — confirming the page reads strictly from the backend's already-chain-authority-resolved tree rather than re-deriving it. Clicking the linked Sire node navigated to `/herd/<sire-id>`.
- **Step 8.3**: Added a `LitterHistory` component to `RabbitDetail.jsx`. Reuses the existing `useQuery(['litters'], getLitters)` (same query key the Dashboard's recent-litters widget uses, so the cache is shared) and filters client-side to litters where `litter.sire?.id === rabbitId || litter.dam?.id === rabbitId` — there's no backend filter param for this, and `GET /api/litters` already returns the full joined sire/dam `{ id, name }` shape needed. Renders nothing (no heading, no section) when the filtered list is empty, per the plan's literal wording. Each row shows the *other* parent's name as the "Partner" (falling back to "Unknown" if that side is unset), the kindle date, and kit count, with the row linking to `/litters/:id`.
- Validated: a buck with one litter (against a doe with no litter history of her own) showed exactly one row with the doe's name as "Partner", the correct formatted date, and kit count; the doe's own (separately tested) empty-history case rendered no table at all.
- **Step 8.4**: Added the action bar into `PageHeader`'s `action` slot: "Edit" (secondary, links to `/herd/:id/edit`), "Add Litter" (secondary, links to `/litters/add?sire=<id>` or `?dam=<id>` based on `rabbit.sex`, or plain `/litters/add` for `unknown`), "Mark For Sale"/"Mark Active" (primary, toggles based on current `status`), "Archive" (danger, `window.confirm`-gated). All three status-mutating actions call a shared `patchStatus(status)` that does `await updateRabbit(id, { status })` then `queryClient.setQueryData(['rabbit', id], updated)` — updates the badge and button labels in place without a full page reload or reusing `invalidateQueries` (the PATCH response already *is* the full updated record, so no refetch is needed).
- **Layout bug found mid-step**: the left/right columns were initially built with `grid grid-cols-1 lg:grid-cols-[minmax(0,360px)_1fr]`. The Tailwind-generated CSS rule for that arbitrary value was correct (confirmed by reading the actual stylesheet rule), but the browser computed `grid-template-columns` as `2px 902px` instead of giving the first track up to 360px — some interaction between this Tailwind/Vite setup's arbitrary-bracket-value escaping and the grid track sizing algorithm that wasn't worth root-causing further. Switched to a plain flex layout (`flex flex-col lg:flex-row`, left column `lg:w-[360px] lg:shrink-0`, right column `min-w-0 flex-1`) which rendered correctly on the first try. **Avoid arbitrary-value `grid-cols-[...]` with multiple differently-sized tracks in this codebase — prefer flex with explicit widths on the fixed-size child.**
- Validated via Playwright: clicked "Mark For Sale" — badge changed to "For Sale" and the button relabeled to "Mark Active" with no navigation/reload; clicked "Archive" — confirmed `window.confirm` fired with the expected message text, accepting it updated the badge to "Deceased". Checked both a 1280px desktop viewport (columns side by side) and a 375px mobile viewport (stacked, bottom nav visible, no horizontal scroll). Cleaned up all seeded test rabbits/litter and the temporary Playwright dependency afterward.
**Phase 8 — Rabbit Detail Page is now complete, pending user validation. Both dev servers were left running.**
## Phase 9 — Litters (complete, pending user validation)
- **Step 9.1**: Created `client/src/components/LitterCard.jsx`. Renders `sire.name × dam.name` (falling back to "Unknown" for either side — same joined `{ id, name }` shape `GET /api/litters` returns), kindle date via the new shared `formatDate` helper, a singular/plural-aware kit count, and "View Kits" (→ `/litters/:id`) / "Edit" (→ `/litters/:id/edit`) buttons. Extracted `formatDate` (was a private function duplicated nowhere else yet, but about to be needed in three places this phase) from `RabbitDetail.jsx` into `lib/format.js`, and updated `RabbitDetail.jsx` to import it instead of keeping its own copy.
- **New route**: the plan's Step 9.1 wording and the design doc's litter card mockup both show an "Edit" button, but neither the IA nor the existing routes define a litter edit destination (unlike rabbits, which have `/herd/:id/edit`). Added `/litters/:id/edit` → a new `EditLitter.jsx` page, and factored the form fields shared between it and `AddLitter.jsx` into `client/src/components/LitterForm.jsx` (mirrors how `RabbitDetailsForm` is shared between `AddRabbit`/`EditRabbit`).
- Validated: rendered with a hardcoded litter prop missing `sire` — showed "Unknown × Daisy"; both buttons' `href`s matched the litter's real ID.
- **Step 9.2**: Built `client/src/pages/Litters.jsx`. `useQuery(['litters'], getLitters)` feeds a vertical `space-y-3` list of `LitterCard`s — no client-side re-sort needed, since `GET /api/litters` already sorts `kindle_date` descending (Step 3.9). "Record Litter" sits in `PageHeader`'s action slot, linking to `/litters/add`. Empty state (`EmptyState` + a second "Record Litter" action) renders when the list is empty.
- Validated: with two seeded test litters, both appeared newest-first; the header's "Record Litter" link resolved to `/litters/add`.
- **Step 9.3**: Added `client/src/components/RabbitAutocomplete.jsx` — a standalone, reusable version of the search dropdown embedded in `PedigreeTree.jsx`'s `AncestorSlot` (controlled `value: {id, name} | null`, sex-filtered via `GET /api/rabbits/search`, "Mark as Unknown" to clear). Built `LitterForm.jsx` around two of these (`sex="buck"` for Sire, `sex="doe"` for Dam) plus Kindle Date (required, red border + inline message if empty on submit), Kit Count (`type="number" min="0"`), and Notes (`textarea`, optional — empty string normalized to `null` on submit). `AddLitter.jsx` reads `?sire=<id>`/`?dam=<id>` via `useSearchParams`, fetches each via `getRabbit` (gated by `enabled: Boolean(id)`), and passes the resolved `{id, name}` as `LitterForm`'s initial sire/dam — showing a loading placeholder until both resolve, the same pattern `EditRabbit.jsx` uses for its ancestors fetch. Submitting calls `createLitter` then navigates to `/litters/:id`.
- Validated via Playwright (installed temporarily, removed after): submitting with a searched Sire, searched Dam, and a kindle date redirected to the new litter's detail page showing the correct parent names; loading `/litters/add?dam=<id>` pre-filled the Dam field with that rabbit's name; submitting with no kindle date showed "Kindle date is required" without a request being sent.
- **Step 9.4**: Built `client/src/pages/LitterDetail.jsx`. Header title is `"${sire.name} × ${dam.name}"`, subtitle the formatted kindle date. A stats `Card` shows Sire/Dam as `Link`s to `/herd/:id` (or muted "Unknown" text if unset), the kindle date, and the expected wean date — computed as `kindle_date + 56 days` via a local `addDays` helper (8 weeks, matching the backend's existing 56-day kit/kindle-date convention from Step 3.11, not a separate "8 weeks" constant). The kit table (Name/Sex/Color/Ear Number/Status/Weight) renders directly from `GET /api/litters/:id`'s already-derived `kits` array — no separate query or re-derivation on the frontend. "Add Kit" (header action) links to `/herd/add` with `sire`/`dam`/`dob` query params built from the litter's own sire/dam IDs and `kindle_date`.
- **AddRabbit.jsx updated to consume these params**: per the plan's explicit note that DOB must be pre-filled (not left for the user to type, to avoid breaking the kit-list's exact-match derivation), `AddRabbit.jsx` now reads `?sire=`/`?dam=`/`?dob=` via `useSearchParams`. `dob` seeds `details.dob` directly in the initial `useState`. `sire`/`dam` are resolved via `getRabbit` (gated `useQuery`s, same loading-guard pattern as `EditRabbit.jsx`) and used to build a `PedigreeTree` `initialState` with those slots pre-set to `mode: 'linked'` — `PedigreeTree`'s existing mount-only effect (added back in Step 7.6) already fetches the cascade for any initially-linked gen1 node, so the grandparent slots correctly lock with no extra work here.
- Validated end-to-end via Playwright: viewed a litter with one kit — the kit row showed correct name/color/ear-number/status/weight and linked to the right rabbit; the wean date showed exactly 56 days after a 2026-05-01 kindle date (June 26, 2026); clicking "Add Kit" landed on `/herd/add` with the correct `sire`/`dam`/`dob` params, and Step 2 of the wizard showed the DOB field already filled while Step 1's pedigree tree showed the Sire node linked (not editable as freeform). Also validated the Edit Litter page (pre-filled all fields including notes, saved via `PATCH /api/litters/:id`, redirected back to the detail page showing the change) and confirmed zero console errors at both 1280px and 375px viewports. Cleaned up all seeded test rabbits/litters and the temporary Playwright dependency afterward.
**Phase 9 — Litters is now complete, pending user validation. Both dev servers were left running.**
- **Auto-generated kits (user-requested addition, post-Phase-9)**: recording a litter now automatically creates `kit_count` kit `Rabbit` rows server-side, instead of requiring the manual "Add Kit" flow for every kit. Implemented in `routes/litters.ts`'s `POST /` handler, after the litter row is inserted.
- **Naming/counter design** (clarified with the user before implementing): names are `${kit_prefix}${counter}` with no separator (e.g. prefix `"RAG"` → `RAG1`, `RAG2`, ...; the dash from the design doc's mockup, if wanted, goes in the prefix itself, e.g. `"RAG-"`). The counter is a single **global, never-resetting** sequence — not per-litter — so kit names stay unique across the whole rabbitry forever, even after a litter's kits are later renamed, sold, or archived. Added two new `settings` rows for this: `kit_prefix` (user-editable — added to `routes/settings.ts`'s `ALLOWED_KEYS`, so the eventual Phase 10 Settings form can write it with no backend changes) and `kit_counter` (internal bookkeeping, deliberately **not** in `ALLOWED_KEYS` — it's not a user preference, just the last-used sequence number). Both seeded in `db/client.ts`'s `defaultSettings` (`kit_prefix: ''`, `kit_counter: '0'`). If `kit_prefix` is empty (the default, until Phase 10 builds a way to set it), generated names fall back to `Kit1`, `Kit2`, etc.
- **Implementation**: `generateKits(sireId, damId, dob, count)` runs in a `db.transaction(...)` — reads both settings rows, loops `count` times incrementing the in-memory counter and inserting a bare `Rabbit` row (`name`, `sireId`, `damId`, `dob`; sex/status left at their schema defaults of `unknown`/`active`), then writes the final counter value back in one update. The transaction wrapper exists so the counter and the inserted rows can't drift apart (e.g. a crash mid-loop leaving the counter behind what was actually inserted, risking a future name collision) — not because of any concurrent-request risk, since this is a single-user app with one SQLite connection.
- **Why `dob` is set to the litter's `kindle_date` exactly**: this isn't a new rule — it's required for the kits to satisfy the existing no-Kit-table derivation query (`sire_id`/`dam_id`/`dob` exact match, see Step 3.9/9.4) that `GET /api/litters/:id` already uses to populate its `kits` array. No frontend changes were needed for the kit table to pick these up.
- **Scope note**: generation only happens on `POST /api/litters` (creation). Editing a litter's `kit_count` via `PATCH` (the Step 9.1 Edit Litter page) does **not** create/delete kit rows to match — reconciling that automatically (which specific kit would you delete if the count goes down?) was out of scope for this request. If kit count needs correcting after the fact, use "Add Kit" / delete the rabbit directly.
- Validated directly against the API: created a litter with `kit_count: 3` and no `kit_prefix` set — got `Kit1`/`Kit2`/`Kit3`, `kit_counter` advanced to `3`. Set `kit_prefix` to `"RAG"` and created a second litter with `kit_count: 2` — got `RAG4`/`RAG5` (counter continued from 3, did not reset to 1), confirming the global-sequence behavior. Re-verified the same flow end-to-end through the real `/litters/add` form via Playwright (installed temporarily, removed after) — submitting the form produced `RAG6`/`RAG7` on the resulting litter detail page's kit table, with zero console errors. Cleaned up all seeded test rabbits/litters and manually reset `kit_prefix`/`kit_counter` back to their defaults afterward (the latter has no API for resetting since it's intentionally not user-editable, so this required a direct `better-sqlite3` script rather than `PATCH /api/settings`).
## Phase 10 — Settings Page (complete, pending user validation)
- **Step 10.1**: Built `client/src/pages/Settings.jsx`. `useQuery(['settings'], getSettings)` feeds a form pre-populated via the same guarded direct `setState`-in-render-body pattern used by `AddRabbit.jsx`'s farm default and `EditRabbit.jsx`'s seeding (a `useRef` flag gates a one-time `setForm` call once the query resolves, instead of a `useEffect`). Fields: Rabbitry Name (text input), Weight Display (two-button segmented control mirroring `RabbitDetailsForm`'s Sex control styling, `decimal_lbs`/`lbs_oz`), and Kit Prefix (text input, wired to the `kit_prefix` setting added in the post-Phase-9 auto-generated-kits work — already in `routes/settings.ts`'s `ALLOWED_KEYS`, so no backend change was needed here). "Save Settings" calls `updateSettings(form)` (a single `PATCH` with all three keys, simpler than diffing since this form has only three fields total) and shows a toast.
- **Toast**: no shared toast component existed yet. Built a minimal one locally inside `Settings.jsx` (a fixed-position bottom div, auto-dismissed via a 2.5s `setTimeout` in a `useEffect` keyed on the toast message) rather than extracting a new shared component for a single call site — same "page-specific UI stays in the page" convention `Herd.jsx`'s `FilterBar`/`SearchBox` already established.
- Validated via Playwright (installed temporarily, removed after): the Rabbitry Name field was pre-filled with the seeded "My Rabbitry" value; changed the name, switched Weight Display to "lbs + oz", set Kit Prefix to "RAG", saved — toast read "Settings saved"; reloaded the page and confirmed all three values persisted (name field, the "lbs + oz" button showing the active/accent style, and the kit prefix field) via direct DOM assertions, not just a screenshot. Zero console errors. Reset all three settings back to their defaults (`My Rabbitry`/`decimal_lbs`/empty prefix) via a direct `PATCH /api/settings` call afterward.
- **Step 10.2**: Added `GET /api/export` (`server/routes/export.ts`, mounted in `index.ts`) — calls `res.download(dbPath, ...)` where `dbPath` is the live SQLite file path, now exported from `db/client.ts` alongside the existing `dataDir`/`uploadsDir` exports (previously only used internally to construct the `Database` connection). Filename is `mykindlebox-backup-<YYYY-MM-DD>.db` using today's ISO date; `Content-Type: application/octet-stream` is passed explicitly via `res.download`'s `headers` option since Express's automatic mime lookup doesn't recognize the `.db` extension. Added `exportDatabaseUrl()` to `client/src/api.js` (returns the URL string rather than fetching — the Settings page navigates `window.location.href` to it directly so the browser's native download/Content-Disposition handling applies, instead of fetching a blob and synthesizing a download link).
- Validated the endpoint directly via `curl -D -`: confirmed `Content-Disposition: attachment; filename="mykindlebox-backup-2026-06-20.db"` and `Content-Type: application/octet-stream` headers, and that the downloaded bytes are a valid SQLite file (`file` reported `SQLite 3.x database`). Validated the full UI path via Playwright: clicking "Export Database" on the Settings page triggered a real browser download event with the exact expected filename. Cleaned up the downloaded test file and the temporary Playwright dependency afterward.
**Phase 10 — Settings Page is now complete, pending user validation. Both dev servers were left running.**
## Phase 11 — Polish and PWA (complete)
- **Step 11.1**: Added `client/public/manifest.json` (`name`/`short_name`/`start_url`/`display: "standalone"`/`background_color: "#F7F3EC"`/`theme_color: "#4A6741"`, one 192×192 icon) and linked it (`<link rel="manifest">` + `<meta name="theme-color">`) in `client/index.html`. The icon (`client/public/icon-192.png`) was generated by a one-off hand-rolled PNG encoder script (raw pixel buffer + `zlib.deflateSync`, no new dependency) — a flat accent-green square with a centered cream circle — run once via `node`, then deleted; it's not part of the build pipeline.
- Validated: `curl`'d `manifest.json` and `icon-192.png` through the Vite dev server (200, correct JSON fields, `image/png` content-type) and confirmed the rendered `index.html` includes the `<link rel="manifest">` and theme-color `<meta>` tags. Equivalent to the plan's "check Chrome devtools → Application → Manifest" validation, done via direct inspection instead of a browser session.
- **Step 11.2**: Added `client/public/sw.js` (network-first with cache fallback for the app shell; explicitly skips `/api/*` and `/uploads/*` so API calls fail fast offline rather than serving stale data) and registered it in `client/src/main.jsx` on `window.load`. Added an `OfflineBanner` to `client/src/components/Layout.jsx` — a `fixed`-positioned top banner toggled by the `online`/`offline` `window` events, shown app-wide rather than threaded through every page's query error state, since every page already has its own `isError` fallback (no crash either way).
- Validated via Playwright (installed temporarily, removed after): loaded the app, confirmed `navigator.serviceWorker.controller` became active and the shell cache (`mykindlebox-shell-v1`) populated with the HTML/JS/CSS/font/icon URLs after a reload; went offline (`context.setOffline(true)`) and reloaded — the app shell still rendered ("MyKindlebox" heading); navigated to `/herd` while offline — the page rendered (client-side routing, no network needed) and the offline banner text was present in the DOM. No test rows were created or needed for this check.
- **Step 11.3**: Added a global `@media (prefers-reduced-motion: reduce)` block to `client/src/index.css` zeroing `animation-duration`/`animation-iteration-count`/`transition-duration`/`scroll-behavior` (all `!important`) for every element, rather than auditing and tagging each individual `animate-pulse`/`transition-colors` usage with `motion-reduce:` variants across the codebase.
- Validated via Playwright (`page.emulateMedia({ reducedMotion: 'reduce' })`): confirmed a loading skeleton's `.animate-pulse` element computed `animationDuration` of `1e-05s` and a nav link's computed `transitionDuration` was likewise collapsed, both under the emulated preference.
- **Step 11.4**: Two changes. (1) Added a global `:focus-visible { outline: 2px solid theme('colors.accent') !important; outline-offset: 2px; }` rule to `index.css` — `!important` is necessary because several inputs/buttons already carry `focus:outline-none` (paired with their own `focus:ring-2`), which otherwise wins on CSS specificity over a bare `:focus-visible` rule and would leave keyboard focus invisible on exactly those elements. This covers every focusable element app-wide without auditing each component individually. (2) Added `client/src/lib/keyboardNav.js` (`handleSearchInputKeyDown`/`handleResultKeyDown`) for arrow-key roving focus through a search-dropdown's `<ul><li><button>` results list — ArrowDown from the input focuses the first result, ArrowUp/ArrowDown move between results (ArrowUp from the first result returns to the input), Escape closes the dropdown and refocuses the trigger button. Wired into both `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx`, which render the same dropdown shape (Enter/click activation needed no extra handling — native `<button>` behavior already covers it).
- Validated via Playwright: confirmed `outline-color`/`outline-style`/`outline-width` computed as the accent green (`rgb(74, 103, 65)`) / `solid` / `2px` on focus across a `Link`-wrapped header button, a `FilterBar` sex-tab button, the sort `<select>`, the search `<input>`, and a sidebar `NavLink`. For the pedigree tree's Sire dropdown: searched against the real seeded data already in the dev DB (no test rows created), confirmed ArrowDown from the input moved focus to the first result button, ArrowUp from there returned focus to the input, and Escape closed the dropdown and returned focus to the slot's trigger button. Ran `npm run build` clean and confirmed no new ESLint errors were introduced (the 5 pre-existing `react-hooks/refs`/`react-hooks/set-state-in-effect` errors in `AddRabbit.jsx`/`Settings.jsx`/`PedigreeTree.jsx` predate this phase — see the "one-shot default-fill" and mount-only-cascade-fetch conventions in `architecture.md`).
**Phase 11 — Polish and PWA is now complete. This was the last phase in the base implementation plan.**

17
memory-bank/tech-stack.md Normal file
View File

@ -0,0 +1,17 @@
# MyKindlebox — Tech Stack
**Version:** 1.0
**Last Updated:** 2026-06-19
---
| Layer | Choice | Notes |
|---|---|---|
| Frontend | React + Vite | PWA-ready |
| Routing | React Router v7 | |
| State | Zustand + React Query | Local UI state + server sync |
| Styling | Tailwind CSS | Custom theme tokens |
| Backend | Node.js + Express | REST API |
| Database | SQLite (via Drizzle ORM) | Single-file DB, easy backup |
| Auth | Single-user session token | Self-hosted, no OAuth complexity |
| Hosting | Docker Compose | Nginx + Node + SQLite volume |

3
mykindlebox/README.md Normal file
View File

@ -0,0 +1,3 @@
# MyKindlebox
A self-hosted rabbitry (rabbit breeding herd) management web app.

View File

@ -0,0 +1,2 @@
node_modules
dist

24
mykindlebox/client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,20 @@
# Build the static client, then serve it from nginx (which also reverse-proxies
# /api and /uploads to the Node server — see nginx.conf).
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

View File

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4A6741" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Apply the cached theme before first paint to avoid a flash of the
default palette. The backend `settings.theme` is the source of truth
(synced across devices) and reconciles this once it loads; this just
restores the last-known choice instantly. Keep ids in sync with
src/lib/theme.js. -->
<script>
(function () {
try {
var t = localStorage.getItem('mykindlebox-theme')
if (t && ['classic', 'midnight', 'ocean', 'berry', 'sunset', 'carnival'].indexOf(t) !== -1) {
document.documentElement.dataset.theme = t
}
} catch (e) {}
})()
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<title>MyKindlebox</title>
</head>
<body class="font-body">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Rabbit photos can be a few MB; the nginx default (1m) would reject them.
client_max_body_size 10m;
# API and uploaded files are reverse-proxied to the Node server so the whole
# app stays same-origin (session cookies, relative fetch URLs all just work).
# "server" is the service name from docker-compose.yml.
location /api/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /uploads/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# The service worker must never be served stale, or clients can get pinned to
# an old shell that points at a renamed bundle.
location = /sw.js {
add_header Cache-Control "no-cache";
}
# SPA fallback: unknown paths are client-side routes, so serve index.html.
location / {
try_files $uri $uri/ /index.html;
}
}

3432
mykindlebox/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "node --test"
},
"dependencies": {
"@tanstack/react-query": "^5.101.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.18.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"vite": "^8.0.12"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,16 @@
{
"name": "MyKindlebox",
"short_name": "Kindlebox",
"start_url": "/",
"display": "standalone",
"background_color": "#F7F3EC",
"theme_color": "#4A6741",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@ -0,0 +1,39 @@
const CACHE_NAME = 'mykindlebox-shell-v1'
const SHELL_URLS = ['/', '/favicon.svg', '/icon-192.png', '/manifest.json']
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)))
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
)
})
// Network-first with a cache fallback for the app shell (HTML/JS/CSS/static
// assets), so a previously-visited bundle still loads offline. API requests
// are left alone — they should fail fast so the UI can show its own inline
// "offline" state instead of serving stale data silently.
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') return
const url = new URL(request.url)
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) return
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy))
}
return response
})
.catch(() => caches.match(request).then((cached) => cached ?? caches.match('/'))),
)
})

View File

@ -0,0 +1,70 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { AuthProvider } from './auth'
import { getSettings } from './api'
import { applyTheme } from './lib/theme'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Herd from './pages/Herd'
import AddRabbit from './pages/AddRabbit'
import RabbitDetail from './pages/RabbitDetail'
import EditRabbit from './pages/EditRabbit'
import PedigreePrint from './pages/PedigreePrint'
import Litters from './pages/Litters'
import AddLitter from './pages/AddLitter'
import AddBreeding from './pages/AddBreeding'
import LitterDetail from './pages/LitterDetail'
import EditLitter from './pages/EditLitter'
import Genetics from './pages/Genetics'
import Settings from './pages/Settings'
import NotFound from './pages/NotFound'
const queryClient = new QueryClient()
// Reconciles the document theme with the backend `settings.theme` (the
// cross-device source of truth). index.html already applied the localStorage
// cache before first paint; this corrects it once settings load — e.g. after
// the user changed the theme on another device. Renders nothing.
function ThemeSync() {
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const theme = settings?.theme
useEffect(() => {
if (theme) applyTheme(theme)
}, [theme])
return null
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeSync />
<BrowserRouter>
<Routes>
{/* Standalone print route — deliberately outside <Layout> so the
sidebar/bottom-nav don't render (or print) on the pedigree slip. */}
<Route path="/herd/:id/pedigree" element={<PedigreePrint />} />
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/herd" element={<Herd />} />
<Route path="/herd/add" element={<AddRabbit />} />
<Route path="/herd/:id" element={<RabbitDetail />} />
<Route path="/herd/:id/edit" element={<EditRabbit />} />
<Route path="/litters" element={<Litters />} />
<Route path="/litters/add" element={<AddLitter />} />
<Route path="/breedings/add" element={<AddBreeding />} />
<Route path="/litters/:id" element={<LitterDetail />} />
<Route path="/litters/:id/edit" element={<EditLitter />} />
<Route path="/genetics" element={<Genetics />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
)
}
export default App

View File

@ -0,0 +1,100 @@
// Same-origin: in dev, Vite proxies /api and /uploads to the API server
// (see vite.config.js); in production, nginx serves the built client and
// reverse-proxies the same paths to the Node server (see client/nginx.conf).
// Relative URLs are what make the app reachable from a phone/PWA over the LAN —
// an absolute http://localhost:3001 would resolve to the *visiting device*.
export const SERVER_BASE = ''
const API_BASE = `${SERVER_BASE}/api`
// Lets the auth gate react to a session expiring mid-session: any protected
// request that comes back 401 flips the app to the login screen instead of
// surfacing a generic error in each page.
let onUnauthorized = null
export function setUnauthorizedHandler(handler) {
onUnauthorized = handler
}
async function request(path, options = {}) {
const isJsonBody = typeof options.body === 'string'
const res = await fetch(`${API_BASE}${path}`, {
headers: isJsonBody ? { 'Content-Type': 'application/json' } : undefined,
credentials: 'same-origin',
...options,
})
if (res.status === 401) {
if (onUnauthorized) onUnauthorized()
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Unauthorized')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `Request failed with status ${res.status}`)
}
if (res.status === 204) return null
return res.json()
}
function withQuery(path, params = {}) {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
search.set(key, value)
}
}
const query = search.toString()
return query ? `${path}?${query}` : path
}
// Rabbits
export const getRabbits = (params) => request(withQuery('/rabbits', params))
export const searchRabbits = (params) => request(withQuery('/rabbits/search', params))
// Global quick-search bar: prefix-matches across farm/name/ear_number/color.
// `fields` is a comma-joined subset of those keys (omit for all). Each result
// carries `matched_fields` so the dropdown can show which filter it hit.
export const quickSearchRabbits = (params) =>
request(withQuery('/rabbits/quick-search', params))
export const getRabbit = (id) => request(`/rabbits/${id}`)
export const getRabbitAncestors = (id) => request(`/rabbits/${id}/ancestors`)
export const createRabbit = (data) =>
request('/rabbits', { method: 'POST', body: JSON.stringify(data) })
export const updateRabbit = (id, data) =>
request(`/rabbits/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
export const deleteRabbit = (id) => request(`/rabbits/${id}`, { method: 'DELETE' })
export const uploadRabbitPhoto = (id, file) => {
const formData = new FormData()
formData.append('photo', file)
return request(`/rabbits/${id}/photo`, { method: 'POST', body: formData })
}
// Litters
export const getLitters = () => request('/litters')
export const getLitter = (id) => request(`/litters/${id}`)
export const createLitter = (data) =>
request('/litters', { method: 'POST', body: JSON.stringify(data) })
export const updateLitter = (id, data) =>
request(`/litters/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
export const deleteLitter = (id) => request(`/litters/${id}`, { method: 'DELETE' })
// Settings
export const getSettings = () => request('/settings')
export const updateSettings = (data) =>
request('/settings', { method: 'PATCH', body: JSON.stringify(data) })
// Stats
export const getStats = () => request('/stats')
// Export
export const exportDatabaseUrl = () => `${API_BASE}/export`
// Import — replaces ALL rabbits (and litters) with the contents of a CSV/JSON file
export const importData = (file) => {
const formData = new FormData()
formData.append('file', file)
return request('/import', { method: 'POST', body: formData })
}
// Auth
export const getAuthStatus = () => request('/auth/status')
export const login = (password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ password }) })
export const logout = () => request('/auth/logout', { method: 'POST' })

View File

@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
getAuthStatus,
login as apiLogin,
logout as apiLogout,
setUnauthorizedHandler,
} from './api'
import { AuthContext } from './authContext'
function LoginScreen({ onSuccess }) {
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
await apiLogin(password)
onSuccess()
} catch (err) {
setError(err.message || 'Login failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-md border border-border bg-surface-raised p-6"
>
<h1 className="font-display text-2xl text-text-primary">MyKindlebox</h1>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Password</label>
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
{error && <p className="font-body text-sm text-danger">{error}</p>}
<button
type="submit"
disabled={submitting || !password}
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}
// Gates the whole app behind the server's auth state. The server is the source
// of truth (fetched via React Query, like every other server read); `session`
// is a local override for client-driven transitions — login, logout, or a 401
// on any protected request — that shouldn't wait on a refetch to take effect.
export function AuthProvider({ children }) {
const { data, isLoading } = useQuery({
queryKey: ['auth-status'],
queryFn: getAuthStatus,
retry: false,
staleTime: Infinity,
})
const [session, setSession] = useState(null)
// Subscribing to an external signal (the api layer's 401 hook) — the callback
// sets state, the effect body itself doesn't.
useEffect(() => {
setUnauthorizedHandler(() => setSession(false))
return () => setUnauthorizedHandler(null)
}, [])
const logout = useCallback(async () => {
try {
await apiLogout()
} finally {
setSession(false)
}
}, [])
if (isLoading) return null
// No data (server unreachable) is treated as "auth not configured" so the app
// still renders and can show its own offline/error states per page.
const configured = data?.configured ?? false
if (!configured) {
return <AuthContext.Provider value={{ mode: 'open', logout }}>{children}</AuthContext.Provider>
}
const authenticated = session ?? data?.authenticated ?? false
if (!authenticated) {
return <LoginScreen onSuccess={() => setSession(true)} />
}
return <AuthContext.Provider value={{ mode: 'authed', logout }}>{children}</AuthContext.Provider>
}

View File

@ -0,0 +1,10 @@
import { createContext, useContext } from 'react'
// Split out from auth.jsx so that file can export only components (the
// react-refresh lint rule), and so pages can import useAuth without pulling in
// the provider.
export const AuthContext = createContext({ mode: 'open', logout: () => {} })
export function useAuth() {
return useContext(AuthContext)
}

View File

@ -0,0 +1,29 @@
const STATUS_CLASSES = {
active: 'bg-accent text-surface',
for_sale: 'bg-accent-warm text-surface',
on_hold: 'bg-text-secondary text-surface',
sold: 'bg-border text-text-primary',
deceased: 'bg-danger text-surface',
other: 'bg-border text-text-primary',
}
const STATUS_LABELS = {
active: 'Active',
for_sale: 'For Sale',
on_hold: 'On Hold',
sold: 'Sold',
deceased: 'Deceased',
other: 'Other',
}
function Badge({ status }) {
return (
<span
className={`inline-block rounded-full px-2.5 py-0.5 font-body text-xs font-medium ${STATUS_CLASSES[status] ?? STATUS_CLASSES.other}`}
>
{STATUS_LABELS[status] ?? status}
</span>
)
}
export default Badge

View File

@ -0,0 +1,21 @@
const VARIANT_CLASSES = {
primary: 'bg-accent text-surface hover:bg-accent-hover',
secondary: 'border border-border bg-transparent text-text-primary hover:bg-surface-sunken',
danger: 'bg-danger text-surface hover:opacity-90',
}
const SIZE_CLASSES = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
}
function Button({ variant = 'primary', size = 'md', className = '', ...props }) {
return (
<button
className={`rounded-md font-body font-medium transition-colors ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
{...props}
/>
)
}
export default Button

View File

@ -0,0 +1,10 @@
function Card({ className = '', ...props }) {
return (
<div
className={`rounded-lg border border-border bg-surface-raised shadow-sm ${className}`}
{...props}
/>
)
}
export default Card

View File

@ -0,0 +1,11 @@
function EmptyState({ title, description, action }) {
return (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<h3 className="font-display text-lg text-text-primary">{title}</h3>
{description && <p className="font-body text-sm text-text-secondary">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export default EmptyState

View File

@ -0,0 +1,65 @@
import { useState } from 'react'
import { LOCI, generateColorString } from '../lib/genetics'
function GeneticsForm({ value, onChange, defaultExpanded = false }) {
const [expanded, setExpanded] = useState(defaultExpanded)
const colorString = generateColorString(value)
function setLocus(key, allele) {
onChange({ [key]: value[key] === allele ? null : allele })
}
return (
<div className="rounded-lg border border-border">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between px-4 py-3 font-body text-sm font-medium text-text-primary"
>
<span>Genetics (optional)</span>
<span className="text-text-secondary">{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="space-y-3 border-t border-border px-4 py-4">
{LOCI.map((locus) => (
<div key={locus.key} className="flex flex-wrap items-center gap-2">
<span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span>
<div className="flex flex-wrap gap-1.5">
{locus.options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setLocus(locus.key, opt.value)}
className={`rounded-full border px-3 py-1 font-body text-xs ${
value[locus.key] === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
))}
<div>
<label className="block font-body text-sm font-medium text-text-primary">
Generated color string
</label>
<input
type="text"
readOnly
value={colorString}
placeholder="Select loci to generate a description"
className="mt-1 w-full rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary"
/>
</div>
</div>
)}
</div>
)
}
export default GeneticsForm

View File

@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import QuickSearch from './QuickSearch'
const navLinkClass = ({ isActive }) =>
`block rounded-md px-3 py-2 font-body text-sm ${
isActive ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
}`
const bottomNavLinkClass = ({ isActive }) =>
`flex flex-1 flex-col items-center justify-center py-2 text-xs ${
isActive ? 'text-accent' : 'text-text-secondary'
}`
// App-wide rather than per-page: every page fetches via the same `api.js`
// helpers, and a failed fetch while offline already degrades gracefully
// (existing isError branches), so one shared banner is enough to make the
// "you're offline, not broken" state visible without threading network
// status through every query/page.
function OfflineBanner() {
const [isOffline, setIsOffline] = useState(!navigator.onLine)
useEffect(() => {
function goOffline() {
setIsOffline(true)
}
function goOnline() {
setIsOffline(false)
}
window.addEventListener('offline', goOffline)
window.addEventListener('online', goOnline)
return () => {
window.removeEventListener('offline', goOffline)
window.removeEventListener('online', goOnline)
}
}, [])
if (!isOffline) return null
return (
<div className="fixed inset-x-0 top-0 z-20 bg-danger px-4 py-2 text-center font-body text-sm text-surface">
You&rsquo;re offline. Showing cached data where available.
</div>
)
}
function Layout() {
return (
<div className="flex min-h-screen bg-surface text-text-primary">
<OfflineBanner />
<aside className="hidden w-60 shrink-0 flex-col gap-1 border-r border-border p-4 md:flex">
<h1 className="mb-4 font-display text-xl">MyKindlebox</h1>
<nav className="flex flex-col gap-1">
<NavLink to="/" end className={navLinkClass}>
Dashboard
</NavLink>
<NavLink to="/herd" className={navLinkClass}>
Herd
</NavLink>
<NavLink to="/litters" className={navLinkClass}>
Litters
</NavLink>
<NavLink to="/genetics" className={navLinkClass}>
Genetics
</NavLink>
<NavLink to="/settings" className={navLinkClass}>
Settings
</NavLink>
</nav>
</aside>
<main className="flex-1 p-4 pb-20 md:p-8 md:pb-8">
<QuickSearch />
<Outlet />
</main>
<nav className="fixed inset-x-0 bottom-0 flex border-t border-border bg-surface-raised md:hidden">
<NavLink to="/" end className={bottomNavLinkClass}>
Dashboard
</NavLink>
<NavLink to="/herd" className={bottomNavLinkClass}>
Herd
</NavLink>
<NavLink to="/litters" className={bottomNavLinkClass}>
Litters
</NavLink>
<NavLink to="/herd/add" className={bottomNavLinkClass}>
Add
</NavLink>
<NavLink to="/settings" className={bottomNavLinkClass}>
Settings
</NavLink>
</nav>
</div>
)
}
export default Layout

View File

@ -0,0 +1,42 @@
import { Link } from 'react-router-dom'
import Card from './Card'
import Button from './Button'
import { formatDate } from '../lib/format'
function LitterCard({ litter }) {
const sireName = litter.sire?.name ?? 'Unknown'
const damName = litter.dam?.name ?? 'Unknown'
const kitCount = litter.kit_count ?? 0
const kindled = Boolean(litter.kindle_date)
// Once kindled, show the kindle date + kit count; an in-progress breeding shows
// its breeding date instead so it doesn't render a blank date line.
const meta = kindled
? `${formatDate(litter.kindle_date)} • ${kitCount} ${kitCount === 1 ? 'kit' : 'kits'}`
: `Bred ${formatDate(litter.breed_date)} • Not yet kindled`
return (
<Card className="flex flex-wrap items-center justify-between gap-4 p-4">
<div>
<p className="font-display text-base text-text-primary">
{sireName} × {damName}
</p>
<p className="mt-1 font-body text-sm text-text-secondary">{meta}</p>
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/litters/${litter.id}`}>
<Button variant="secondary" size="sm">
{kindled ? 'View Kits' : 'View'}
</Button>
</Link>
<Link to={`/litters/${litter.id}/edit`}>
<Button variant="secondary" size="sm">
Edit
</Button>
</Link>
</div>
</Card>
)
}
export default LitterCard

View File

@ -0,0 +1,144 @@
import { useState } from 'react'
import RabbitAutocomplete from './RabbitAutocomplete'
import Button from './Button'
// Shared by AddLitter/EditLitter (kindle records) and AddBreeding (breeding
// records). A litter row spans the breeding→kindle lifecycle, so both a breeding
// date and a kindle date live here. `mode` decides which date is the required,
// primary field and whether kit count is shown:
// - 'breeding': breeding date required; kindle date + kit count hidden.
// - 'litter': kindle date required; breeding date shown as optional.
function LitterForm({
initialValues,
onSubmit,
onCancel,
saving,
error,
submitLabel = 'Save Litter',
mode = 'litter',
}) {
const isBreeding = mode === 'breeding'
const [sire, setSire] = useState(initialValues.sire ?? null)
const [dam, setDam] = useState(initialValues.dam ?? null)
const [breedDate, setBreedDate] = useState(initialValues.breed_date ?? '')
const [kindleDate, setKindleDate] = useState(initialValues.kindle_date ?? '')
const [kitCount, setKitCount] = useState(
initialValues.kit_count === null || initialValues.kit_count === undefined
? ''
: String(initialValues.kit_count)
)
const [notes, setNotes] = useState(initialValues.notes ?? '')
const [showValidation, setShowValidation] = useState(false)
function handleSubmit(e) {
e.preventDefault()
if (isBreeding ? !breedDate : !kindleDate) {
setShowValidation(true)
return
}
onSubmit({
sire_id: sire?.id ?? null,
dam_id: dam?.id ?? null,
breed_date: breedDate || null,
kindle_date: kindleDate || null,
kit_count: kitCount === '' ? null : Number(kitCount),
notes: notes.trim() || null,
})
}
return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4">
<div>
<label className="block font-body text-xs text-text-secondary">Sire</label>
<div className="mt-0.5">
<RabbitAutocomplete sex="buck" value={sire} onChange={setSire} />
</div>
</div>
<div>
<label className="block font-body text-xs text-text-secondary">Dam</label>
<div className="mt-0.5">
<RabbitAutocomplete sex="doe" value={dam} onChange={setDam} />
</div>
</div>
<div>
<label className="block font-body text-xs text-text-secondary">
{isBreeding ? 'Breeding Date *' : 'Breeding Date'}
</label>
<input
type="date"
value={breedDate}
onChange={(e) => {
setBreedDate(e.target.value)
setShowValidation(false)
}}
className={`mt-0.5 w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
isBreeding && showValidation ? 'border-danger' : 'border-border'
}`}
/>
{isBreeding && showValidation && (
<p className="mt-1 font-body text-xs text-danger">Breeding date is required</p>
)}
</div>
{!isBreeding && (
<>
<div>
<label className="block font-body text-xs text-text-secondary">Kindle Date *</label>
<input
type="date"
value={kindleDate}
onChange={(e) => {
setKindleDate(e.target.value)
setShowValidation(false)
}}
className={`mt-0.5 w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
showValidation ? 'border-danger' : 'border-border'
}`}
/>
{showValidation && (
<p className="mt-1 font-body text-xs text-danger">Kindle date is required</p>
)}
</div>
<div>
<label className="block font-body text-xs text-text-secondary">Kit Count</label>
<input
type="number"
min="0"
value={kitCount}
onChange={(e) => setKitCount(e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</>
)}
<div>
<label className="block font-body text-xs text-text-secondary">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{error && <p className="font-body text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-3">
{onCancel && (
<Button type="button" variant="secondary" onClick={onCancel} disabled={saving}>
Cancel
</Button>
)}
<Button type="submit" disabled={saving}>
{saving ? 'Saving…' : submitLabel}
</Button>
</div>
</form>
)
}
export default LitterForm

View File

@ -0,0 +1,13 @@
function PageHeader({ title, subtitle, action }) {
return (
<div className="flex items-start justify-between gap-4 p-6">
<div>
<h1 className="font-display text-2xl text-text-primary">{title}</h1>
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)
}
export default PageHeader

View File

@ -0,0 +1,357 @@
import { useEffect, useRef, useState } from 'react'
import { searchRabbits, getRabbitAncestors } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
import {
GEN1,
GEN2,
GEN3,
createInitialPedigreeState,
resolveEffectiveNode,
buildPedigreePayload,
buildPedigreeSummary,
serializeManualAncestor,
parseManualAncestor,
formatManualAncestorDisplay,
} from '../lib/pedigree'
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: true }
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
// is the separate "Unknown" checkbox. They collapse into one stored string
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
function manualFormFromParsed(parsed) {
const isKnownDate = parsed?.dob && parsed.dob !== 'Unknown'
return {
...EMPTY_MANUAL_FORM,
...parsed,
weight_lbs: String(parsed?.weight_lbs ?? '0'),
dob: isKnownDate ? parsed.dob : '',
dobUnknown: !isKnownDate,
}
}
function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade }) {
const node = resolveEffectiveNode(path, state, cascadeCache)
const [open, setOpen] = useState(false)
const [mode, setMode] = useState('search') // 'search' | 'manual'
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [manualForm, setManualForm] = useState(EMPTY_MANUAL_FORM)
const containerRef = useRef(null)
const inputRef = useRef(null)
const triggerRef = useRef(null)
function close() {
setOpen(false)
triggerRef.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])
useEffect(() => {
if (!open || mode !== 'search') return
const handle = setTimeout(() => {
searchRabbits({ q: query }).then(setResults).catch(() => setResults([]))
}, 200)
return () => clearTimeout(handle)
}, [query, open, mode])
if (node.locked) {
const text = node.loading
? 'Loading…'
: node.mode === 'unknown'
? 'Unknown'
: node.mode === 'manual'
? formatManualAncestorDisplay(node.name)
: node.name
return (
<div
data-testid={`pedigree-node-${slot}`}
data-locked="true"
className="flex h-16 w-full items-center justify-between gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2"
>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate font-body text-xs text-text-secondary">{label}</p>
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
</div>
<span aria-label="locked" className="text-text-secondary">
🔒
</span>
</div>
)
}
function selectRabbit(rabbit) {
setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name })
if (slot === 'sire' || slot === 'dam') fetchCascade(rabbit.id)
setOpen(false)
setQuery('')
}
const canSaveManual =
manualForm.name.trim() && manualForm.farm.trim() && manualForm.ear_number.trim()
function setManualField(field, fieldValue) {
setManualForm((prev) => ({ ...prev, [field]: fieldValue }))
}
function saveManual() {
if (!canSaveManual) return
const dob = manualForm.dobUnknown ? 'Unknown' : manualForm.dob
setSlotState(slot, { mode: 'manual', id: null, name: serializeManualAncestor({ ...manualForm, dob }) })
setOpen(false)
}
function markUnknown() {
setSlotState(slot, { mode: 'unknown', id: null, name: null })
setOpen(false)
}
const displayText =
node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name
return (
<div ref={containerRef} data-testid={`pedigree-node-${slot}`} className="relative w-full min-w-[140px]">
<button
ref={triggerRef}
type="button"
onClick={() => {
setOpen((v) => !v)
if (node.mode === 'manual') {
setMode('manual')
setManualForm(manualFormFromParsed(parseManualAncestor(node.name)))
} else {
setMode('search')
setManualForm(EMPTY_MANUAL_FORM)
}
}}
className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
>
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
<span
className={`w-full truncate font-body text-sm ${node.mode === 'unknown' ? 'text-text-secondary' : 'text-text-primary'}`}
>
{displayText}
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-10 mt-1 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm">
<div className="mb-2 flex gap-1">
<button
type="button"
onClick={() => setMode('search')}
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
>
Search existing
</button>
<button
type="button"
onClick={() => setMode('manual')}
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
>
Enter manually
</button>
</div>
{mode === 'search' && (
<div>
<input
ref={inputRef}
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
placeholder="Search by name…"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<ul className="mt-1 max-h-40 overflow-auto">
{results.map((rabbit, index) => (
<li key={rabbit.id}>
<button
type="button"
onClick={() => selectRabbit(rabbit)}
onKeyDown={(e) =>
handleResultKeyDown(e, {
containerEl: containerRef.current,
index,
inputEl: inputRef.current,
close,
})
}
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken"
>
{rabbit.name}
{rabbit.farm ? ` (${rabbit.farm})` : ''}
</button>
</li>
))}
{query && results.length === 0 && (
<li className="px-2 py-1 text-sm text-text-secondary">No matches</li>
)}
</ul>
</div>
)}
{mode === 'manual' && (
<div className="space-y-1.5">
<input
autoFocus
type="text"
value={manualForm.farm}
onChange={(e) => setManualField('farm', e.target.value)}
placeholder="Farm Name *"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<input
type="text"
value={manualForm.name}
onChange={(e) => setManualField('name', e.target.value)}
placeholder="Rabbit Name *"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<input
type="text"
value={manualForm.ear_number}
onChange={(e) => setManualField('ear_number', e.target.value)}
placeholder="Ear Number *"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
<input
type="number"
step="0.01"
value={manualForm.weight_lbs}
onChange={(e) => setManualField('weight_lbs', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Date of Birth</label>
<input
type="date"
value={manualForm.dob}
disabled={manualForm.dobUnknown}
onChange={(e) => setManualField('dob', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
/>
</div>
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
<input
type="checkbox"
checked={manualForm.dobUnknown}
onChange={(e) =>
setManualForm((prev) => ({
...prev,
dobUnknown: e.target.checked,
dob: e.target.checked ? '' : prev.dob,
}))
}
className="accent-accent"
/>
Unknown
</label>
<button
type="button"
onClick={saveManual}
disabled={!canSaveManual}
className="w-full rounded bg-accent px-2 py-1 text-xs text-surface disabled:opacity-50"
>
Save
</button>
</div>
)}
<button
type="button"
onClick={markUnknown}
className="mt-2 w-full rounded px-2 py-1 text-left text-xs text-text-secondary hover:bg-surface-sunken"
>
Mark as Unknown
</button>
</div>
)}
</div>
)
}
function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }) {
return (
<div className="flex min-w-0 flex-1 flex-col justify-around gap-3">
{entries.map(({ path, slot, label }) => (
<AncestorSlot
key={slot}
path={path}
slot={slot}
label={label}
state={state}
setSlotState={setSlotState}
cascadeCache={cascadeCache}
fetchCascade={fetchCascade}
/>
))}
</div>
)
}
function PedigreeTree({
subjectName = 'New Rabbit',
initialState,
initialCascade,
onChange,
}) {
const [state, setState] = useState(() => initialState ?? createInitialPedigreeState())
const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map())
function setSlotState(slot, value) {
setState((prev) => ({ ...prev, [slot]: value }))
}
function fetchCascade(id) {
if (cascadeCache.has(id)) return
setCascadeCache((prev) => new Map(prev).set(id, undefined))
getRabbitAncestors(id)
.then((tree) => setCascadeCache((prev) => new Map(prev).set(id, tree)))
.catch(() => setCascadeCache((prev) => new Map(prev).set(id, null)))
}
useEffect(() => {
onChange?.(buildPedigreePayload(state, cascadeCache), buildPedigreeSummary(state, cascadeCache))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, cascadeCache])
// If the initial state (e.g. editing an existing rabbit) already has a
// linked sire/dam, make sure its cascade subtree gets fetched — initialCascade
// only covers what was already available synchronously at mount.
useEffect(() => {
for (const { slot } of GEN1) {
const node = state[slot]
if (node.mode === 'linked' && node.id != null) fetchCascade(node.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex w-full min-w-[900px] items-center gap-6 overflow-x-auto p-4 pb-[300px]">
<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>
</div>
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
</div>
)
}
export default PedigreeTree

View File

@ -0,0 +1,64 @@
import { Link } from 'react-router-dom'
import { GEN1, GEN2, GEN3, getAncestorAtPath, formatManualAncestorDisplay } from '../lib/pedigree'
function AncestorNodeView({ path, label, ancestors }) {
const node = getAncestorAtPath(ancestors, path)
if (!node) {
return (
<div className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-sunken px-3 py-2">
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
<span className="w-full truncate font-body text-sm text-text-secondary italic">Unknown</span>
</div>
)
}
if (node.id != null) {
return (
<Link
to={`/herd/${node.id}`}
className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
>
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
<span className="w-full truncate font-body text-sm text-text-primary">
{node.name}
{node.farm ? ` (${node.farm})` : ''}
</span>
</Link>
)
}
return (
<div className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-sunken px-3 py-2">
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
<span className="w-full truncate font-body text-sm text-text-secondary italic">
{formatManualAncestorDisplay(node.name)}
</span>
</div>
)
}
function Generation({ entries, ancestors }) {
return (
<div className="flex min-w-0 flex-1 flex-col justify-around gap-3">
{entries.map(({ path, slot, label }) => (
<AncestorNodeView key={slot} path={path} label={label} ancestors={ancestors} />
))}
</div>
)
}
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>
</div>
<Generation entries={GEN1} ancestors={ancestors} />
<Generation entries={GEN2} ancestors={ancestors} />
<Generation entries={GEN3} ancestors={ancestors} />
</div>
)
}
export default PedigreeTreeReadOnly

View File

@ -0,0 +1,222 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { quickSearchRabbits } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
// The fields the quick-search can target. Keys match the backend's
// `?fields=` values and the `matched_fields` it returns; `label` is the
// filter-menu / result-chip text. Order here is the menu order.
const SEARCH_FIELDS = [
{ key: 'farm', label: 'Farm Name' },
{ key: 'name', label: 'Rabbit Name' },
{ key: 'ear_number', label: 'Ear Number' },
{ key: 'color', label: 'Color' },
]
// Per-result chip label for a matched field (shorter than the menu label).
const MATCH_LABELS = {
farm: 'Farm',
name: 'Name',
ear_number: 'Ear #',
color: 'Color',
}
// The value on the result row that a given field matched against, so the chip
// can show the actual hit (e.g. the ear number that matched), not just "Ear #".
function matchedValue(result, field) {
switch (field) {
case 'farm':
return result.farm
case 'name':
return result.name
case 'ear_number':
return result.ear_number
case 'color':
return result.color_name
default:
return null
}
}
function QuickSearch() {
const navigate = useNavigate()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [open, setOpen] = useState(false)
const [filtersOpen, setFiltersOpen] = useState(false)
// All filters selected by default.
const [enabled, setEnabled] = useState(() =>
Object.fromEntries(SEARCH_FIELDS.map((f) => [f.key, true]))
)
const containerRef = useRef(null)
const inputRef = useRef(null)
const selectedFields = SEARCH_FIELDS.filter((f) => enabled[f.key]).map((f) => f.key)
function close() {
setOpen(false)
setFiltersOpen(false)
inputRef.current?.focus()
}
// Close both dropdowns when clicking outside the whole widget.
useEffect(() => {
if (!open && !filtersOpen) return
function onClickOutside(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false)
setFiltersOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open, filtersOpen])
// Debounced search as the user types; re-runs when the filter set changes.
// No synchronous setState here (project lint forbids it): when the query is
// empty or no filters are selected we just skip the fetch — the dropdown's
// render is already gated on a non-empty query / selected filters, so any
// stale results sitting in state are never shown.
useEffect(() => {
const term = query.trim()
if (!term || selectedFields.length === 0) {
return
}
const handle = setTimeout(() => {
quickSearchRabbits({ q: term, fields: selectedFields.join(',') })
.then(setResults)
.catch(() => setResults([]))
}, 200)
return () => clearTimeout(handle)
// selectedFields is derived from `enabled`; depend on the stable join.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, enabled])
function selectResult(result) {
setOpen(false)
setFiltersOpen(false)
setQuery('')
setResults([])
navigate(`/herd/${result.id}`)
}
function toggleField(key) {
setEnabled((prev) => ({ ...prev, [key]: !prev[key] }))
}
const showDropdown = open && query.trim() !== ''
return (
<div ref={containerRef} className="relative mb-4 max-w-xl">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) =>
handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })
}
placeholder="Quick search herd…"
aria-label="Quick search herd"
className="w-full rounded-md border border-border bg-surface-raised px-3 py-2 font-body text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
/>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
aria-label="Choose search filters"
className={`shrink-0 rounded-md border px-3 py-2 font-body text-sm ${
filtersOpen
? 'border-accent text-accent'
: 'border-border text-text-secondary hover:border-accent'
}`}
>
Filters
</button>
</div>
{filtersOpen && (
<div className="absolute right-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-surface-raised p-2 shadow-sm">
<p className="px-1 pb-1 font-label text-xs uppercase tracking-wide text-text-secondary">
Search in
</p>
{SEARCH_FIELDS.map((field) => (
<label
key={field.key}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-surface-sunken"
>
<input
type="checkbox"
checked={enabled[field.key]}
onChange={() => toggleField(field.key)}
className="accent-accent"
/>
{field.label}
</label>
))}
</div>
)}
{showDropdown && (
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised shadow-sm">
{selectedFields.length === 0 ? (
<p className="px-3 py-2 text-sm text-text-secondary">
No filters selected.
</p>
) : results.length === 0 ? (
<p className="px-3 py-2 text-sm text-text-secondary">No matches</p>
) : (
<ul className="max-h-80 overflow-auto py-1">
{results.map((result, index) => (
<li key={result.id}>
<button
type="button"
onClick={() => selectResult(result)}
onKeyDown={(e) =>
handleResultKeyDown(e, {
containerEl: containerRef.current,
index,
inputEl: inputRef.current,
close,
})
}
className="block w-full px-3 py-2 text-left hover:bg-surface-sunken"
>
<span className="font-body text-sm text-text-primary">
{result.name}
{result.farm ? (
<span className="text-text-secondary"> · {result.farm}</span>
) : null}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{(result.matched_fields ?? []).map((field) => {
const value = matchedValue(result, field)
return (
<span
key={field}
className="rounded bg-surface-sunken px-1.5 py-0.5 text-xs text-text-secondary"
>
{MATCH_LABELS[field] ?? field}
{value ? `: ${value}` : ''}
</span>
)
})}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
export default QuickSearch

View File

@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react'
import { searchRabbits } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
// Controlled sire/dam picker for the litter form. `value` is `{ id, name }`
// or `null` (Unknown) — mirrors PedigreeTree's search dropdown, but as a
// standalone reusable field rather than embedded in a pedigree slot.
function RabbitAutocomplete({ sex, value, onChange, placeholder = 'Search by name…' }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const containerRef = useRef(null)
const inputRef = useRef(null)
const triggerRef = useRef(null)
function close() {
setOpen(false)
triggerRef.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])
useEffect(() => {
if (!open) return
const handle = setTimeout(() => {
searchRabbits({ q: query, sex }).then(setResults).catch(() => setResults([]))
}, 200)
return () => clearTimeout(handle)
}, [query, open, sex])
function select(rabbit) {
onChange(rabbit ? { id: rabbit.id, name: rabbit.name } : null)
setOpen(false)
setQuery('')
}
return (
<div ref={containerRef} className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full rounded border border-border bg-surface-raised px-2 py-1.5 text-left font-body text-sm text-text-primary hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
>
{value ? value.name : 'Unknown'}
</button>
{open && (
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised p-2 shadow-sm">
<input
ref={inputRef}
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
placeholder={placeholder}
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<ul className="mt-1 max-h-40 overflow-auto">
{results.map((rabbit, index) => (
<li key={rabbit.id}>
<button
type="button"
onClick={() => select(rabbit)}
onKeyDown={(e) =>
handleResultKeyDown(e, {
containerEl: containerRef.current,
index,
inputEl: inputRef.current,
close,
})
}
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken"
>
{rabbit.name}
{rabbit.farm ? ` (${rabbit.farm})` : ''}
</button>
</li>
))}
{query && results.length === 0 && (
<li className="px-2 py-1 text-sm text-text-secondary">No matches</li>
)}
</ul>
<button
type="button"
onClick={() => select(null)}
className="mt-2 w-full rounded px-2 py-1 text-left text-xs text-text-secondary hover:bg-surface-sunken"
>
Mark as Unknown
</button>
</div>
)}
</div>
)
}
export default RabbitAutocomplete

View File

@ -0,0 +1,46 @@
import { Link } from 'react-router-dom'
import Card from './Card'
import Silhouette from './Silhouette'
import { SERVER_BASE } from '../api'
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
function RabbitCard({ rabbit }) {
const weightUnit = useWeightUnit()
const age = formatAge(rabbit.dob)
const weight = formatWeight(rabbit.weight_lbs, weightUnit)
const subtitleParts = [rabbit.color_name, SEX_LABELS[rabbit.sex] ?? rabbit.sex].filter(Boolean)
return (
<Link to={`/herd/${rabbit.id}`}>
<Card className="flex h-full flex-col overflow-hidden transition-colors hover:bg-surface-sunken">
<div className="flex aspect-square items-center justify-center bg-surface-sunken">
{rabbit.photo_path ? (
<img
src={`${SERVER_BASE}${rabbit.photo_path}`}
alt={rabbit.name}
className="h-full w-full object-cover"
/>
) : (
<Silhouette />
)}
</div>
<div className="flex flex-col gap-1 p-3">
<p className="truncate font-display text-base text-text-primary">{rabbit.name}</p>
{subtitleParts.length > 0 && (
<p className="truncate font-body text-sm text-text-secondary">
{subtitleParts.join(' • ')}
</p>
)}
<p className="font-body text-sm text-text-secondary">
{age}
{weight ? ` • ${weight}` : ''}
</p>
<p className="font-body text-xs text-text-secondary">0 litters • 0 kits</p>
</div>
</Card>
</Link>
)
}
export default RabbitCard

View File

@ -0,0 +1,204 @@
import { useState } from 'react'
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'for_sale', label: 'For Sale' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'sold', label: 'Sold' },
{ value: 'deceased', label: 'Deceased' },
{ value: 'other', label: 'Other' },
]
const SEX_OPTIONS = [
{ value: 'buck', label: 'Buck' },
{ value: 'doe', label: 'Doe' },
{ value: 'unknown', label: 'Unknown' },
]
function lbsToOz(decimalLbs) {
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
const total = Number(decimalLbs)
if (Number.isNaN(total)) return { lbs: '', oz: '' }
const lbs = Math.floor(total)
const oz = Math.round((total - lbs) * 16)
return { lbs: String(lbs), oz: String(oz) }
}
function ozToLbs(lbs, oz) {
const l = Number(lbs) || 0
const o = Number(oz) || 0
return l + o / 16
}
function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decimal_lbs', showValidation = false }) {
const [photoPreview, setPhotoPreview] = useState(null)
const { lbs: ozLbs, oz: ozOz } = lbsToOz(value.weight_lbs)
function setField(field, fieldValue) {
onChange({ [field]: fieldValue })
}
function handlePhotoSelect(e) {
const file = e.target.files?.[0]
if (!file) return
onPhotoChange?.(file)
setPhotoPreview(URL.createObjectURL(file))
}
const nameInvalid = showValidation && !value.name?.trim()
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label className="block font-body text-sm font-medium text-text-primary">
Name <span className="text-danger">*</span>
</label>
<input
type="text"
value={value.name ?? ''}
onChange={(e) => setField('name', e.target.value)}
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 ${
nameInvalid ? 'border-danger' : 'border-border'
}`}
/>
{nameInvalid && <p className="mt-1 font-body text-xs text-danger">Name is required</p>}
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Farm / Prefix</label>
<input
type="text"
value={value.farm ?? ''}
onChange={(e) => setField('farm', e.target.value)}
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">Sex</label>
<div className="mt-1 flex gap-1">
{SEX_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setField('sex', opt.value)}
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
value.sex === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label>
<input
type="date"
value={value.dob ?? ''}
onChange={(e) => setField('dob', e.target.value)}
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' ? (
<div className="mt-1 flex gap-2">
<input
type="number"
min="0"
value={ozLbs}
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
placeholder="lbs"
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<input
type="number"
min="0"
max="15"
value={ozOz}
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
placeholder="oz"
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
) : (
<input
type="number"
min="0"
step="0.01"
value={value.weight_lbs ?? ''}
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
placeholder="decimal lbs"
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">Ear Number</label>
<input
type="text"
value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value)}
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">Location</label>
<input
type="text"
value={value.location ?? ''}
onChange={(e) => setField('location', e.target.value)}
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">Status</label>
<select
value={value.status ?? 'active'}
onChange={(e) => setField('status', 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"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label>
<input
type="text"
value={value.color_name ?? ''}
onChange={(e) => setField('color_name', e.target.value)}
placeholder="e.g. Broken Black Tort"
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 className="md:col-span-2">
<label className="block font-body text-sm font-medium text-text-primary">Photo</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoSelect}
className="mt-1 block w-full font-body text-sm text-text-secondary"
/>
{photoPreview && (
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
)}
</div>
</div>
)
}
export default RabbitDetailsForm

View File

@ -0,0 +1,75 @@
import Button from './Button'
import Card from './Card'
import { LOCI, generateColorString } from '../lib/genetics'
const DETAIL_FIELDS = [
{ key: 'name', label: 'Name' },
{ key: 'farm', label: 'Farm / Prefix' },
{ key: 'sex', label: 'Sex' },
{ key: 'dob', label: 'Date of Birth' },
{ key: 'weight_lbs', label: 'Weight (lbs)' },
{ key: 'ear_number', label: 'Ear Number' },
{ key: 'location', label: 'Location' },
{ key: 'status', label: 'Status' },
{ key: 'color_name', label: 'Color Name' },
]
function dash(value) {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
function ReviewStep({ details, pedigreeSummary, genetics, onBack, onSave, saving = false }) {
return (
<div className="space-y-6">
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Pedigree</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{pedigreeSummary.map((node) => (
<div key={node.slot} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{node.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(node.display)}</dd>
</div>
))}
</dl>
</Card>
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Details</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{DETAIL_FIELDS.map((field) => (
<div key={field.key} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{field.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(details[field.key])}</dd>
</div>
))}
</dl>
</Card>
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Genetics</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{LOCI.map((locus) => (
<div key={locus.key} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{locus.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(genetics[locus.key])}</dd>
</div>
))}
</dl>
<p className="mt-3 font-body text-sm text-text-secondary">
Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</span>
</p>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onBack} disabled={saving}>
Back
</Button>
<Button type="button" variant="primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
</Button>
</div>
</div>
)
}
export default ReviewStep

View File

@ -0,0 +1,9 @@
function Silhouette({ className = 'h-12 w-12' }) {
return (
<svg viewBox="0 0 64 64" className={`${className} text-text-secondary`} fill="currentColor">
<path d="M21 10c-2 0-3 3-3 7 0 3 1 6 3 8-4 2-7 6-7 12v8c0 3 2 5 5 5h26c3 0 5-2 5-5v-8c0-6-3-10-7-12 2-2 3-5 3-8 0-4-1-7-3-7-2 0-4 4-4 8 0 1 0 2 1 3-2-1-5-2-8-2s-6 1-8 2c1-1 1-2 1-3 0-4-2-8-4-8z" />
</svg>
)
}
export default Silhouette

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getSettings } from '../api'
// Reads the user's weight-display preference from the (React Query-cached)
// settings so any component can format weights consistently without prop
// drilling. The query key matches Settings.jsx's, so this shares one cached
// fetch rather than issuing its own. Defaults to decimal until settings load.
export function useWeightUnit() {
const { data } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
return data?.weight_unit ?? 'decimal_lbs'
}

View File

@ -0,0 +1,163 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Theme palettes. The Tailwind color tokens (tailwind.config.js) resolve to
these CSS variables, so setting `data-theme` on <html> re-themes the whole
app. `:root` is the default (Classic Cream) palette, also used when no/an
unknown data-theme is set. Each named theme overrides the same ten tokens.
Keep this list in sync with the registry in src/lib/theme.js. */
:root,
[data-theme='classic'] {
--color-surface: #f7f3ec;
--color-surface-raised: #ffffff;
--color-surface-sunken: #ede8de;
--color-border: #d4c9b4;
--color-text-primary: #1c1a16;
--color-text-secondary: #6b6354;
--color-accent: #4a6741;
--color-accent-hover: #3a5233;
--color-accent-warm: #c47d2e;
--color-danger: #b34040;
}
/* Dashboard stat-tile tokens. Defined once here on :root so EVERY theme
inherits them (theme blocks below don't redefine them), and they resolve
through the per-theme color tokens — so by default the eight tiles all read
as a plain raised surface, exactly like the rest of the app. The vibrant
`carnival` theme below overrides each tile with its own color. The tile
foreground vars let the colored variant flip to light text without touching
the global text tokens. Dashboard.jsx references these as literal
`bg-[var(--stat-N)]` classes (Tailwind only generates classes it can see as
complete strings, so the per-tile names are spelled out, not built up). */
:root {
--stat-fg: var(--color-text-primary);
--stat-fg-muted: var(--color-text-secondary);
--stat-1: var(--color-surface-raised);
--stat-2: var(--color-surface-raised);
--stat-3: var(--color-surface-raised);
--stat-4: var(--color-surface-raised);
--stat-5: var(--color-surface-raised);
--stat-6: var(--color-surface-raised);
--stat-7: var(--color-surface-raised);
--stat-8: var(--color-surface-raised);
--stat-1-hover: var(--color-surface-sunken);
--stat-2-hover: var(--color-surface-sunken);
--stat-3-hover: var(--color-surface-sunken);
--stat-4-hover: var(--color-surface-sunken);
--stat-5-hover: var(--color-surface-sunken);
--stat-6-hover: var(--color-surface-sunken);
--stat-7-hover: var(--color-surface-sunken);
--stat-8-hover: var(--color-surface-sunken);
}
/* Dark theme: accent-hover is *lighter* than accent (the opposite of the light
themes), since a darker hover would disappear against dark surfaces. */
[data-theme='midnight'] {
--color-surface: #1a1d1a;
--color-surface-raised: #242823;
--color-surface-sunken: #151814;
--color-border: #3a3f38;
--color-text-primary: #ece8e0;
--color-text-secondary: #a8a294;
--color-accent: #7fa56e;
--color-accent-hover: #94b884;
--color-accent-warm: #d9954a;
--color-danger: #d46a6a;
}
[data-theme='ocean'] {
--color-surface: #f0f5f8;
--color-surface-raised: #ffffff;
--color-surface-sunken: #e1eaf0;
--color-border: #c2d3de;
--color-text-primary: #14222b;
--color-text-secondary: #5a7484;
--color-accent: #2e6e8e;
--color-accent-hover: #245870;
--color-accent-warm: #e0913b;
--color-danger: #c0504d;
}
[data-theme='berry'] {
--color-surface: #f8f2f6;
--color-surface-raised: #ffffff;
--color-surface-sunken: #efe2ea;
--color-border: #dcc6d3;
--color-text-primary: #2a1a26;
--color-text-secondary: #7a6070;
--color-accent: #9b3b6e;
--color-accent-hover: #7e2d58;
--color-accent-warm: #c9762e;
--color-danger: #c0392b;
}
[data-theme='sunset'] {
--color-surface: #fbf3ec;
--color-surface-raised: #ffffff;
--color-surface-sunken: #f4e6da;
--color-border: #e6cdb8;
--color-text-primary: #2a1c14;
--color-text-secondary: #8a6f5c;
--color-accent: #c9542e;
--color-accent-hover: #a8431f;
--color-accent-warm: #e0913b;
--color-danger: #b33a3a;
}
/* Carnival: the deliberately loud theme. A clean light base lets the
multi-colored dashboard tiles (overridden below) pop. The stat tiles flip to
white text via --stat-fg. */
[data-theme='carnival'] {
--color-surface: #faf7fd;
--color-surface-raised: #ffffff;
--color-surface-sunken: #f0eaf7;
--color-border: #e2d9ec;
--color-text-primary: #1b1228;
--color-text-secondary: #6a5c7c;
--color-accent: #7c3aed;
--color-accent-hover: #6d28d9;
--color-accent-warm: #ea580c;
--color-danger: #e11d48;
--stat-fg: #ffffff;
--stat-fg-muted: rgba(255, 255, 255, 0.82);
--stat-1: #7c3aed; /* herd — violet */
--stat-2: #2563eb; /* bucks — blue */
--stat-3: #db2777; /* does — pink */
--stat-4: #ea580c; /* kits — orange */
--stat-5: #059669; /* for sale — green*/
--stat-6: #d97706; /* on hold — amber */
--stat-7: #0891b2; /* sold — cyan */
--stat-8: #475569; /* archived — slate*/
--stat-1-hover: #6d28d9;
--stat-2-hover: #1d4ed8;
--stat-3-hover: #be185d;
--stat-4-hover: #c2410c;
--stat-5-hover: #047857;
--stat-6-hover: #b45309;
--stat-7-hover: #0e7490;
--stat-8-hover: #334155;
}
/* `!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
keyboard focus invisible on exactly the elements that most need it. This
guarantees every focusable element gets a visible accent-colored ring on
keyboard focus, app-wide, without auditing each component individually. */
:focus-visible {
outline: 2px solid theme('colors.accent') !important;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -0,0 +1,55 @@
export const SEX_LABELS = {
buck: 'Buck',
doe: 'Doe',
unknown: 'Unknown',
}
export function formatAge(dob) {
if (!dob) return 'Age unknown'
const birth = new Date(`${dob}T00:00:00`)
if (Number.isNaN(birth.getTime())) return 'Age unknown'
const today = new Date()
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (today.getDate() < birth.getDate()) months -= 1
if (months < 0) {
years -= 1
months += 12
}
if (years < 0) return 'Age unknown'
if (years === 0 && months === 0) return '<1m'
if (years === 0) return `${months}m`
if (months === 0) return `${years}y`
return `${years}y ${months}m`
}
// Weight is always stored as decimal lbs; the `unit` argument (the user's
// `weight_unit` setting — see useWeightUnit) only controls display. lbs+oz
// rounds to the nearest ounce, carrying 16oz up to a pound so you never see
// "3 lb 16 oz".
export function formatWeight(weightLbs, unit = 'decimal_lbs') {
if (weightLbs === null || weightLbs === undefined || weightLbs === '') return null
const value = Number(weightLbs)
if (Number.isNaN(value)) return null
if (unit === 'lbs_oz') {
const totalOz = Math.round(value * 16)
const lbs = Math.floor(totalOz / 16)
const oz = totalOz % 16
return `${lbs} lb ${oz} oz`
}
return `${value.toFixed(2)} lbs`
}
export function formatDate(isoDate) {
if (!isoDate) return null
return new Date(`${isoDate}T00:00:00`).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}

View File

@ -0,0 +1,37 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatWeight, formatAge } from './format.js'
test('formatWeight: decimal lbs (default)', () => {
assert.equal(formatWeight(3.5), '3.50 lbs')
assert.equal(formatWeight(3), '3.00 lbs')
assert.equal(formatWeight(0), '0.00 lbs')
})
test('formatWeight: lbs + oz rounds to nearest ounce', () => {
assert.equal(formatWeight(3.5, 'lbs_oz'), '3 lb 8 oz')
assert.equal(formatWeight(3, 'lbs_oz'), '3 lb 0 oz')
assert.equal(formatWeight(0.25, 'lbs_oz'), '0 lb 4 oz')
})
test('formatWeight: lbs + oz carries 16 oz up to a pound', () => {
// 3.99 lbs = 63.84 oz -> rounds to 64 -> 4 lb 0 oz, never "3 lb 16 oz".
assert.equal(formatWeight(3.99, 'lbs_oz'), '4 lb 0 oz')
})
test('formatWeight: empty/invalid -> null', () => {
assert.equal(formatWeight(null), null)
assert.equal(formatWeight(undefined), null)
assert.equal(formatWeight(''), null)
assert.equal(formatWeight('abc'), null)
assert.equal(formatWeight('abc', 'lbs_oz'), null)
})
test('formatAge: missing/invalid dob', () => {
assert.equal(formatAge(null), 'Age unknown')
assert.equal(formatAge('not-a-date'), 'Age unknown')
})
test('formatAge: a clearly old dob reports whole years', () => {
assert.match(formatAge('2000-01-01'), /\d+y/)
})

View File

@ -0,0 +1,109 @@
export const LOCI = [
{
key: 'locus_a',
label: 'A Locus',
options: [
{ value: 'AA', label: 'AA Agouti' },
// Stored value is plain ASCII ('ata'); the superscript form is display-only.
{ value: 'ata', label: 'aᵗa Tan' },
{ value: 'aa', label: 'aa Self' },
],
},
{
key: 'locus_b',
label: 'B Locus',
options: [
{ value: 'BB', label: 'BB Black' },
{ value: 'Bb', label: 'Bb Black (carries chocolate)' },
{ value: 'bb', label: 'bb Chocolate' },
],
},
{
key: 'locus_c',
label: 'C Locus',
options: [
{ value: 'CC', label: 'CC Full Color' },
{ value: 'cchd', label: 'cchd Chinchilla' },
{ value: 'cchl', label: 'cchl Sable' },
{ value: 'cch', label: 'cch Himalayan' },
{ value: 'cc', label: 'cc REW' },
],
},
{
key: 'locus_d',
label: 'D Locus',
options: [
{ value: 'DD', label: 'DD Dense' },
{ value: 'Dd', label: 'Dd Dense (carries dilute)' },
{ value: 'dd', label: 'dd Dilute' },
],
},
{
key: 'locus_e',
label: 'E Locus',
options: [
{ value: 'ES', label: 'ES Steel' },
{ value: 'E', label: 'E Full Extension' },
{ value: 'ej', label: 'ej Harlequin' },
{ value: 'e', label: 'e Non-Extension' },
],
},
{
key: 'locus_en',
label: 'En Locus',
options: [
{ value: 'EnEn', label: 'EnEn Charlie' },
{ value: 'Enen', label: 'Enen Broken' },
{ value: 'enen', label: 'enen Solid' },
],
},
{
key: 'locus_v',
label: 'V Locus',
options: [
{ value: 'VV', label: 'VV Normal' },
{ value: 'Vv', label: 'Vv Carrier' },
{ value: 'vv', label: 'vv BEW' },
],
},
]
const A_LABELS = { AA: 'Agouti', ata: 'Tan Pattern', aa: 'Self' }
// Keyed by visible phenotype (dominant allele present or not), so carriers
// (Bb/Dd) resolve to the same color as the homozygous-dominant form.
const BD_LABELS = { 'B-D': 'Black', 'B-d': 'Blue', 'b-D': 'Chocolate', 'b-d': 'Lilac' }
const E_LABELS = { ES: 'Steel', ej: 'Harlequin', e: 'Non-Extension' }
const C_LABELS = { cchd: 'Chinchilla', cchl: 'Sable', cch: 'Himalayan', cc: 'REW' }
const EN_LABELS = { EnEn: 'Charlie', Enen: 'Broken' }
export function generateColorString(loci) {
const { locus_a, locus_b, locus_c, locus_d, locus_e, locus_en, locus_v } = loci
if (locus_v === 'vv') return 'BEW (Blue-Eyed White)'
const parts = []
if (locus_a && A_LABELS[locus_a]) parts.push(A_LABELS[locus_a])
if (locus_c === 'cc') {
parts.push('REW')
} else {
if (locus_b && locus_d) {
// Reduce each genotype to its phenotype: a dominant allele (uppercase)
// anywhere means the dominant trait shows, so Bb reads as B, Dd as D.
const bPheno = locus_b.includes('B') ? 'B' : 'b'
const dPheno = locus_d.includes('D') ? 'D' : 'd'
const colorLabel = BD_LABELS[`${bPheno}-${dPheno}`]
if (colorLabel) parts.push(colorLabel)
}
if (locus_e && E_LABELS[locus_e]) parts.push(E_LABELS[locus_e])
if (locus_c && C_LABELS[locus_c]) parts.push(C_LABELS[locus_c])
}
if (locus_en && EN_LABELS[locus_en]) parts.unshift(EN_LABELS[locus_en])
return parts.join(' ')
}
export function hasAnyLocusSet(loci) {
return LOCI.some((locus) => Boolean(loci[locus.key]))
}

View File

@ -0,0 +1,47 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { generateColorString, hasAnyLocusSet } from './genetics.js'
test('generateColorString: self black non-extension', () => {
assert.equal(
generateColorString({ locus_a: 'aa', locus_b: 'BB', locus_c: 'CC', locus_d: 'DD', locus_e: 'e' }),
'Self Black Non-Extension'
)
})
test('generateColorString: B+D combos map to base colors', () => {
assert.equal(generateColorString({ locus_b: 'BB', locus_d: 'dd' }), 'Blue')
assert.equal(generateColorString({ locus_b: 'bb', locus_d: 'DD' }), 'Chocolate')
assert.equal(generateColorString({ locus_b: 'bb', locus_d: 'dd' }), 'Lilac')
})
test('generateColorString: heterozygous carriers show the dominant color', () => {
// Bb carries chocolate but still shows black; Dd carries dilute but shows dense.
assert.equal(generateColorString({ locus_b: 'Bb', locus_d: 'Dd' }), 'Black')
assert.equal(generateColorString({ locus_b: 'Bb', locus_d: 'dd' }), 'Blue')
assert.equal(generateColorString({ locus_b: 'bb', locus_d: 'Dd' }), 'Chocolate')
})
test('generateColorString: vv short-circuits to BEW', () => {
assert.equal(
generateColorString({ locus_v: 'vv', locus_a: 'AA', locus_b: 'BB', locus_d: 'DD' }),
'BEW (Blue-Eyed White)'
)
})
test('generateColorString: cc short-circuits to REW, skipping B/D/E', () => {
assert.equal(generateColorString({ locus_c: 'cc', locus_b: 'BB', locus_d: 'DD', locus_e: 'e' }), 'REW')
})
test('generateColorString: En broken is prefixed', () => {
assert.equal(
generateColorString({ locus_a: 'AA', locus_b: 'BB', locus_d: 'DD', locus_en: 'Enen' }),
'Broken Agouti Black'
)
})
test('hasAnyLocusSet', () => {
assert.equal(hasAnyLocusSet({}), false)
assert.equal(hasAnyLocusSet({ locus_a: '' }), false)
assert.equal(hasAnyLocusSet({ locus_a: 'AA' }), true)
})

View File

@ -0,0 +1,28 @@
// Arrow-key roving focus for the `<ul><li><button>` search-results list used
// by both PedigreeTree's AncestorSlot and RabbitAutocomplete — they render
// the same search-dropdown shape, so the keyboard behavior is shared here
// rather than duplicated. Enter/click activation is native <button> behavior
// and needs no extra handling.
export function handleSearchInputKeyDown(e, { containerEl, close }) {
if (e.key === 'ArrowDown') {
e.preventDefault()
containerEl?.querySelector('ul button')?.focus()
} else if (e.key === 'Escape') {
close()
}
}
export function handleResultKeyDown(e, { containerEl, index, inputEl, close }) {
const buttons = Array.from(containerEl?.querySelectorAll('ul button') ?? [])
if (e.key === 'ArrowDown') {
e.preventDefault()
buttons[index + 1]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (index === 0) inputEl?.focus()
else buttons[index - 1]?.focus()
} else if (e.key === 'Escape') {
close()
}
}

View File

@ -0,0 +1,243 @@
// Pedigree tree slot definitions and chain-authority resolution helpers.
// Mirrors the backend's resolveAncestorNode in server/routes/rabbits.ts:
// a node is "locked" (cascade-derived) whenever its immediate parent resolved
// via a real FK; only the subject's sire_id/dam_id are real FK columns, so
// gen2+ nodes can only ever be saved as freeform `_name_text`.
export const GEN1 = [
{ path: ['sire'], slot: 'sire', label: 'Sire' },
{ path: ['dam'], slot: 'dam', label: 'Dam' },
]
export const GEN2 = [
{ path: ['sire', 'sire'], slot: 'paternal_grandsire', label: "Sire's Sire" },
{ path: ['sire', 'dam'], slot: 'paternal_granddam', label: "Sire's Dam" },
{ path: ['dam', 'sire'], slot: 'maternal_grandsire', label: "Dam's Sire" },
{ path: ['dam', 'dam'], slot: 'maternal_granddam', label: "Dam's Dam" },
]
export const GEN3 = [
{ path: ['sire', 'sire', 'sire'], slot: 'pp_grandsire', label: "Sire's Sire's Sire" },
{ path: ['sire', 'sire', 'dam'], slot: 'pp_granddam', label: "Sire's Sire's Dam" },
{ path: ['sire', 'dam', 'sire'], slot: 'pm_grandsire', label: "Sire's Dam's Sire" },
{ path: ['sire', 'dam', 'dam'], slot: 'pm_granddam', label: "Sire's Dam's Dam" },
{ path: ['dam', 'sire', 'sire'], slot: 'mp_grandsire', label: "Dam's Sire's Sire" },
{ path: ['dam', 'sire', 'dam'], slot: 'mp_granddam', label: "Dam's Sire's Dam" },
{ path: ['dam', 'dam', 'sire'], slot: 'mm_grandsire', label: "Dam's Dam's Sire" },
{ path: ['dam', 'dam', 'dam'], slot: 'mm_granddam', label: "Dam's Dam's Dam" },
]
export const ALL_SLOTS = [...GEN1, ...GEN2, ...GEN3]
const TEXT_COLUMN_BY_SLOT = {
sire: 'sire_name_text',
dam: 'dam_name_text',
paternal_grandsire: 'paternal_grandsire_name_text',
paternal_granddam: 'paternal_granddam_name_text',
maternal_grandsire: 'maternal_grandsire_name_text',
maternal_granddam: 'maternal_granddam_name_text',
pp_grandsire: 'pp_grandsire_name_text',
pp_granddam: 'pp_granddam_name_text',
pm_grandsire: 'pm_grandsire_name_text',
pm_granddam: 'pm_granddam_name_text',
mp_grandsire: 'mp_grandsire_name_text',
mp_granddam: 'mp_granddam_name_text',
mm_grandsire: 'mm_grandsire_name_text',
mm_granddam: 'mm_granddam_name_text',
}
// Manually-entered ancestors capture more than a bare name (Farm, Ear Number,
// Weight, DOB), but each `_name_text` column is still a single text field, so
// the extra fields are persisted as a JSON string in that same column. Only
// Name/Farm/Ear Number are meaningful for the user to fill in; Weight and DOB
// default to placeholders ancestor records never had real values for.
const MANUAL_ANCESTOR_DEFAULTS = { weight_lbs: 0, dob: 'Unknown' }
export function serializeManualAncestor({ name, farm, ear_number, weight_lbs, dob }) {
return JSON.stringify({
name: name?.trim() || '',
farm: farm?.trim() || '',
ear_number: ear_number?.trim() || '',
weight_lbs:
weight_lbs === '' || weight_lbs === null || weight_lbs === undefined
? MANUAL_ANCESTOR_DEFAULTS.weight_lbs
: Number(weight_lbs),
dob: dob?.trim() || MANUAL_ANCESTOR_DEFAULTS.dob,
})
}
// Accepts either a structured manual-ancestor JSON string or a legacy plain
// freeform name (predates this fields, or hand-edited in the DB) and always
// returns the full field set with defaults applied.
export function parseManualAncestor(text) {
if (!text) return null
try {
const parsed = JSON.parse(text)
if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') {
return {
name: parsed.name,
farm: parsed.farm ?? '',
ear_number: parsed.ear_number ?? '',
weight_lbs: parsed.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs,
dob: parsed.dob ?? MANUAL_ANCESTOR_DEFAULTS.dob,
}
}
} catch {
// Not JSON — a legacy plain-text entry; fall through to treat it as just a name.
}
return {
name: text,
farm: '',
ear_number: '',
weight_lbs: MANUAL_ANCESTOR_DEFAULTS.weight_lbs,
dob: MANUAL_ANCESTOR_DEFAULTS.dob,
}
}
export function formatManualAncestorDisplay(text) {
const parsed = parseManualAncestor(text)
if (!parsed) return null
let label = parsed.name
if (parsed.farm) label += ` (${parsed.farm})`
if (parsed.ear_number) label += ` • Ear #${parsed.ear_number}`
return label
}
export function emptyNodeState() {
return { mode: 'unknown', id: null, name: null }
}
export function createInitialPedigreeState() {
const state = {}
for (const { slot } of ALL_SLOTS) state[slot] = emptyNodeState()
return state
}
export function seedPedigreeStateFromRabbit(rabbit, ancestors) {
const state = createInitialPedigreeState()
state.sire = rabbit.sire_id
? { mode: 'linked', id: rabbit.sire_id, name: ancestors?.sire?.name ?? null }
: rabbit.sire_name_text
? { mode: 'manual', id: null, name: rabbit.sire_name_text }
: emptyNodeState()
state.dam = rabbit.dam_id
? { mode: 'linked', id: rabbit.dam_id, name: ancestors?.dam?.name ?? null }
: rabbit.dam_name_text
? { mode: 'manual', id: null, name: rabbit.dam_name_text }
: emptyNodeState()
for (const { slot } of [...GEN2, ...GEN3]) {
const text = rabbit[TEXT_COLUMN_BY_SLOT[slot]]
state[slot] = text ? { mode: 'manual', id: null, name: text } : emptyNodeState()
}
return state
}
export function cascadeFromAncestors(ancestors) {
const cache = new Map()
if (ancestors?.sire?.id != null) cache.set(ancestors.sire.id, ancestors.sire)
if (ancestors?.dam?.id != null) cache.set(ancestors.dam.id, ancestors.dam)
return cache
}
function findSlotEntry(path) {
const key = path.join('.')
return ALL_SLOTS.find((entry) => entry.path.join('.') === key)
}
// Walks `path` from the root, deriving each node either from the subject's
// own editable state (chain not yet broken-resolved above it) or, once an
// ancestor up the chain resolved via FK, from that ancestor's own cached
// AncestorNode subtree. `subtree` on the returned node is the AncestorNode
// to recurse into for *its* children, or undefined while a fetch is pending.
export function resolveEffectiveNode(path, state, cascadeCache) {
const { slot } = findSlotEntry(path)
if (path.length === 1) {
const own = state[slot]
return {
locked: false,
loading: false,
mode: own.mode,
id: own.id,
name: own.name,
subtree: own.mode === 'linked' && own.id != null ? cascadeCache.get(own.id) : undefined,
}
}
const parent = resolveEffectiveNode(path.slice(0, -1), state, cascadeCache)
const direction = path[path.length - 1]
if (parent.mode === 'linked' && parent.id != null) {
if (parent.subtree === undefined) {
return { locked: true, loading: true, mode: 'unknown', id: null, name: null, subtree: undefined }
}
const child = parent.subtree ? parent.subtree[direction] : null
return {
locked: true,
loading: false,
mode: child ? (child.id != null ? 'linked' : 'manual') : 'unknown',
id: child?.id ?? null,
name: child?.name ?? null,
subtree: child ?? null,
}
}
const own = state[slot]
return {
locked: false,
loading: false,
mode: own.mode,
id: own.id,
name: own.name,
subtree: own.mode === 'linked' && own.id != null ? cascadeCache.get(own.id) : undefined,
}
}
export function buildPedigreeSummary(state, cascadeCache) {
return ALL_SLOTS.map(({ path, slot, label }) => {
const eff = resolveEffectiveNode(path, state, cascadeCache)
const display = eff.loading
? 'Loading…'
: eff.mode === 'unknown'
? 'Unknown'
: eff.mode === 'manual'
? formatManualAncestorDisplay(eff.name)
: eff.name
return { slot, label, display }
})
}
// Walks a `GET /api/rabbits/:id/ancestors` response tree along `path`
// (e.g. ['sire', 'sire']). Returns the AncestorNode at that position, or null
// if the chain breaks (either backend "Unknown" or a missing intermediate
// node) before `path` is fully consumed.
export function getAncestorAtPath(ancestors, path) {
let node = ancestors
for (const direction of path) {
if (!node) return null
node = node[direction]
}
return node ?? null
}
export function buildPedigreePayload(state, cascadeCache) {
const payload = { sire_id: null, dam_id: null }
for (const slot of Object.keys(TEXT_COLUMN_BY_SLOT)) {
payload[TEXT_COLUMN_BY_SLOT[slot]] = null
}
for (const { path, slot } of ALL_SLOTS) {
const eff = resolveEffectiveNode(path, state, cascadeCache)
if (eff.locked) continue // dead column on the subject — chain is intact, leave null
if (slot === 'sire' || slot === 'dam') {
if (eff.mode === 'linked' && eff.id != null) payload[`${slot}_id`] = eff.id
else if (eff.name) payload[TEXT_COLUMN_BY_SLOT[slot]] = eff.name
} else if (eff.name) {
payload[TEXT_COLUMN_BY_SLOT[slot]] = eff.name
}
}
return payload
}

View File

@ -0,0 +1,71 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
createInitialPedigreeState,
buildPedigreePayload,
serializeManualAncestor,
parseManualAncestor,
formatManualAncestorDisplay,
} from './pedigree.js'
test('manual ancestor serialize/parse round-trips', () => {
const json = serializeManualAncestor({
name: 'Old Bob',
farm: 'Sunnyside',
ear_number: 'B-12',
weight_lbs: 4.5,
dob: 'Unknown',
})
const parsed = parseManualAncestor(json)
assert.equal(parsed.name, 'Old Bob')
assert.equal(parsed.farm, 'Sunnyside')
assert.equal(parsed.ear_number, 'B-12')
assert.equal(parsed.weight_lbs, 4.5)
assert.equal(parsed.dob, 'Unknown')
})
test('parseManualAncestor accepts legacy plain-text names', () => {
const parsed = parseManualAncestor('Just A Name')
assert.equal(parsed.name, 'Just A Name')
assert.equal(parsed.farm, '')
assert.equal(parsed.ear_number, '')
})
test('formatManualAncestorDisplay composes name, farm, ear number', () => {
const json = serializeManualAncestor({ name: 'Old Bob', farm: 'Sunnyside', ear_number: 'B-12' })
assert.equal(formatManualAncestorDisplay(json), 'Old Bob (Sunnyside) • Ear #B-12')
assert.equal(formatManualAncestorDisplay(serializeManualAncestor({ name: 'Solo' })), 'Solo')
})
test('buildPedigreePayload: empty tree -> all null', () => {
const payload = buildPedigreePayload(createInitialPedigreeState(), new Map())
assert.equal(payload.sire_id, null)
assert.equal(payload.dam_id, null)
assert.equal(payload.paternal_grandsire_name_text, null)
})
test('buildPedigreePayload: a linked parent writes the FK and locks (nulls) its ancestors', () => {
const state = createInitialPedigreeState()
state.sire = { mode: 'linked', id: 5, name: 'Jimmy' }
// Cascade cache holds Jimmy's own (empty) ancestry subtree.
const cache = new Map([[5, { id: 5, name: 'Jimmy', sire: null, dam: null }]])
const payload = buildPedigreePayload(state, cache)
assert.equal(payload.sire_id, 5)
assert.equal(payload.sire_name_text, null)
// Gen2 paternal slots are chain-derived from the linked sire, so they must
// stay null on the subject rather than being written as freeform text.
assert.equal(payload.paternal_grandsire_name_text, null)
assert.equal(payload.paternal_granddam_name_text, null)
})
test('buildPedigreePayload: a manual parent leaves deeper slots editable and persisted', () => {
const state = createInitialPedigreeState()
state.sire = { mode: 'manual', id: null, name: 'Bob' }
state.paternal_grandsire = { mode: 'manual', id: null, name: 'Old Bob' }
const payload = buildPedigreePayload(state, new Map())
assert.equal(payload.sire_id, null)
assert.equal(payload.sire_name_text, 'Bob')
assert.equal(payload.paternal_grandsire_name_text, 'Old Bob')
})

View File

@ -0,0 +1,92 @@
// Theme registry. The actual palettes live as CSS variables in src/index.css
// (one `[data-theme='…']` block per theme); this module is the single source
// of truth for the *list* of themes, their labels, the small preview swatches
// the Settings picker renders, and the browser chrome `theme-color`.
//
// Persistence: the chosen theme is stored in the backend `settings` table
// (key `theme`) so it syncs across devices. localStorage mirrors it only as a
// fast-path cache so the correct theme can be applied before the settings
// request resolves (avoiding a flash of the default theme on load).
export const DEFAULT_THEME = 'classic'
export const THEME_STORAGE_KEY = 'mykindlebox-theme'
// `themeColor` drives the <meta name="theme-color"> (PWA / mobile browser
// chrome). `swatch` is just for the Settings preview tiles — the live colors
// always come from the CSS variables, these are only an at-a-glance preview.
export const THEMES = [
{
id: 'classic',
label: 'Classic Cream',
description: 'The original warm, earthy look.',
themeColor: '#4a6741',
swatch: { surface: '#f7f3ec', accent: '#4a6741', warm: '#c47d2e' },
},
{
id: 'midnight',
label: 'Midnight',
description: 'Easy-on-the-eyes dark mode.',
themeColor: '#1a1d1a',
swatch: { surface: '#242823', accent: '#7fa56e', warm: '#d9954a' },
},
{
id: 'ocean',
label: 'Ocean',
description: 'Cool blues and teal.',
themeColor: '#2e6e8e',
swatch: { surface: '#f0f5f8', accent: '#2e6e8e', warm: '#e0913b' },
},
{
id: 'berry',
label: 'Berry',
description: 'Bold plum and magenta.',
themeColor: '#9b3b6e',
swatch: { surface: '#f8f2f6', accent: '#9b3b6e', warm: '#c9762e' },
},
{
id: 'sunset',
label: 'Sunset',
description: 'Warm coral and amber.',
themeColor: '#c9542e',
swatch: { surface: '#fbf3ec', accent: '#c9542e', warm: '#e0913b' },
},
{
id: 'carnival',
label: 'Carnival',
description: 'Bright multi-color dashboard.',
themeColor: '#7c3aed',
swatch: { surface: '#7c3aed', accent: '#db2777', warm: '#ea580c' },
},
]
const THEME_IDS = new Set(THEMES.map((t) => t.id))
export function isValidTheme(id) {
return typeof id === 'string' && THEME_IDS.has(id)
}
// Falls back to the default for unknown/missing ids so a stray DB value or an
// old cached id can never leave the app unstyled.
export function normalizeTheme(id) {
return isValidTheme(id) ? id : DEFAULT_THEME
}
// Applies a theme to the document: sets the data-theme attribute (which swaps
// the CSS variables), updates the browser-chrome theme-color meta, and mirrors
// the choice to localStorage for next load's flash-free start.
export function applyTheme(id) {
const theme = normalizeTheme(id)
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = theme
const meta = document.querySelector('meta[name="theme-color"]')
const entry = THEMES.find((t) => t.id === theme)
if (meta && entry) meta.setAttribute('content', entry.themeColor)
}
try {
localStorage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// localStorage can throw in private mode / when disabled — non-fatal,
// the backend setting remains the source of truth.
}
}

View File

@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
})
}

View File

@ -0,0 +1,67 @@
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import LitterForm from '../components/LitterForm'
import { getRabbit, createLitter } from '../api'
function useParentFromQuery(id) {
return useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
enabled: Boolean(id),
})
}
// Records a breeding (the pairing event) as a litter-in-progress: a litter row
// with a breed date but no kindle date or kits yet. Reached from a rabbit's page
// with the started-from rabbit pre-filled as sire or dam via query params.
function AddBreeding() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const sireId = searchParams.get('sire')
const damId = searchParams.get('dam')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const { data: sireRabbit, isLoading: sireLoading } = useParentFromQuery(sireId)
const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId)
if ((sireId && sireLoading) || (damId && damLoading)) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Record Breeding</h1>
}
async function handleSubmit(payload) {
setSaving(true)
setError(null)
try {
const created = await createLitter(payload)
navigate(`/litters/${created.id}`)
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div>
<PageHeader title="Record Breeding" />
<div className="px-6 pb-6">
<LitterForm
mode="breeding"
initialValues={{
sire: sireRabbit ? { id: sireRabbit.id, name: sireRabbit.name } : null,
dam: damRabbit ? { id: damRabbit.id, name: damRabbit.name } : null,
}}
onSubmit={handleSubmit}
onCancel={() => navigate('/litters')}
saving={saving}
error={error}
submitLabel="Save Breeding"
/>
</div>
</div>
)
}
export default AddBreeding

View File

@ -0,0 +1,63 @@
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import LitterForm from '../components/LitterForm'
import { getRabbit, createLitter } from '../api'
function useParentFromQuery(id) {
return useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
enabled: Boolean(id),
})
}
function AddLitter() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const sireId = searchParams.get('sire')
const damId = searchParams.get('dam')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const { data: sireRabbit, isLoading: sireLoading } = useParentFromQuery(sireId)
const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId)
if ((sireId && sireLoading) || (damId && damLoading)) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Record Litter</h1>
}
async function handleSubmit(payload) {
setSaving(true)
setError(null)
try {
const created = await createLitter(payload)
navigate(`/litters/${created.id}`)
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div>
<PageHeader title="Record Litter" />
<div className="px-6 pb-6">
<LitterForm
initialValues={{
sire: sireRabbit ? { id: sireRabbit.id, name: sireRabbit.name } : null,
dam: damRabbit ? { id: damRabbit.id, name: damRabbit.name } : null,
}}
onSubmit={handleSubmit}
onCancel={() => navigate('/litters')}
saving={saving}
error={error}
submitLabel="Save Litter"
/>
</div>
</div>
)
}
export default AddLitter

View File

@ -0,0 +1,183 @@
import { useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import PedigreeTree from '../components/PedigreeTree'
import RabbitDetailsForm from '../components/RabbitDetailsForm'
import GeneticsForm from '../components/GeneticsForm'
import ReviewStep from '../components/ReviewStep'
import Button from '../components/Button'
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
import { createInitialPedigreeState } from '../lib/pedigree'
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
const INITIAL_DETAILS = {
name: '',
farm: '',
sex: 'unknown',
dob: '',
weight_lbs: null,
ear_number: '',
location: '',
status: 'active',
color_name: '',
}
const INITIAL_GENETICS = {
locus_a: null,
locus_b: null,
locus_c: null,
locus_d: null,
locus_e: null,
locus_en: null,
locus_v: null,
}
function AddRabbit() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const sireId = searchParams.get('sire')
const damId = searchParams.get('dam')
const prefillDob = searchParams.get('dob')
const [step, setStep] = useState(1)
const [details, setDetails] = useState(() => ({
...INITIAL_DETAILS,
dob: prefillDob ?? INITIAL_DETAILS.dob,
}))
const [genetics, setGenetics] = useState(INITIAL_GENETICS)
const [pedigreeFields, setPedigreeFields] = useState({})
const [pedigreeSummary, setPedigreeSummary] = useState([])
const [photoFile, setPhotoFile] = useState(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const farmDefaulted = useRef(false)
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
queryKey: ['rabbit', sireId],
queryFn: () => getRabbit(sireId),
enabled: Boolean(sireId),
})
const { data: damRabbit, isLoading: damLoading } = useQuery({
queryKey: ['rabbit', damId],
queryFn: () => getRabbit(damId),
enabled: Boolean(damId),
})
if (settings && !farmDefaulted.current) {
farmDefaulted.current = true
if (settings.rabbitry_name) {
setDetails((prev) => (prev.farm ? prev : { ...prev, farm: settings.rabbitry_name }))
}
}
// Coming from a litter's "Add Kit" button — the sire/dam must finish
// loading before PedigreeTree mounts, since it only reads `initialState`
// once, at mount (same constraint EditRabbit.jsx works around).
if ((sireId && sireLoading) || (damId && damLoading)) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Add Rabbit</h1>
}
const pedigreeInitialState =
sireId || damId
? {
...createInitialPedigreeState(),
...(sireRabbit ? { sire: { mode: 'linked', id: sireRabbit.id, name: sireRabbit.name } } : {}),
...(damRabbit ? { dam: { mode: 'linked', id: damRabbit.id, name: damRabbit.name } } : {}),
}
: undefined
function updateDetails(partial) {
setDetails((prev) => ({ ...prev, ...partial }))
}
function updateGenetics(partial) {
setGenetics((prev) => ({ ...prev, ...partial }))
}
async function handleSave() {
setSaving(true)
setError(null)
try {
const created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
if (photoFile) {
await uploadRabbitPhoto(created.id, photoFile)
}
navigate(`/herd/${created.id}`)
} catch (err) {
setError(err.message)
setSaving(false)
}
}
const step2Valid = details.name.trim().length > 0
const headerAction =
step === 1 ? (
<Button type="button" onClick={() => setStep(2)}>
Next
</Button>
) : step === 2 ? (
<div className="flex gap-3">
<Button type="button" variant="secondary" onClick={() => setStep(1)}>
Back
</Button>
<Button type="button" onClick={() => setStep(3)} disabled={!step2Valid}>
Next
</Button>
</div>
) : null
return (
<div>
<PageHeader
title="Add Rabbit"
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
action={headerAction}
/>
<div className="px-6 pb-6">
{step === 1 && (
<PedigreeTree
initialState={pedigreeInitialState}
onChange={(fields, summary) => {
setPedigreeFields(fields)
setPedigreeSummary(summary)
}}
/>
)}
{step === 2 && (
<div className="space-y-6">
<RabbitDetailsForm
value={details}
onChange={updateDetails}
onPhotoChange={setPhotoFile}
weightUnit={settings?.weight_unit}
showValidation={!step2Valid}
/>
<GeneticsForm value={genetics} onChange={updateGenetics} defaultExpanded={false} />
</div>
)}
{step === 3 && (
<div>
{error && <p className="mb-4 font-body text-sm text-danger">{error}</p>}
<ReviewStep
details={details}
pedigreeSummary={pedigreeSummary}
genetics={genetics}
onBack={() => setStep(2)}
onSave={handleSave}
saving={saving}
/>
</div>
)}
</div>
</div>
)
}
export default AddRabbit

View File

@ -0,0 +1,215 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getStats, getLitters, getRabbits } from '../api'
import Card from '../components/Card'
import Button from '../components/Button'
import EmptyState from '../components/EmptyState'
const BIRTHDAY_WINDOW_DAYS = 30
// `tile` carries literal Tailwind classes (not built up dynamically — Tailwind
// only generates classes it can see as complete strings) that bind each tile to
// a --stat-N / --stat-N-hover CSS variable. Those variables default to a plain
// raised surface in every theme (see index.css :root) and only turn colorful in
// the `carnival` theme, so the dashboard re-themes with zero conditional logic.
const STAT_CARDS = [
{ key: 'herd', label: 'Herd', href: '/herd', tile: 'bg-[var(--stat-1)] hover:bg-[var(--stat-1-hover)]' },
{ key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', tile: 'bg-[var(--stat-2)] hover:bg-[var(--stat-2-hover)]' },
{ key: 'does', label: 'Does', href: '/herd?sex=doe', tile: 'bg-[var(--stat-3)] hover:bg-[var(--stat-3-hover)]' },
{ key: 'kits', label: 'Kits', href: '/herd?sex=kits', tile: 'bg-[var(--stat-4)] hover:bg-[var(--stat-4-hover)]' },
{ key: 'for_sale', label: 'For Sale', href: '/herd?status=for_sale', tile: 'bg-[var(--stat-5)] hover:bg-[var(--stat-5-hover)]' },
{ key: 'on_hold', label: 'On Hold', href: '/herd?status=on_hold', tile: 'bg-[var(--stat-6)] hover:bg-[var(--stat-6-hover)]' },
{ key: 'sold', label: 'Sold', href: '/herd?status=sold', tile: 'bg-[var(--stat-7)] hover:bg-[var(--stat-7-hover)]' },
{ key: 'archived', label: 'Archived', href: '/herd?status=archived', tile: 'bg-[var(--stat-8)] hover:bg-[var(--stat-8-hover)]' },
]
function formatDate(isoDate) {
return new Date(`${isoDate}T00:00:00`).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
function StatsBar() {
const { data, isLoading, isError } = useQuery({ queryKey: ['stats'], queryFn: getStats })
if (isError) {
return (
<p className="font-body text-sm text-danger">Failed to load dashboard stats.</p>
)
}
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{STAT_CARDS.map((card) =>
isLoading ? (
<Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" />
) : (
<Link key={card.key} to={card.href}>
<Card className={`flex flex-col gap-1 p-4 transition-colors ${card.tile}`}>
<span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]">
{card.label}
</span>
<span className="font-display text-2xl text-[var(--stat-fg)]">
{data[card.key]}
</span>
</Card>
</Link>
)
)}
</div>
)
}
function QuickActions() {
return (
<div className="flex flex-wrap gap-3">
<Link to="/herd/add">
<Button variant="primary">Add Rabbit</Button>
</Link>
<Link to="/litters/add">
<Button variant="secondary">Record Litter</Button>
</Link>
<Link to="/herd">
<Button variant="secondary">View Herd</Button>
</Link>
</div>
)
}
function RecentLitters() {
const { data, isLoading, isError } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
if (isLoading || isError) return null
const recent = [...data]
.sort((a, b) => new Date(b.kindle_date) - new Date(a.kindle_date))
.slice(0, 3)
if (recent.length === 0) {
return (
<Card>
<EmptyState
title="No litters yet"
description="Record a litter to see it here."
/>
</Card>
)
}
return (
<Card className="divide-y divide-border">
{recent.map((litter) => (
<Link
key={litter.id}
to={`/litters/${litter.id}`}
className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-surface-sunken"
>
<div>
<p className="font-body text-sm font-medium text-text-primary">
{litter.sire?.name ?? 'Unknown'} × {litter.dam?.name ?? 'Unknown'}
</p>
<p className="font-body text-sm text-text-secondary">
{formatDate(litter.kindle_date)}
</p>
</div>
<span className="font-body text-sm text-text-secondary">
{litter.kit_count} {litter.kit_count === 1 ? 'kit' : 'kits'}
</span>
</Link>
))}
</Card>
)
}
function getUpcomingBirthdays(rabbits, windowDays = BIRTHDAY_WINDOW_DAYS) {
const today = new Date()
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
return rabbits
.filter((rabbit) => rabbit.dob)
.map((rabbit) => {
const dob = new Date(`${rabbit.dob}T00:00:00`)
let next = new Date(startOfToday.getFullYear(), dob.getMonth(), dob.getDate())
if (next < startOfToday) next = new Date(startOfToday.getFullYear() + 1, dob.getMonth(), dob.getDate())
const daysUntil = Math.round((next - startOfToday) / 86400000)
const turningAge = next.getFullYear() - dob.getFullYear()
return { rabbit, nextDate: next, daysUntil, turningAge }
})
.filter((entry) => entry.daysUntil <= windowDays)
.sort((a, b) => a.daysUntil - b.daysUntil)
}
function formatMonthDay(date) {
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
}
function UpcomingBirthdays() {
const { data, isLoading, isError } = useQuery({
queryKey: ['rabbits', { status: 'active' }],
queryFn: () => getRabbits({ status: 'active' }),
})
if (isLoading || isError) return null
const upcoming = getUpcomingBirthdays(data)
if (upcoming.length === 0) {
return (
<Card>
<EmptyState
title="No upcoming birthdays"
description={`No active rabbits have a birthday in the next ${BIRTHDAY_WINDOW_DAYS} days.`}
/>
</Card>
)
}
return (
<Card className="divide-y divide-border">
{upcoming.map(({ rabbit, nextDate, daysUntil, turningAge }) => (
<Link
key={rabbit.id}
to={`/herd/${rabbit.id}`}
className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-surface-sunken"
>
<div>
<p className="font-body text-sm font-medium text-text-primary">{rabbit.name}</p>
<p className="font-body text-sm text-text-secondary">
{formatMonthDay(nextDate)} · turning {turningAge}
</p>
</div>
<span className="font-body text-sm text-text-secondary">
{daysUntil === 0 ? 'Today' : daysUntil === 1 ? 'Tomorrow' : `in ${daysUntil} days`}
</span>
</Link>
))}
</Card>
)
}
function Dashboard() {
return (
<div className="flex flex-col gap-8">
<section>
<StatsBar />
</section>
<section>
<QuickActions />
</section>
<section className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<h2 className="font-display text-lg text-text-primary">Recent Litters</h2>
<RecentLitters />
</div>
<div className="flex flex-col gap-3">
<h2 className="font-display text-lg text-text-primary">Upcoming Birthdays</h2>
<UpcomingBirthdays />
</div>
</section>
</div>
)
}
export default Dashboard

View File

@ -0,0 +1,59 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import LitterForm from '../components/LitterForm'
import { getLitter, updateLitter } from '../api'
function EditLitter() {
const { id } = useParams()
const navigate = useNavigate()
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const { data: litter, isLoading } = useQuery({
queryKey: ['litter', id],
queryFn: () => getLitter(id),
})
if (isLoading || !litter) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Edit Litter</h1>
}
async function handleSubmit(payload) {
setSaving(true)
setError(null)
try {
await updateLitter(id, payload)
navigate(`/litters/${id}`)
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div>
<PageHeader title="Edit Litter" />
<div className="px-6 pb-6">
<LitterForm
initialValues={{
sire: litter.sire,
dam: litter.dam,
breed_date: litter.breed_date,
kindle_date: litter.kindle_date,
kit_count: litter.kit_count,
notes: litter.notes,
}}
onSubmit={handleSubmit}
onCancel={() => navigate(`/litters/${id}`)}
saving={saving}
error={error}
submitLabel="Save Changes"
/>
</div>
</div>
)
}
export default EditLitter

View File

@ -0,0 +1,121 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import RabbitDetailsForm from '../components/RabbitDetailsForm'
import GeneticsForm from '../components/GeneticsForm'
import Button from '../components/Button'
import { getRabbit, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
import { LOCI, hasAnyLocusSet } from '../lib/genetics'
const DETAIL_KEYS = [
'name',
'farm',
'sex',
'dob',
'weight_lbs',
'ear_number',
'location',
'status',
'color_name',
]
const GENETICS_KEYS = LOCI.map((locus) => locus.key)
function EditRabbit() {
const { id } = useParams()
const navigate = useNavigate()
const [details, setDetails] = useState(null)
const [genetics, setGenetics] = useState(null)
const [photoFile, setPhotoFile] = useState(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const { data: rabbit, isLoading: rabbitLoading } = useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
})
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
if (rabbit && details === null) {
const initialDetails = {}
for (const key of DETAIL_KEYS) initialDetails[key] = rabbit[key]
setDetails(initialDetails)
const initialGenetics = {}
for (const key of GENETICS_KEYS) initialGenetics[key] = rabbit[key]
setGenetics(initialGenetics)
}
if (rabbitLoading || !rabbit || details === null || genetics === null) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Edit Rabbit</h1>
}
function updateDetails(partial) {
setDetails((prev) => ({ ...prev, ...partial }))
}
function updateGenetics(partial) {
setGenetics((prev) => ({ ...prev, ...partial }))
}
async function handleSave() {
setSaving(true)
setError(null)
const changed = {}
for (const key of DETAIL_KEYS) {
if (details[key] !== rabbit[key]) changed[key] = details[key]
}
for (const key of GENETICS_KEYS) {
if (genetics[key] !== rabbit[key]) changed[key] = genetics[key]
}
try {
if (Object.keys(changed).length > 0) {
await updateRabbit(id, changed)
}
if (photoFile) {
await uploadRabbitPhoto(id, photoFile)
}
navigate(`/herd/${id}`)
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div>
<PageHeader title="Edit Rabbit" subtitle={rabbit.name} />
<div className="space-y-6 px-6 pb-6">
<RabbitDetailsForm
value={details}
onChange={updateDetails}
onPhotoChange={setPhotoFile}
weightUnit={settings?.weight_unit}
/>
<GeneticsForm
value={genetics}
onChange={updateGenetics}
defaultExpanded={hasAnyLocusSet(genetics)}
/>
{error && <p className="font-body text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => navigate(`/herd/${id}`)} disabled={saving}>
Cancel
</Button>
<Button type="button" variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Changes'}
</Button>
</div>
</div>
</div>
)
}
export default EditRabbit

View File

@ -0,0 +1,5 @@
function Genetics() {
return <h1>Genetics Calculator</h1>
}
export default Genetics

View File

@ -0,0 +1,349 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getRabbits } from '../api'
import Card from '../components/Card'
import Badge from '../components/Badge'
import EmptyState from '../components/EmptyState'
import PageHeader from '../components/PageHeader'
import Button from '../components/Button'
import RabbitCard from '../components/RabbitCard'
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
const SEX_TABS = [
{ value: '', label: 'All' },
{ value: 'buck', label: 'Bucks' },
{ value: 'doe', label: 'Does' },
{ value: 'kits', label: 'Kits' },
{ value: 'unknown', label: 'Other' },
]
const SORT_OPTIONS = [
{ value: 'name', label: 'Name', apiSort: 'name' },
{ value: 'age', label: 'Age', apiSort: 'dob' },
{ value: 'recent', label: 'Recent', apiSort: 'created_at' },
{ value: 'weight', label: 'Weight', apiSort: 'weight_lbs' },
]
// The Herd page defaults to the active herd so it matches the Dashboard's "Herd"
// count and doesn't bury working animals under archived/sold/reference records.
// 'archived' (other + deceased) and 'all' (no status filter) are handled
// specially in the query below.
const DEFAULT_STATUS = 'active'
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'for_sale', label: 'For Sale' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'sold', label: 'Sold' },
{ value: 'archived', label: 'Archived' },
{ value: 'all', label: 'All' },
]
const SEARCH_DEBOUNCE_MS = 300
function SearchIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="9" cy="9" r="6" />
<path d="M17.5 17.5 13.5 13.5" strokeLinecap="round" />
</svg>
)
}
function GridIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
<rect x="2" y="2" width="7" height="7" rx="1" />
<rect x="11" y="2" width="7" height="7" rx="1" />
<rect x="2" y="11" width="7" height="7" rx="1" />
<rect x="11" y="11" width="7" height="7" rx="1" />
</svg>
)
}
function ListIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
<rect x="2" y="3" width="16" height="3" rx="1" />
<rect x="2" y="8.5" width="16" height="3" rx="1" />
<rect x="2" y="14" width="16" height="3" rx="1" />
</svg>
)
}
function SearchBox({ q, onSearchChange }) {
const [searchInput, setSearchInput] = useState(q)
useEffect(() => {
const timeout = setTimeout(() => {
if (searchInput !== q) onSearchChange(searchInput)
}, SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput])
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5">
<SearchIcon />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search by name"
className="w-40 bg-transparent font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
</div>
)
}
function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, onSortChange, onSearchChange, onViewChange }) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-1 rounded-md border border-border bg-surface-raised p-1">
{SEX_TABS.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => onSexChange(tab.value)}
className={`rounded-md px-3 py-1.5 font-body text-sm transition-colors ${
sex === tab.value
? 'bg-accent text-surface'
: 'text-text-primary hover:bg-surface-sunken'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<SearchBox key={q} q={q} onSearchChange={onSearchChange} />
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
Status: {option.label}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
Sort: {option.label}
</option>
))}
</select>
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
<button
type="button"
aria-label="Grid view"
onClick={() => onViewChange('grid')}
className={`rounded-md p-1.5 transition-colors ${
view === 'grid' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
}`}
>
<GridIcon />
</button>
<button
type="button"
aria-label="List view"
onClick={() => onViewChange('list')}
className={`rounded-md p-1.5 transition-colors ${
view === 'list' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
}`}
>
<ListIcon />
</button>
</div>
</div>
</div>
)
}
function GridSkeleton() {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="overflow-hidden">
<div className="aspect-square animate-pulse bg-surface-sunken" />
<div className="flex flex-col gap-2 p-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-surface-sunken" />
<div className="h-3 w-1/2 animate-pulse rounded bg-surface-sunken" />
</div>
</Card>
))}
</div>
)
}
function ListSkeleton() {
return (
<Card className="divide-y divide-border overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="h-4 w-full animate-pulse rounded bg-surface-sunken" />
</div>
))}
</Card>
)
}
function RabbitGrid({ rabbits }) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{rabbits.map((rabbit) => (
<RabbitCard key={rabbit.id} rabbit={rabbit} />
))}
</div>
)
}
function RabbitListView({ rabbits }) {
const weightUnit = useWeightUnit()
return (
<Card className="overflow-x-auto">
<table className="w-full font-body text-sm">
<thead>
<tr className="border-b border-border text-left text-text-secondary">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Sex</th>
<th className="px-4 py-2 font-medium">Color</th>
<th className="px-4 py-2 font-medium">Age</th>
<th className="px-4 py-2 font-medium">Weight</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{rabbits.map((rabbit, i) => (
<tr
key={rabbit.id}
className={i % 2 === 1 ? 'bg-surface-sunken' : ''}
>
<td className="p-0">
<Link
to={`/herd/${rabbit.id}`}
className="block px-4 py-3 font-medium text-text-primary hover:underline"
>
{rabbit.name}
</Link>
</td>
<td className="px-4 py-3 text-text-primary">{SEX_LABELS[rabbit.sex] ?? rabbit.sex}</td>
<td className="px-4 py-3 text-text-primary">{rabbit.color_name ?? '—'}</td>
<td className="px-4 py-3 text-text-primary">{formatAge(rabbit.dob)}</td>
<td className="px-4 py-3 text-text-primary">{formatWeight(rabbit.weight_lbs, weightUnit) ?? '—'}</td>
<td className="px-4 py-3">
<Badge status={rabbit.status} />
</td>
</tr>
))}
</tbody>
</table>
</Card>
)
}
function Herd() {
const [searchParams, setSearchParams] = useSearchParams()
const sex = searchParams.get('sex') ?? ''
const sort = searchParams.get('sort') ?? 'name'
const q = searchParams.get('q') ?? ''
const view = searchParams.get('view') ?? 'grid'
const status = searchParams.get('status') ?? DEFAULT_STATUS
function updateParams(updates) {
const next = new URLSearchParams(searchParams)
for (const [key, value] of Object.entries(updates)) {
if (value) next.set(key, value)
else next.delete(key)
}
setSearchParams(next, { replace: true })
}
const apiSort = SORT_OPTIONS.find((option) => option.value === sort)?.apiSort ?? 'name'
const isArchived = status === 'archived'
// 'archived' (other + deceased) and 'all' aren't single backend status values,
// so neither sends a status filter to the API — 'archived' is narrowed
// client-side below, 'all' shows everything.
const apiStatus = isArchived || status === 'all' ? undefined : status
const { data, isLoading, isError } = useQuery({
queryKey: ['rabbits', { sex, search: q, sort: apiSort, status: apiStatus }],
queryFn: () =>
getRabbits({
sex: sex || undefined,
search: q || undefined,
sort: apiSort,
status: apiStatus,
}),
})
let rabbits = data ?? []
if (isArchived) {
rabbits = rabbits.filter((rabbit) => rabbit.status === 'other' || rabbit.status === 'deceased')
}
if (sort === 'recent') {
rabbits = [...rabbits].reverse()
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Herd"
action={
<Link to="/herd/add">
<Button variant="primary">Add Rabbit</Button>
</Link>
}
/>
<FilterBar
sex={sex}
status={status}
sort={sort}
q={q}
view={view}
onSexChange={(value) => updateParams({ sex: value })}
onStatusChange={(value) => updateParams({ status: value })}
onSortChange={(value) => updateParams({ sort: value })}
onSearchChange={(value) => updateParams({ q: value })}
onViewChange={(value) => updateParams({ view: value })}
/>
{isError ? (
<p className="font-body text-sm text-danger">Failed to load rabbits.</p>
) : isLoading ? (
view === 'list' ? <ListSkeleton /> : <GridSkeleton />
) : rabbits.length === 0 ? (
<Card>
<EmptyState
title="No rabbits found"
description="Try adjusting your filters or add a new rabbit to get started."
action={
<Link to="/herd/add">
<Button variant="primary">Add Rabbit</Button>
</Link>
}
/>
</Card>
) : view === 'list' ? (
<RabbitListView rabbits={rabbits} />
) : (
<RabbitGrid rabbits={rabbits} />
)}
</div>
)
}
export default Herd

View File

@ -0,0 +1,165 @@
import { Link, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Badge from '../components/Badge'
import Button from '../components/Button'
import { getLitter } from '../api'
import { SEX_LABELS, formatDate, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
function addDays(isoDate, days) {
const date = new Date(`${isoDate}T00:00:00`)
date.setDate(date.getDate() + days)
return date.toISOString().slice(0, 10)
}
function ParentLink({ parent }) {
if (!parent) return <p className="font-body text-sm text-text-secondary">Unknown</p>
return (
<Link to={`/herd/${parent.id}`} className="font-body text-sm text-text-primary hover:text-accent">
{parent.name}
</Link>
)
}
function LitterDetail() {
const { id } = useParams()
const { data: litter, isLoading } = useQuery({
queryKey: ['litter', id],
queryFn: () => getLitter(id),
})
const weightUnit = useWeightUnit()
if (isLoading || !litter) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Litter Detail</h1>
}
const sireName = litter.sire?.name ?? 'Unknown'
const damName = litter.dam?.name ?? 'Unknown'
const kits = litter.kits ?? []
// A litter that hasn't kindled yet shows projections off the breeding date:
// ~31-day gestation to the expected kindle, then weaning ~56 days after kindle.
const kindled = Boolean(litter.kindle_date)
const expectedKindle =
!kindled && litter.breed_date ? formatDate(addDays(litter.breed_date, 31)) : null
const weanDate = kindled ? formatDate(addDays(litter.kindle_date, 56)) : null
const addKitParams = new URLSearchParams()
if (litter.sire?.id != null) addKitParams.set('sire', litter.sire.id)
if (litter.dam?.id != null) addKitParams.set('dam', litter.dam.id)
if (kindled) addKitParams.set('dob', litter.kindle_date)
return (
<div>
<PageHeader
title={`${sireName} × ${damName}`}
subtitle={
kindled
? formatDate(litter.kindle_date)
: litter.breed_date
? `Bred ${formatDate(litter.breed_date)}`
: undefined
}
action={
kindled ? (
<Link to={`/herd/add?${addKitParams.toString()}`}>
<Button size="sm">Add Kit</Button>
</Link>
) : (
<Link to={`/litters/${litter.id}/edit`}>
<Button size="sm">Record Kindle</Button>
</Link>
)
}
/>
<div className="space-y-6 px-6 pb-6">
<Card className="grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div>
<p className="font-body text-xs text-text-secondary">Sire</p>
<ParentLink parent={litter.sire} />
</div>
<div>
<p className="font-body text-xs text-text-secondary">Dam</p>
<ParentLink parent={litter.dam} />
</div>
{litter.breed_date && (
<div>
<p className="font-body text-xs text-text-secondary">Breeding Date</p>
<p className="font-body text-sm text-text-primary">{formatDate(litter.breed_date)}</p>
</div>
)}
<div>
<p className="font-body text-xs text-text-secondary">
{kindled ? 'Kindle Date' : 'Expected Kindle Date'}
</p>
<p className="font-body text-sm text-text-primary">
{kindled ? formatDate(litter.kindle_date) : (expectedKindle ?? '—')}
</p>
</div>
{kindled && (
<div>
<p className="font-body text-xs text-text-secondary">Expected Wean Date</p>
<p className="font-body text-sm text-text-primary">{weanDate}</p>
</div>
)}
</Card>
<Card className="overflow-hidden">
<table className="w-full text-left">
<thead className="bg-surface-sunken">
<tr>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Name</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Sex</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Color</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Ear Number</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Status</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Weight</th>
</tr>
</thead>
<tbody>
{kits.map((kit) => (
<tr key={kit.id} className="border-t border-border">
<td className="px-4 py-2 font-body text-sm">
<Link to={`/herd/${kit.id}`} className="text-text-primary hover:text-accent">
{kit.name}
</Link>
</td>
<td className="px-4 py-2 font-body text-sm text-text-secondary">
{SEX_LABELS[kit.sex] ?? kit.sex}
</td>
<td className="px-4 py-2 font-body text-sm text-text-secondary">{kit.color_name ?? '—'}</td>
<td className="px-4 py-2 font-body text-sm text-text-secondary">{kit.ear_number ?? '—'}</td>
<td className="px-4 py-2 font-body text-sm">
<Badge status={kit.status} />
</td>
<td className="px-4 py-2 font-body text-sm text-text-secondary">
{formatWeight(kit.weight_lbs, weightUnit) ?? '—'}
</td>
</tr>
))}
{kits.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-6 text-center font-body text-sm text-text-secondary">
No kits recorded yet
</td>
</tr>
)}
</tbody>
</table>
</Card>
{litter.notes && (
<Card className="p-4">
<p className="font-body text-xs text-text-secondary">Notes</p>
<p className="mt-1 font-body text-sm text-text-primary">{litter.notes}</p>
</Card>
)}
</div>
</div>
)
}
export default LitterDetail

View File

@ -0,0 +1,46 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Button from '../components/Button'
import EmptyState from '../components/EmptyState'
import LitterCard from '../components/LitterCard'
import { getLitters } from '../api'
function Litters() {
const { data: litters, isLoading } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
return (
<div>
<PageHeader
title="Litters"
action={
<Link to="/litters/add">
<Button>Record Litter</Button>
</Link>
}
/>
<div className="px-6 pb-6">
{isLoading ? null : litters.length === 0 ? (
<EmptyState
title="No litters yet"
description="Record your first litter to start tracking kindle dates and kits."
action={
<Link to="/litters/add">
<Button>Record Litter</Button>
</Link>
}
/>
) : (
<div className="space-y-3">
{litters.map((litter) => (
<LitterCard key={litter.id} litter={litter} />
))}
</div>
)}
</div>
</div>
)
}
export default Litters

View File

@ -0,0 +1,5 @@
function NotFound() {
return <h1>Not Found</h1>
}
export default NotFound

View File

@ -0,0 +1,259 @@
import { useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom'
import { getRabbit, getRabbitAncestors, getSettings } from '../api'
import { GEN1, GEN2, GEN3, getAncestorAtPath, parseManualAncestor } from '../lib/pedigree'
import { formatWeight, SEX_LABELS } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
// A standalone, print-optimized 4-generation pedigree slip rendered OUTSIDE the
// app Layout (no sidebar/bottom-nav chrome to strip), modeled on the original
// Kindlebox PHP app's printed pedigree: every box carries the full rabbit
// detail (name, color, ear, weight, DOB), plus a "Sold To" block and a
// certification/signature line. The user prints it via the browser's
// Print → Save as PDF — the print method was chosen over a bundled PDF library
// to keep the self-hosted footprint dependency-free.
// MM-DD-YYYY to match the old slip; manual ancestors store the literal string
// "Unknown", and linked records can have a null dob — both render as "Unknown".
function fmtDob(dob) {
if (!dob || dob === 'Unknown') return 'Unknown'
const d = new Date(`${dob}T00:00:00`)
if (Number.isNaN(d.getTime())) return 'Unknown'
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}-${dd}-${d.getFullYear()}`
}
// Weight is omitted (rather than shown as "0.00 lbs") when unset — manual
// ancestors default to 0, meaning "unknown".
function fmtWeight(weight, unit) {
if (weight === null || weight === undefined || Number(weight) === 0) return null
return formatWeight(weight, unit)
}
// Normalizes an ancestors-tree node (linked DB record, manual freeform JSON, or
// missing) into the fields a box shows. Manual ancestors store their extra
// fields JSON-encoded in `name`, so they're parsed back out here.
function nodeInfo(node) {
if (!node || (node.id == null && !node.name)) return null
if (node.id != null) {
return {
name: node.name,
farm: node.farm,
ear_number: node.ear_number,
weight_lbs: node.weight_lbs,
dob: node.dob,
color_name: node.color_name,
}
}
const parsed = parseManualAncestor(node.name)
return {
name: parsed.name,
farm: parsed.farm,
ear_number: parsed.ear_number,
weight_lbs: parsed.weight_lbs,
dob: parsed.dob,
color_name: null,
}
}
function nodeName(info) {
return info.farm ? `${info.farm}'s ${info.name}` : info.name
}
function AncestorBox({ info, weightUnit, gridRow }) {
const weight = info && fmtWeight(info.weight_lbs, weightUnit)
return (
<div
className="flex min-w-0 flex-col items-center justify-center gap-0.5 rounded border border-border px-2 py-1 text-center leading-tight"
style={{ gridRow }}
>
{info ? (
<>
<p className="font-display text-[11px] font-bold text-text-primary">{nodeName(info)}</p>
<p className="font-body text-[9px] text-text-secondary">
Color: {info.color_name || '—'}
</p>
<p className="font-body text-[9px] text-text-secondary">
Ear: {info.ear_number || '—'}
{weight ? ` Wt: ${weight}` : ''}
</p>
<p className="font-body text-[9px] text-text-secondary">DOB: {fmtDob(info.dob)}</p>
</>
) : (
<p className="font-body text-[10px] italic text-text-secondary">Unknown</p>
)}
</div>
)
}
function SubjectBox({ rabbit, weightUnit }) {
const weight = fmtWeight(rabbit.weight_lbs, weightUnit)
return (
<div className="flex flex-col items-center justify-center gap-0.5 rounded border-2 border-accent bg-surface px-3 py-3 text-center">
<p className="font-display text-sm font-bold text-text-primary">
{nodeName(rabbit)}
</p>
<p className="font-body text-[10px] text-text-secondary">Color: {rabbit.color_name || '—'}</p>
<p className="font-body text-[10px] text-text-secondary">Ear Number: {rabbit.ear_number || '—'}</p>
<p className="font-body text-[10px] text-text-secondary">DOB: {fmtDob(rabbit.dob)}</p>
{weight && <p className="font-body text-[10px] text-text-secondary">Weight: {weight}</p>}
<p className="font-body text-[10px] text-text-secondary">
Sex: {SEX_LABELS[rabbit.sex] ?? rabbit.sex}
</p>
</div>
)
}
// Blank fields the breeder fills in by hand after printing.
function BlankLine({ label }) {
return (
<div className="flex items-end gap-1">
<span className="font-body text-[10px] text-text-secondary">{label}</span>
<span className="flex-1 border-b border-text-secondary" />
</div>
)
}
function SoldToBlock() {
return (
<div className="space-y-1">
<p className="font-body text-[11px] font-semibold text-text-primary">Sold To</p>
<BlankLine label="Name:" />
<BlankLine label="Address:" />
<BlankLine label="City, State:" />
<BlankLine label="Zip Code:" />
</div>
)
}
function CertifyBlock() {
return (
<div className="space-y-2">
<p className="font-body text-[10px] text-text-secondary">
I hereby certify that this pedigree is correct to the best of my knowledge and belief.
</p>
<BlankLine label="Signed:" />
</div>
)
}
function PedigreePrint() {
const { id } = useParams()
const weightUnit = useWeightUnit()
const { data: rabbit, isLoading } = useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
})
const { data: ancestors } = useQuery({
queryKey: ['rabbit', id, 'ancestors'],
queryFn: () => getRabbitAncestors(id),
enabled: Boolean(rabbit),
})
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
if (isLoading || !rabbit || !ancestors) {
return <p className="p-8 font-body text-sm text-text-secondary">Loading pedigree…</p>
}
const rabbitryName = settings?.rabbitry_name || 'MyKindlebox'
const printedOn = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<div className="min-h-screen bg-white p-6 text-text-primary">
{/* Print-only page setup + screen-only chrome hiding. Scoped here via a
mounted <style> so @page landscape doesn't leak to other routes. */}
<style>{`
@page { size: landscape; margin: 12mm; }
@media print {
.no-print { display: none !important; }
body { background: #fff !important; }
}
.pedigree-print, .pedigree-print * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
`}</style>
<div className="pedigree-print mx-auto max-w-[1100px]">
<div className="no-print mb-4 flex items-center justify-between">
<Link to={`/herd/${id}`} className="font-body text-sm text-accent hover:underline">
← Back to {rabbit.name}
</Link>
<button
type="button"
onClick={() => window.print()}
className="rounded-md bg-accent px-4 py-2 font-body text-sm font-medium text-white hover:bg-accent-hover"
>
Print / Save as PDF
</button>
</div>
{/* Rabbitry header band */}
<div className="mb-4 flex items-end justify-between border-b-2 border-accent pb-2">
<div>
<p className="font-display text-xl font-bold text-accent">{rabbitryName}</p>
<p className="font-display text-lg font-semibold text-text-primary">
Pedigree — {nodeName(rabbit)}
</p>
</div>
<p className="font-body text-[10px] text-text-secondary">Printed {printedOn}</p>
</div>
{/* Bracket grid: subject column | parents | grandparents | great-grandparents.
The leftmost column stacks Sold To / subject / certification like the
original slip; row spans align each generation against the next. */}
<div
className="grid gap-x-3 gap-y-1.5"
style={{
gridTemplateColumns: '1.2fr 1fr 1fr 1fr',
gridTemplateRows: 'repeat(8, minmax(62px, auto))',
}}
>
<div
className="flex flex-col justify-between gap-3"
style={{ gridRow: '1 / span 8', gridColumn: 1 }}
>
<SoldToBlock />
<SubjectBox rabbit={rabbit} weightUnit={weightUnit} />
<CertifyBlock />
</div>
{GEN1.map((entry, i) => (
<AncestorBox
key={entry.slot}
info={nodeInfo(getAncestorAtPath(ancestors, entry.path))}
weightUnit={weightUnit}
gridRow={`${i * 4 + 1} / span 4`}
/>
))}
{GEN2.map((entry, i) => (
<AncestorBox
key={entry.slot}
info={nodeInfo(getAncestorAtPath(ancestors, entry.path))}
weightUnit={weightUnit}
gridRow={`${i * 2 + 1} / span 2`}
/>
))}
{GEN3.map((entry, i) => (
<AncestorBox
key={entry.slot}
info={nodeInfo(getAncestorAtPath(ancestors, entry.path))}
weightUnit={weightUnit}
gridRow={`${i + 1} / span 1`}
/>
))}
</div>
</div>
</div>
)
}
export default PedigreePrint

View File

@ -0,0 +1,261 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Badge from '../components/Badge'
import Button from '../components/Button'
import Silhouette from '../components/Silhouette'
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, SERVER_BASE } from '../api'
import { SEX_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
import { LOCI } from '../lib/genetics'
function locusLabel(locusKey, value) {
const locus = LOCI.find((l) => l.key === locusKey)
return locus?.options.find((opt) => opt.value === value)?.label ?? value
}
function StatRow({ label, value }) {
if (value === null || value === undefined || value === '') return null
return (
<div>
<p className="font-body text-xs text-text-secondary">{label}</p>
<p className="font-body text-sm text-text-primary">{value}</p>
</div>
)
}
function DetailColumn({ rabbit }) {
const weightUnit = useWeightUnit()
const setLoci = LOCI.filter((locus) => rabbit[locus.key])
return (
<Card className="overflow-hidden">
<div className="flex aspect-square items-center justify-center bg-surface-sunken">
{rabbit.photo_path ? (
<img
src={`${SERVER_BASE}${rabbit.photo_path}`}
alt={rabbit.name}
className="h-full w-full object-cover"
/>
) : (
<Silhouette className="h-20 w-20" />
)}
</div>
<div className="space-y-4 p-6">
<div>
<h1 className="font-display text-2xl text-text-primary">{rabbit.name}</h1>
{rabbit.farm && <p className="font-body text-sm text-text-secondary">{rabbit.farm}</p>}
<div className="mt-2">
<Badge status={rabbit.status} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<StatRow label="Sex" value={SEX_LABELS[rabbit.sex] ?? rabbit.sex} />
<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="Ear Number" value={rabbit.ear_number} />
<StatRow label="Location" value={rabbit.location} />
</div>
{(rabbit.registration_number || rabbit.gc_number) && (
<div className="grid grid-cols-2 gap-4 border-t border-border pt-4">
<StatRow label="Registration Number" value={rabbit.registration_number} />
<StatRow label="GC Number" value={rabbit.gc_number} />
</div>
)}
{(rabbit.color_name || setLoci.length > 0) && (
<div className="space-y-2 border-t border-border pt-4">
{rabbit.color_name && <p className="font-body text-sm text-text-primary">{rabbit.color_name}</p>}
{setLoci.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{setLoci.map((locus) => (
<span
key={locus.key}
className="rounded-full border border-border bg-surface-sunken px-3 py-1 font-body text-xs text-text-primary"
>
{locusLabel(locus.key, rabbit[locus.key])}
</span>
))}
</div>
)}
</div>
)}
</div>
</Card>
)
}
function LitterHistory({ rabbitId }) {
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
const history = (litters ?? []).filter(
(litter) => litter.sire?.id === rabbitId || litter.dam?.id === rabbitId
)
if (history.length === 0) return null
return (
<Card className="overflow-hidden">
<table className="w-full text-left">
<thead className="bg-surface-sunken">
<tr>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Partner</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Kindle Date</th>
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Kits</th>
</tr>
</thead>
<tbody>
{history.map((litter) => {
const partner = litter.sire?.id === rabbitId ? litter.dam : litter.sire
return (
<tr key={litter.id} className="border-t border-border">
<td className="px-4 py-2 font-body text-sm">
<Link to={`/litters/${litter.id}`} className="text-text-primary hover:text-accent">
{partner?.name ?? 'Unknown'}
</Link>
</td>
<td className="px-4 py-2 font-body text-sm text-text-secondary">
{litter.kindle_date
? formatDate(litter.kindle_date)
: litter.breed_date
? `Bred ${formatDate(litter.breed_date)}`
: '—'}
</td>
<td className="px-4 py-2 font-body text-sm text-text-secondary">
{litter.kindle_date ? (litter.kit_count ?? 0) : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)
}
function RabbitDetail() {
const { id } = useParams()
const rabbitId = Number(id)
const queryClient = useQueryClient()
const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null)
const { data: rabbit, isLoading } = useQuery({
queryKey: ['rabbit', id],
queryFn: () => getRabbit(id),
})
const { data: ancestors } = useQuery({
queryKey: ['rabbit', id, 'ancestors'],
queryFn: () => getRabbitAncestors(id),
enabled: Boolean(rabbit),
})
if (isLoading || !rabbit) {
return <h1 className="p-6 font-display text-2xl text-text-primary">Rabbit Detail</h1>
}
async function patchStatus(status) {
setUpdating(true)
setError(null)
try {
const updated = await updateRabbit(id, { status })
queryClient.setQueryData(['rabbit', id], updated)
} catch (err) {
setError(err.message)
} finally {
setUpdating(false)
}
}
function handleArchive() {
if (window.confirm('Archive this rabbit? This marks it as deceased.')) {
patchStatus('deceased')
}
}
// Pre-fill the started-from rabbit as sire or dam based on its sex; an
// unknown-sex rabbit opens the flow with neither slot filled.
const parentQuery =
rabbit.sex === 'buck'
? `?sire=${rabbit.id}`
: rabbit.sex === 'doe'
? `?dam=${rabbit.id}`
: ''
const addBreedingHref = `/breedings/add${parentQuery}`
const addLitterHref = `/litters/add${parentQuery}`
return (
<div>
<PageHeader
title={rabbit.name}
subtitle={rabbit.farm}
action={
<div className="flex flex-wrap gap-2">
<Link to={`/herd/${rabbit.id}/edit`}>
<Button variant="secondary" size="sm">
Edit
</Button>
</Link>
<Link to={addBreedingHref}>
<Button variant="secondary" size="sm">
Add Breeding
</Button>
</Link>
<Link to={addLitterHref}>
<Button variant="secondary" size="sm">
Add Litter
</Button>
</Link>
<Link to={`/herd/${rabbit.id}/pedigree`}>
<Button variant="secondary" size="sm">
Print Pedigree
</Button>
</Link>
<Button
variant="primary"
size="sm"
disabled={updating}
onClick={() => patchStatus(rabbit.status === 'for_sale' ? 'active' : 'for_sale')}
>
{rabbit.status === 'for_sale' ? 'Mark Active' : 'Mark For Sale'}
</Button>
<Button variant="danger" size="sm" disabled={updating} onClick={handleArchive}>
Archive
</Button>
</div>
}
/>
<div className="space-y-6 px-6 pb-6">
{error && <p className="font-body text-sm text-danger">{error}</p>}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<div className="w-full lg:w-[360px] lg:shrink-0">
<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>
<LitterHistory rabbitId={rabbitId} />
</div>
</div>
</div>
</div>
)
}
export default RabbitDetail

View File

@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Button from '../components/Button'
import { useAuth } from '../authContext'
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
const WEIGHT_UNIT_OPTIONS = [
{ value: 'decimal_lbs', label: 'Decimal lbs' },
{ value: 'lbs_oz', label: 'lbs + oz' },
]
function Toast({ message }) {
if (!message) return null
return (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 rounded-md bg-text-primary px-4 py-2 font-body text-sm text-surface shadow-lg md:bottom-6">
{message}
</div>
)
}
function Settings() {
const { mode, logout } = useAuth()
const queryClient = useQueryClient()
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const [form, setForm] = useState(null)
const seeded = useRef(false)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState(null)
const importInputRef = useRef(null)
const [pendingFile, setPendingFile] = useState(null)
const [importing, setImporting] = useState(false)
if (settings && !seeded.current) {
seeded.current = true
setForm({
rabbitry_name: settings.rabbitry_name ?? '',
weight_unit: settings.weight_unit ?? 'decimal_lbs',
kit_prefix: settings.kit_prefix ?? '',
theme: settings.theme ?? DEFAULT_THEME,
})
}
useEffect(() => {
if (!toast) return
const timer = setTimeout(() => setToast(null), 2500)
return () => clearTimeout(timer)
}, [toast])
async function handleSave() {
setSaving(true)
try {
await updateSettings(form)
setToast('Settings saved')
} catch (err) {
setToast(err.message || 'Failed to save settings')
} finally {
setSaving(false)
}
}
function handleThemeSelect(themeId) {
setForm((prev) => ({ ...prev, theme: themeId }))
// Apply immediately for live preview; handleSave persists it to the backend.
applyTheme(themeId)
}
function handleExport() {
window.location.href = exportDatabaseUrl()
}
function handleFilePicked(e) {
const file = e.target.files?.[0]
// Reset the input so picking the same file again still fires onChange.
e.target.value = ''
if (!file) return
// Validate before uploading: an oversized file gets rejected by the server
// mid-stream, which the browser surfaces as an opaque "network error" rather
// than a readable message — so catch it (and obvious wrong file types) here.
const name = file.name.toLowerCase()
if (!name.endsWith('.csv') && !name.endsWith('.json')) {
setToast('Please choose a .csv or .json file')
return
}
if (file.size > 10 * 1024 * 1024) {
setToast('File is too large (max 10 MB)')
return
}
setPendingFile(file)
}
async function handleConfirmImport() {
const file = pendingFile
setImporting(true)
try {
const result = await importData(file)
// Everything derived from rabbits/litters is now stale.
queryClient.invalidateQueries()
setToast(`Imported ${result.rabbits_imported} rabbits`)
setPendingFile(null)
} catch (err) {
setToast(err.message || 'Import failed')
} finally {
setImporting(false)
}
}
if (isLoading || !form) {
return (
<div>
<PageHeader title="Settings" />
</div>
)
}
return (
<div>
<PageHeader title="Settings" />
<div className="space-y-6 p-6 pt-0">
<Card className="space-y-4 p-6">
<div>
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label>
<input
type="text"
value={form.rabbitry_name}
onChange={(e) => setForm({ ...form, rabbitry_name: e.target.value })}
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 Display</label>
<div className="mt-1 flex gap-1">
{WEIGHT_UNIT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setForm({ ...form, weight_unit: opt.value })}
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
form.weight_unit === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Kit Prefix</label>
<input
type="text"
value={form.kit_prefix}
onChange={(e) => setForm({ ...form, kit_prefix: e.target.value })}
placeholder="e.g. RAG"
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">
Auto-generated kit names use this prefix (e.g. "{form.kit_prefix || 'Kit'}1").
</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">
Selecting a theme previews it instantly. Save to keep it across your devices.
</p>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{THEMES.map((theme) => (
<button
key={theme.id}
type="button"
onClick={() => handleThemeSelect(theme.id)}
aria-pressed={form.theme === theme.id}
className={`flex items-center gap-3 rounded-md border px-3 py-2 text-left ${
form.theme === theme.id
? 'border-accent ring-2 ring-accent'
: 'border-border hover:bg-surface-sunken'
}`}
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border"
style={{ backgroundColor: theme.swatch.surface }}
>
<span
className="h-4 w-4 rounded-full"
style={{ backgroundColor: theme.swatch.accent }}
/>
</span>
<span className="min-w-0">
<span className="block truncate font-body text-sm font-medium text-text-primary">
{theme.label}
</span>
<span className="block truncate font-body text-xs text-text-secondary">
{theme.description}
</span>
</span>
</button>
))}
</div>
</div>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</Card>
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Export Database</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
Download a full backup of the SQLite database file.
</p>
</div>
<Button variant="secondary" onClick={handleExport}>
Export Database
</Button>
</Card>
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Import Data</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
Load rabbits from a CSV or JSON file. This replaces your entire herd —{' '}
<span className="font-medium text-danger">all current rabbits and litters are deleted first.</span>
</p>
</div>
<input
ref={importInputRef}
type="file"
accept=".csv,.json,text/csv,application/json"
onChange={handleFilePicked}
className="hidden"
/>
<Button variant="secondary" onClick={() => importInputRef.current?.click()}>
Import from File
</Button>
</Card>
{mode === 'authed' && (
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Account</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
End this session on this device.
</p>
</div>
<Button variant="secondary" onClick={logout}>
Log Out
</Button>
</Card>
)}
</div>
{pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<Card className="w-full max-w-md space-y-4 p-6">
<h2 className="font-display text-lg text-text-primary">Replace all data?</h2>
<p className="font-body text-sm text-text-secondary">
Importing <span className="font-medium text-text-primary">{pendingFile.name}</span> will
permanently delete <span className="font-medium text-danger">all current rabbits and litters</span>{' '}
before loading the file. This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPendingFile(null)} disabled={importing}>
Cancel
</Button>
<Button variant="danger" onClick={handleConfirmImport} disabled={importing}>
{importing ? 'Importing...' : 'Delete & Import'}
</Button>
</div>
</Card>
</div>
)}
<Toast message={toast} />
</div>
)
}
export default Settings

View File

@ -0,0 +1,31 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
// Color tokens are CSS variables (defined per-theme in src/index.css)
// rather than literal hex values, so switching the document's
// `data-theme` re-themes every existing utility class with no markup
// changes. The `:root` block in index.css holds the default (Classic)
// palette; see src/lib/theme.js for the theme registry.
colors: {
surface: 'var(--color-surface)',
'surface-raised': 'var(--color-surface-raised)',
'surface-sunken': 'var(--color-surface-sunken)',
border: 'var(--color-border)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
accent: 'var(--color-accent)',
'accent-hover': 'var(--color-accent-hover)',
'accent-warm': 'var(--color-accent-warm)',
danger: 'var(--color-danger)',
},
fontFamily: {
display: ['DM Sans', 'sans-serif'],
body: ['Inter', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
// The client uses relative URLs (/api, /uploads). In dev, proxy those to the
// Node API server so the browser stays same-origin with the Vite dev server —
// this keeps session cookies working and lets a phone on the LAN hit the app
// at http://<dev-machine-ip>:5173 without the API resolving to the phone.
export default defineConfig({
plugins: [react()],
server: {
host: true,
proxy: {
'/api': 'http://localhost:3001',
'/uploads': 'http://localhost:3001',
},
},
})

View File

@ -0,0 +1,26 @@
services:
server:
build: ./server
environment:
# Set MYKINDLEBOX_PASSWORD (e.g. in a .env file next to this compose file)
# to require a login. If left empty, auth is DISABLED and the app is open
# to anyone who can reach it — only acceptable on a fully trusted network.
AUTH_PASSWORD: ${MYKINDLEBOX_PASSWORD:-}
DATA_DIR: /app/data
volumes:
- server-data:/app/data
restart: unless-stopped
# No published port: the server is only reached through the client's nginx
# reverse proxy on the internal compose network. Add a `ports` mapping here
# if you want to hit the API directly for debugging.
client:
build: ./client
ports:
- "8080:80"
depends_on:
- server
restart: unless-stopped
volumes:
server-data:

View File

@ -0,0 +1,2 @@
node_modules
data

View File

@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
# better-sqlite3 is a native module; alpine (musl) often needs to compile it.
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci
COPY . .
# tsc only emits .ts -> dist; the Drizzle migration files (.sql + meta) are read
# at runtime from <dist>/db/migrations, so copy them alongside the build output.
RUN npm run build && cp -r db/migrations dist/db/migrations
ENV NODE_ENV=production
# Keep the SQLite file on the mounted volume (docker-compose.yml), not inside
# the build output where it wouldn't persist across container rebuilds.
ENV DATA_DIR=/app/data
EXPOSE 3001
CMD ["node", "dist/index.js"]

View File

@ -0,0 +1,83 @@
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
// Single-user session-token auth (no OAuth, per tech-stack.md).
//
// A single shared password is read from the AUTH_PASSWORD env var. If it's
// unset/empty, auth is DISABLED — every request passes through — which keeps
// local dev frictionless. When set, clients must POST it to /api/auth/login to
// receive an httpOnly session cookie, checked on every protected route.
//
// Tokens live in memory only: a server restart invalidates all sessions
// (you log in again). That's an acceptable trade for a single-user, self-hosted
// app and avoids persisting a session table.
const COOKIE_NAME = 'mkb_session';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const password = process.env.AUTH_PASSWORD ?? '';
export const authEnabled = password.length > 0;
const sessions = new Set<string>();
export function logAuthStatus() {
if (authEnabled) {
console.log('Auth: enabled (AUTH_PASSWORD is set).');
} else {
console.warn(
'Auth: DISABLED — AUTH_PASSWORD is not set, so the API is open to anyone ' +
'who can reach it. Set AUTH_PASSWORD to require a login.'
);
}
}
function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
if (key) out[key] = decodeURIComponent(part.slice(eq + 1).trim());
}
return out;
}
function readSessionToken(req: Request): string | undefined {
return parseCookies(req.headers.cookie)[COOKIE_NAME];
}
export function isAuthenticated(req: Request): boolean {
if (!authEnabled) return true;
const token = readSessionToken(req);
return token != null && sessions.has(token);
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (isAuthenticated(req)) {
next();
return;
}
res.status(401).json({ error: 'Unauthorized' });
}
export function login(res: Response, attempt: unknown): boolean {
if (!authEnabled) return true;
if (typeof attempt !== 'string' || attempt !== password) return false;
const token = randomUUID();
sessions.add(token);
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS,
});
return true;
}
export function logout(req: Request, res: Response) {
const token = readSessionToken(req);
if (token) sessions.delete(token);
res.clearCookie(COOKIE_NAME, { path: '/' });
}

View File

@ -0,0 +1,43 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import fs from 'fs';
import path from 'path';
import { settings } from './schema';
// DATA_DIR lets the deployment point the SQLite file + uploads at a mounted
// volume (see docker-compose.yml). Falling back to ../data keeps local dev
// (server/data) unchanged. Resolved against the compiled file's location,
// which differs between `tsx` (source) and `node dist/...` (build) — hence the
// explicit env override for production rather than a path that assumes either.
export const dataDir = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: path.join(__dirname, '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });
export const uploadsDir = path.join(dataDir, 'uploads');
fs.mkdirSync(uploadsDir, { recursive: true });
export const dbPath = path.join(dataDir, 'mykindlebox.db');
const sqlite = new Database(dbPath);
export const db = drizzle(sqlite);
migrate(db, { migrationsFolder: path.join(__dirname, 'migrations') });
const defaultSettings = [
{ key: 'rabbitry_name', value: 'My Rabbitry' },
{ key: 'weight_unit', value: 'decimal_lbs' },
{ key: 'kit_prefix', value: '' },
{ key: 'theme', value: 'classic' },
// 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.
{ key: 'kit_counter', value: '0' },
];
for (const setting of defaultSettings) {
sqlite
.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
.run(setting.key, setting.value);
}

View File

@ -0,0 +1,44 @@
CREATE TABLE `rabbits` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`farm` text,
`sex` text DEFAULT 'unknown' NOT NULL,
`dob` text,
`weight_lbs` real,
`ear_number` text,
`location` text,
`status` text DEFAULT 'active' NOT NULL,
`color_name` text,
`photo_path` text,
`registration_number` text,
`gc_number` text,
`sire_id` integer,
`dam_id` integer,
`locus_a` text,
`locus_b` text,
`locus_c` text,
`locus_d` text,
`locus_e` text,
`locus_en` text,
`locus_v` text,
`sire_name_text` text,
`dam_name_text` text,
`paternal_grandsire_name_text` text,
`paternal_granddam_name_text` text,
`maternal_grandsire_name_text` text,
`maternal_granddam_name_text` text,
`pp_grandsire_name_text` text,
`pp_granddam_name_text` text,
`pm_grandsire_name_text` text,
`pm_granddam_name_text` text,
`mp_grandsire_name_text` text,
`mp_granddam_name_text` text,
`mm_grandsire_name_text` text,
`mm_granddam_name_text` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`sire_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`dam_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "sex_check" CHECK("rabbits"."sex" in ('buck', 'doe', 'unknown')),
CONSTRAINT "status_check" CHECK("rabbits"."status" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other'))
);

View File

@ -0,0 +1,11 @@
CREATE TABLE `litters` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`sire_id` integer,
`dam_id` integer,
`kindle_date` text NOT NULL,
`kit_count` integer,
`notes` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`sire_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`dam_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@ -0,0 +1,4 @@
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text
);

View File

@ -0,0 +1,18 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_litters` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`sire_id` integer,
`dam_id` integer,
`breed_date` text,
`kindle_date` text,
`kit_count` integer,
`notes` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`sire_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`dam_id`) REFERENCES `rabbits`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_litters`("id", "sire_id", "dam_id", "kindle_date", "kit_count", "notes", "created_at") SELECT "id", "sire_id", "dam_id", "kindle_date", "kit_count", "notes", "created_at" FROM `litters`;--> statement-breakpoint
DROP TABLE `litters`;--> statement-breakpoint
ALTER TABLE `__new_litters` RENAME TO `litters`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@ -0,0 +1,334 @@
{
"version": "6",
"dialect": "sqlite",
"id": "128180e0-eff5-4190-b589-3a2f268a4651",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"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
},
"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')"
}
}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,421 @@
{
"version": "6",
"dialect": "sqlite",
"id": "74717e1f-b693-4d47-b8e6-3fedab26aaea",
"prevId": "128180e0-eff5-4190-b589-3a2f268a4651",
"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
},
"kindle_date": {
"name": "kindle_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"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
},
"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')"
}
}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,445 @@
{
"version": "6",
"dialect": "sqlite",
"id": "77879836-7291-479a-8b9a-5e425c5a73cc",
"prevId": "74717e1f-b693-4d47-b8e6-3fedab26aaea",
"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
},
"kindle_date": {
"name": "kindle_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"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
},
"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": {}
}
}

View File

@ -0,0 +1,452 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a83e302c-5450-4417-a1e6-cdef0cdb6570",
"prevId": "77879836-7291-479a-8b9a-5e425c5a73cc",
"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
},
"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": {}
}
}

View File

@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1781967016908,
"tag": "0000_modern_power_pack",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781967296459,
"tag": "0001_military_lord_tyger",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1781967483711,
"tag": "0002_luxuriant_the_stranger",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1782015377645,
"tag": "0003_add_breed_date",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,81 @@
import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer, real, check } from 'drizzle-orm/sqlite-core';
export const rabbits = sqliteTable(
'rabbits',
{
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
farm: text('farm'),
sex: text('sex').notNull().default('unknown'),
dob: text('dob'),
weightLbs: real('weight_lbs'),
earNumber: text('ear_number'),
location: text('location'),
status: text('status').notNull().default('active'),
colorName: text('color_name'),
photoPath: text('photo_path'),
registrationNumber: text('registration_number'),
gcNumber: text('gc_number'),
sireId: integer('sire_id').references((): any => rabbits.id),
damId: integer('dam_id').references((): any => rabbits.id),
// Genetics loci
locusA: text('locus_a'),
locusB: text('locus_b'),
locusC: text('locus_c'),
locusD: text('locus_d'),
locusE: text('locus_e'),
locusEn: text('locus_en'),
locusV: text('locus_v'),
// Chain-authority freeform ancestor text (see memory-bank/architecture.md)
// Generation 1 (parents)
sireNameText: text('sire_name_text'),
damNameText: text('dam_name_text'),
// Generation 2 (grandparents)
paternalGrandsireNameText: text('paternal_grandsire_name_text'),
paternalGranddamNameText: text('paternal_granddam_name_text'),
maternalGrandsireNameText: text('maternal_grandsire_name_text'),
maternalGranddamNameText: text('maternal_granddam_name_text'),
// Generation 3 (great-grandparents)
ppGrandsireNameText: text('pp_grandsire_name_text'),
ppGranddamNameText: text('pp_granddam_name_text'),
pmGrandsireNameText: text('pm_grandsire_name_text'),
pmGranddamNameText: text('pm_granddam_name_text'),
mpGrandsireNameText: text('mp_grandsire_name_text'),
mpGranddamNameText: text('mp_granddam_name_text'),
mmGrandsireNameText: text('mm_grandsire_name_text'),
mmGranddamNameText: text('mm_granddam_name_text'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
updatedAt: text('updated_at').notNull().default(sql`(current_timestamp)`),
},
(table) => [
check('sex_check', sql`${table.sex} in ('buck', 'doe', 'unknown')`),
check(
'status_check',
sql`${table.status} in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')`
),
]
);
export const litters = sqliteTable('litters', {
id: integer('id').primaryKey({ autoIncrement: true }),
sireId: integer('sire_id').references(() => rabbits.id),
damId: integer('dam_id').references(() => rabbits.id),
// A litter record spans the breeding→kindle lifecycle: `breedDate` is set when
// the pairing is recorded; `kindleDate` (nullable until the doe kindles) and
// `kitCount` are filled in later. At least one of the two dates is required —
// enforced in routes/litters.ts, not by the schema.
breedDate: text('breed_date'),
kindleDate: text('kindle_date'),
kitCount: integer('kit_count'),
notes: text('notes'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value'),
});

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './db/schema.ts',
out: './db/migrations',
dbCredentials: {
url: './data/mykindlebox.db',
},
});

View File

@ -0,0 +1,56 @@
import express, { NextFunction, Request, Response } from 'express';
import cors from 'cors';
import { uploadsDir } from './db/client';
import { logAuthStatus, requireAuth } from './auth';
import authRouter from './routes/auth';
import rabbitsRouter from './routes/rabbits';
import littersRouter from './routes/litters';
import settingsRouter from './routes/settings';
import statsRouter from './routes/stats';
import exportRouter from './routes/export';
import importRouter from './routes/import';
const app = express();
const PORT = Number(process.env.PORT) || 3001;
// The client is served same-origin (Vite proxy in dev, nginx in prod), so CORS
// isn't needed there. Keep it for cross-origin dev tools, but reflect the
// request origin and allow credentials so session cookies still round-trip
// (a wildcard origin can't be combined with credentials).
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use((req: Request, res: Response, next: NextFunction) => {
console.log(`${req.method} ${req.path}`);
next();
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Auth endpoints must stay open so the login screen can reach them.
app.use('/api/auth', authRouter);
// Everything past here requires a valid session (a no-op when auth is disabled).
app.use('/uploads', requireAuth, express.static(uploadsDir));
app.use('/api/rabbits', requireAuth, rabbitsRouter);
app.use('/api/litters', requireAuth, littersRouter);
app.use('/api/settings', requireAuth, settingsRouter);
app.use('/api/stats', requireAuth, statsRouter);
app.use('/api/export', requireAuth, exportRouter);
app.use('/api/import', requireAuth, importRouter);
app.use((req: Request, res: Response, next: NextFunction) => {
res.status(404).json({ error: 'Not found' });
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
logAuthStatus();
});

3221
mykindlebox/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "node --import tsx --test \"**/*.test.ts\""
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"better-sqlite3": "^12.11.1",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/multer": "^2.1.0",
"@types/node": "^26.0.0",
"drizzle-kit": "^0.31.10",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}

View File

@ -0,0 +1,26 @@
import { Router } from 'express';
import { authEnabled, isAuthenticated, login, logout } from '../auth';
const router = Router();
// Lets the client decide whether to show a login screen at all:
// `configured: false` means auth is disabled, so the app loads straight through.
router.get('/status', (req, res) => {
res.json({ configured: authEnabled, authenticated: isAuthenticated(req) });
});
router.post('/login', (req, res) => {
const ok = login(res, req.body?.password);
if (!ok) {
res.status(401).json({ error: 'Incorrect password' });
return;
}
res.json({ authenticated: true });
});
router.post('/logout', (req, res) => {
logout(req, res);
res.json({ authenticated: false });
});
export default router;

View File

@ -0,0 +1,13 @@
import { Router } from 'express';
import { dbPath } from '../db/client';
const router = Router();
router.get('/', (req, res) => {
const date = new Date().toISOString().slice(0, 10);
res.download(dbPath, `mykindlebox-backup-${date}.db`, {
headers: { 'Content-Type': 'application/octet-stream' },
});
});
export default router;

View File

@ -0,0 +1,310 @@
import { Router } from 'express';
import { eq, getTableColumns } from 'drizzle-orm';
import multer from 'multer';
import { db } from '../db/client';
import { rabbits, litters } from '../db/schema';
import { toCamelCase, pick } from '../utils/case';
const router = Router();
// In-memory upload: import files are small (a herd of a few thousand rabbits is
// well under a megabyte) and we parse the whole thing in one shot, so there's no
// reason to spill to disk. 10 MB is a generous ceiling that still rejects a
// pathological upload before we try to buffer it.
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}).single('file');
const VALID_SEX = new Set(['buck', 'doe', 'unknown']);
const VALID_STATUS = new Set(['active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other']);
// Legacy Kindlebox (PHP app) numeric status codes → MyKindlebox status values.
// Sourced from the old editrabbit.php status <select> (0 Herd / 1 For Sale /
// 2 Pending Sale / 3 Sold / 4 Other / 5 Dead). Any unmapped/garbage code falls
// back to 'active' so a stray value never blocks an import.
const LEGACY_STATUS_MAP: Record<string, string> = {
'0': 'active',
'1': 'for_sale',
'2': 'on_hold',
'3': 'sold',
'4': 'other',
'5': 'deceased',
};
// Only these columns can be set from an imported row. `id` is allowed (so a
// native re-import / legacy import can preserve original ids and therefore
// parentage); timestamps and photo_path stay server-managed. sireId/damId are
// handled separately via the two-pass FK resolution below, so they're excluded
// here and re-attached after the id set is known.
const RABBIT_SYSTEM_FIELDS = new Set(['createdAt', 'updatedAt', 'photoPath', 'sireId', 'damId']);
const RABBIT_IMPORTABLE = Object.keys(getTableColumns(rabbits)).filter(
(key) => !RABBIT_SYSTEM_FIELDS.has(key)
);
interface NormalizedRabbit {
values: Record<string, unknown>; // camelCase, ready for drizzle insert (no sire/dam FK)
id: number | null;
sireRaw: number | null;
damRaw: number | null;
}
function toInt(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const n = Number.parseInt(String(value), 10);
return Number.isNaN(n) ? null : n;
}
// Legacy "0" sentinels (and blanks) mean "not recorded" for ear/registration
// numbers — store them as null rather than a literal "0".
function cleanSentinel(value: unknown): string | null {
if (value === null || value === undefined) return null;
const s = String(value).trim();
return s === '' || s === '0' ? null : s;
}
function toWeight(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const n = Number.parseFloat(String(value));
// 0 means "unweighed" in the legacy data; treat it as missing.
return Number.isNaN(n) || n === 0 ? null : n;
}
function cleanGene(value: unknown): string | null {
if (value === null || value === undefined) return null;
const s = String(value).trim();
return s === '' ? null : s;
}
// --- CSV parsing (RFC-4180-ish: quoted fields, "" escapes, CR/LF) ---
function parseCsv(text: string): string[][] {
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // strip BOM
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
let i = 0;
while (i < text.length) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
field += c;
i++;
continue;
}
if (c === '"') {
inQuotes = true;
i++;
} else if (c === ',') {
row.push(field);
field = '';
i++;
} else if (c === '\r') {
i++;
} else if (c === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
i++;
} else {
field += c;
i++;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
function csvToObjects(text: string): Record<string, string>[] {
const rows = parseCsv(text).filter((r) => !(r.length === 1 && r[0].trim() === ''));
if (rows.length === 0) return [];
const header = rows[0].map((h) => h.trim());
return rows.slice(1).map((cells) => {
const obj: Record<string, string> = {};
header.forEach((h, idx) => {
obj[h] = cells[idx] ?? '';
});
return obj;
});
}
// --- Row normalization ---
function isLegacyHeader(keys: string[]): boolean {
const set = new Set(keys);
return set.has('ID') && set.has('GENDER') && set.has('SIRE_ID');
}
function normalizeLegacy(o: Record<string, string>): NormalizedRabbit {
const gender = String(o.GENDER ?? '').trim();
return {
id: toInt(o.ID),
sireRaw: toInt(o.SIRE_ID),
damRaw: toInt(o.DAM_ID),
values: {
id: toInt(o.ID),
name: (o.NAME ?? '').trim() || 'Unnamed',
sex: gender === '1' ? 'buck' : gender === '0' ? 'doe' : 'unknown',
dob: (o.DATE_OF_BIRTH ?? '').trim() || null,
earNumber: cleanSentinel(o.EAR_NUMBER),
colorName: (o.COLOR ?? '').trim() || null,
status: LEGACY_STATUS_MAP[String(o.STATUS ?? '').trim()] ?? 'active',
weightLbs: toWeight(o.WEIGHT),
registrationNumber: cleanSentinel(o.REG_NUM),
gcNumber: cleanSentinel(o.GC_NUM),
farm: (o.FARM_NAME ?? '').trim() || null,
locusA: cleanGene(o.A_GENE),
locusB: cleanGene(o.B_GENE),
locusC: cleanGene(o.C_GENE),
locusD: cleanGene(o.D_GENE),
locusE: cleanGene(o.E_GENE),
locusEn: cleanGene(o.EN_GENE),
locusV: cleanGene(o.V_GENE),
},
};
}
// Native format: a row whose keys already match the API's snake_case (what this
// app exports), or an equivalent JSON object. Unknown keys are dropped; sex /
// status are clamped to valid enum values so a malformed row can't 500 against
// the DB CHECK constraint.
function normalizeNative(o: Record<string, unknown>): NormalizedRabbit {
const camel = pick(toCamelCase(o as Record<string, unknown>), RABBIT_IMPORTABLE);
const id = toInt((o as Record<string, unknown>).id);
const sireRaw = toInt((o as Record<string, unknown>).sire_id ?? (o as Record<string, unknown>).sireId);
const damRaw = toInt((o as Record<string, unknown>).dam_id ?? (o as Record<string, unknown>).damId);
const values: Record<string, unknown> = { ...camel };
values.id = id;
values.name = (values.name != null && String(values.name).trim() !== '') ? values.name : 'Unnamed';
values.sex = VALID_SEX.has(String(values.sex)) ? values.sex : 'unknown';
values.status = VALID_STATUS.has(String(values.status)) ? values.status : 'active';
if (values.weightLbs !== undefined) values.weightLbs = toWeight(values.weightLbs);
return { id, sireRaw, damRaw, values };
}
router.post('/', (req, res) => {
upload(req, res, (err) => {
if (err) {
res.status(400).json({ error: 'Upload failed (file too large or malformed)' });
return;
}
const file = req.file;
if (!file) {
res.status(400).json({ error: 'No file provided' });
return;
}
const text = file.buffer.toString('utf8');
// A NUL byte means the file isn't UTF-8 text — almost always a UTF-16 export
// (Excel "Unicode Text") or an actual binary file (.xlsx/.xls/image). Decoding
// it as UTF-8 produces garbage that would otherwise import as phantom rows, so
// reject it with an actionable message instead.
if (file.buffer.includes(0)) {
res.status(400).json({
error:
"This doesn't look like a UTF-8 text file. If you exported from Excel, re-save as \"CSV UTF-8\" (not \"Unicode Text\" or .xlsx).",
});
return;
}
const isJson =
file.originalname.toLowerCase().endsWith('.json') ||
text.trimStart().startsWith('[') ||
text.trimStart().startsWith('{');
let normalized: NormalizedRabbit[];
let format: string;
try {
if (isJson) {
const parsed = JSON.parse(text);
const arr: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray((parsed as Record<string, unknown>)?.rabbits)
? ((parsed as Record<string, unknown>).rabbits as unknown[])
: [];
if (!Array.isArray(arr) || arr.length === 0) {
res.status(400).json({ error: 'JSON must be an array of rabbits, or { "rabbits": [...] }' });
return;
}
normalized = arr.map((row) => normalizeNative(row as Record<string, unknown>));
format = 'json';
} else {
const objects = csvToObjects(text);
if (objects.length === 0) {
res.status(400).json({ error: 'CSV has no data rows' });
return;
}
const legacy = isLegacyHeader(Object.keys(objects[0]));
normalized = objects.map((o) => (legacy ? normalizeLegacy(o) : normalizeNative(o)));
format = legacy ? 'legacy-csv' : 'csv';
}
} catch {
res.status(400).json({ error: 'Could not parse file — expected valid CSV or JSON' });
return;
}
// Resolve parentage: keep a sire/dam FK only when it points at an id that's
// actually present in this import; otherwise drop it (legacy 9999 sentinels,
// or references to rabbits that weren't included).
const idSet = new Set<number>();
for (const r of normalized) {
if (r.id != null) idSet.add(r.id);
}
try {
db.transaction((tx) => {
// Litters reference rabbits, so they must go first — and they're wiped
// regardless of the "rabbits only" import scope, since leaving them
// would dangle against deleted rabbits.
tx.delete(litters).run();
tx.delete(rabbits).run();
// Pass 1: insert every rabbit with no parent links yet (a child may
// appear before its parent in the file).
for (const r of normalized) {
tx.insert(rabbits)
.values({ ...r.values, sireId: null, damId: null } as typeof rabbits.$inferInsert)
.run();
}
// Pass 2: attach FKs now that all ids exist.
for (const r of normalized) {
if (r.id == null) continue;
const sireId = r.sireRaw != null && idSet.has(r.sireRaw) ? r.sireRaw : null;
const damId = r.damRaw != null && idSet.has(r.damRaw) ? r.damRaw : null;
if (sireId === null && damId === null) continue;
tx.update(rabbits).set({ sireId, damId }).where(eq(rabbits.id, r.id)).run();
}
});
} catch (e) {
console.error('Import failed:', e);
res.status(500).json({ error: 'Import failed — no changes were made' });
return;
}
res.json({ format, rabbits_imported: normalized.length });
});
});
export default router;

View File

@ -0,0 +1,175 @@
import { Router } from 'express';
import { alias } from 'drizzle-orm/sqlite-core';
import { and, desc, eq, isNull, getTableColumns, sql } from 'drizzle-orm';
import { db } from '../db/client';
import { litters, rabbits, settings } 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 LITTER_SYSTEM_FIELDS = new Set(['id', 'createdAt']);
const LITTER_WRITABLE = Object.keys(getTableColumns(litters)).filter(
(key) => !LITTER_SYSTEM_FIELDS.has(key)
);
// Auto-creates `count` kit Rabbit rows for a newly-recorded litter, named
// from the `kit_prefix` setting (falling back to "Kit" if unset) plus a
// global, never-resetting `kit_counter` — so kit names stay unique across
// every litter ever recorded, not just within one litter. Wrapped in a
// transaction so the counter and the inserted rows can't drift apart if
// something fails partway through.
function generateKits(sireId: number | null, damId: number | null, dob: string, count: number) {
db.transaction((tx) => {
const prefixRow = tx.select().from(settings).where(eq(settings.key, 'kit_prefix')).get();
const counterRow = tx.select().from(settings).where(eq(settings.key, 'kit_counter')).get();
const prefix = prefixRow?.value || 'Kit';
let counter = Number(counterRow?.value ?? '0');
for (let i = 0; i < count; i++) {
counter += 1;
tx.insert(rabbits)
.values({ name: `${prefix}${counter}`, sireId, damId, dob })
.run();
}
tx.update(settings).set({ value: String(counter) }).where(eq(settings.key, 'kit_counter')).run();
});
}
const sireRabbits = alias(rabbits, 'sire_rabbits');
const damRabbits = alias(rabbits, 'dam_rabbits');
function litterWithParents() {
return db
.select({
id: litters.id,
sireId: litters.sireId,
damId: litters.damId,
breedDate: litters.breedDate,
kindleDate: litters.kindleDate,
kitCount: litters.kitCount,
notes: litters.notes,
createdAt: litters.createdAt,
sireName: sireRabbits.name,
damName: damRabbits.name,
})
.from(litters)
.leftJoin(sireRabbits, eq(litters.sireId, sireRabbits.id))
.leftJoin(damRabbits, eq(litters.damId, damRabbits.id));
}
function serializeLitter(row: {
id: number;
sireId: number | null;
damId: number | null;
breedDate: string | null;
kindleDate: string | null;
kitCount: number | null;
notes: string | null;
createdAt: string;
sireName: string | null;
damName: string | null;
}) {
return {
id: row.id,
breed_date: row.breedDate,
kindle_date: row.kindleDate,
kit_count: row.kitCount,
notes: row.notes,
created_at: row.createdAt,
sire: row.sireId != null ? { id: row.sireId, name: row.sireName } : null,
dam: row.damId != null ? { id: row.damId, name: row.damName } : null,
};
}
router.get('/', (req, res) => {
// Breedings without a kindle date yet fall back to their breed date so an
// in-progress breeding sorts by when it happened, not to the bottom.
const results = litterWithParents()
.orderBy(desc(sql`coalesce(${litters.kindleDate}, ${litters.breedDate})`))
.all();
res.json(results.map(serializeLitter));
});
router.get('/:id', (req, res) => {
const litter = litterWithParents()
.where(eq(litters.id, Number(req.params.id)))
.get();
if (!litter) {
res.status(404).json({ error: 'Litter not found' });
return;
}
// Kits are derived by matching sire/dam/DOB against the kindle date, so a
// breeding that hasn't kindled yet (no kindle date) simply has no kits.
const kits = litter.kindleDate
? db
.select()
.from(rabbits)
.where(
and(
litter.sireId != null ? eq(rabbits.sireId, litter.sireId) : isNull(rabbits.sireId),
litter.damId != null ? eq(rabbits.damId, litter.damId) : isNull(rabbits.damId),
eq(rabbits.dob, litter.kindleDate)
)
)
.all()
: [];
res.json({ ...serializeLitter(litter), kits: kits.map(toSnakeCase) });
});
router.post('/', (req, res) => {
const { breed_date, kindle_date, kit_count } = req.body;
const hasBreedDate = typeof breed_date === 'string' && breed_date.trim() !== '';
const hasKindleDate = typeof kindle_date === 'string' && kindle_date.trim() !== '';
if (!hasBreedDate && !hasKindleDate) {
res.status(400).json({ error: 'A breeding date or kindle date is required' });
return;
}
const values = pick(toCamelCase(req.body), LITTER_WRITABLE) as typeof litters.$inferInsert;
const created = db.insert(litters).values(values).returning().get();
if (created.kindleDate && typeof kit_count === 'number' && kit_count > 0) {
generateKits(created.sireId, created.damId, created.kindleDate, kit_count);
}
res.status(201).json(toSnakeCase(created));
});
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
const values = pick(toCamelCase(req.body), LITTER_WRITABLE) as Partial<
typeof litters.$inferInsert
>;
const updated = db.update(litters).set(values).where(eq(litters.id, id)).returning().get();
if (!updated) {
res.status(404).json({ error: 'Litter not found' });
return;
}
res.json(toSnakeCase(updated));
});
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
const existing = db.select().from(litters).where(eq(litters.id, id)).get();
if (!existing) {
res.status(404).json({ error: 'Litter not found' });
return;
}
db.delete(litters).where(eq(litters.id, id)).run();
res.status(204).end();
});
export default router;

View File

@ -0,0 +1,526 @@
import { Router } from 'express';
import { and, or, asc, gte, eq, sql, SQL, AnyColumn, getTableColumns } from 'drizzle-orm';
import multer from 'multer';
import path from 'path';
import { db, uploadsDir } from '../db/client';
import { rabbits } from '../db/schema';
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
const router = Router();
// Mass-assignment guard: accept only real schema columns from the request body,
// and treat id / timestamps / photo_path as server-managed (photo is set via the
// dedicated upload route). Derived from the schema so new columns are covered
// automatically — anything unexpected in the body is silently dropped.
const RABBIT_SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'photoPath']);
const RABBIT_WRITABLE = Object.keys(getTableColumns(rabbits)).filter(
(key) => !RABBIT_SYSTEM_FIELDS.has(key)
);
const VALID_SEX = new Set(['buck', 'doe', 'unknown']);
const VALID_STATUS = new Set(['active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other']);
// These are also enforced by DB CHECK constraints, but validating here turns a
// bad value into a clear 400 instead of a generic 500 from the constraint.
function invalidEnumError(values: Record<string, unknown>): string | null {
if (values.sex !== undefined && !VALID_SEX.has(values.sex as string)) {
return `Invalid sex: ${values.sex}`;
}
if (values.status !== undefined && !VALID_STATUS.has(values.status as string)) {
return `Invalid status: ${values.status}`;
}
return null;
}
// Case-insensitive substring match on name. The user's text is escaped so LIKE
// wildcards (% and _) and the escape char itself are treated literally — a
// search for "a_b" matches the string "a_b", not "axb". The ESCAPE clause is
// required because SQLite's LIKE has no escape character by default.
function nameContains(term: string): SQL {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
return sql`${rabbits.name} like ${`%${escaped}%`} escape '\\'`;
}
// Quick-search "wildcard at the end": a prefix match (`term%`), so typing "Ju"
// matches "Jim Bean"… no, "Jupiter" — only the start of the field. The user's
// text is escaped so literal % / _ aren't treated as wildcards. SQLite's LIKE is
// case-insensitive for ASCII, which is the behavior we want here.
function fieldStartsWith(column: AnyColumn, term: string): SQL {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
return sql`${column} like ${`${escaped}%`} escape '\\'`;
}
// The fields the quick-search bar can target, keyed by the snake_case name the
// client sends in `?fields=` (and the same keys returned in `matched_fields`).
const QUICK_SEARCH_FIELDS = {
farm: rabbits.farm,
name: rabbits.name,
ear_number: rabbits.earNumber,
color: rabbits.colorName,
} as const;
type QuickSearchField = keyof typeof QUICK_SEARCH_FIELDS;
const ALLOWED_PHOTO_TYPES: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
};
const photoUpload = multer({
storage: multer.diskStorage({
destination: uploadsDir,
filename: (req, file, cb) => {
cb(null, `${req.params.id}${ALLOWED_PHOTO_TYPES[file.mimetype]}`);
},
}),
fileFilter: (req, file, cb) => {
if (!ALLOWED_PHOTO_TYPES[file.mimetype]) {
cb(new Error('INVALID_FILE_TYPE'));
return;
}
cb(null, true);
},
}).single('photo');
type RabbitRow = typeof rabbits.$inferSelect;
type Direction = 'sire' | 'dam';
interface AncestorNode {
id: number | null;
name: string | null;
farm: string | null;
sex: string | null;
ear_number: string | null;
weight_lbs: number | null;
dob: string | null;
color_name: string | null;
sire_id: number | null;
dam_id: number | null;
sire: AncestorNode | null;
dam: AncestorNode | null;
}
function fetchRabbitById(id: number): RabbitRow | undefined {
return db.select().from(rabbits).where(eq(rabbits.id, id)).get();
}
function dbAncestorNode(ancestor: RabbitRow): AncestorNode {
return {
id: ancestor.id,
name: ancestor.name,
farm: ancestor.farm,
sex: ancestor.sex,
ear_number: ancestor.earNumber,
weight_lbs: ancestor.weightLbs,
dob: ancestor.dob,
color_name: ancestor.colorName,
sire_id: ancestor.sireId,
dam_id: ancestor.damId,
sire: null,
dam: null,
};
}
function freeformAncestorNode(text: string): AncestorNode {
return {
id: null,
name: text,
farm: null,
sex: null,
ear_number: null,
weight_lbs: null,
dob: null,
color_name: null,
sire_id: null,
dam_id: null,
sire: null,
dam: null,
};
}
// Chain-authority rule: a flat `_name_text` column is only read when the chain
// has already broken at an earlier generation (see memory-bank/architecture.md).
function flatAncestorText(record: RabbitRow, path: Direction[]): string | null {
switch (path.join(',')) {
case 'sire,sire':
return record.paternalGrandsireNameText;
case 'sire,dam':
return record.paternalGranddamNameText;
case 'dam,sire':
return record.maternalGrandsireNameText;
case 'dam,dam':
return record.maternalGranddamNameText;
case 'sire,sire,sire':
return record.ppGrandsireNameText;
case 'sire,sire,dam':
return record.ppGranddamNameText;
case 'sire,dam,sire':
return record.pmGrandsireNameText;
case 'sire,dam,dam':
return record.pmGranddamNameText;
case 'dam,sire,sire':
return record.mpGrandsireNameText;
case 'dam,sire,dam':
return record.mpGranddamNameText;
case 'dam,dam,sire':
return record.mmGrandsireNameText;
case 'dam,dam,dam':
return record.mmGranddamNameText;
default:
return null;
}
}
function resolveAncestorNode(record: RabbitRow, path: Direction[]): AncestorNode | null {
const [head, ...rest] = path;
const fkValue = head === 'sire' ? record.sireId : record.damId;
if (fkValue != null) {
const ancestor = fetchRabbitById(fkValue);
if (!ancestor) return null;
if (rest.length === 0) return dbAncestorNode(ancestor);
return resolveAncestorNode(ancestor, rest);
}
if (rest.length === 0) {
const text = head === 'sire' ? record.sireNameText : record.damNameText;
return text ? freeformAncestorNode(text) : null;
}
const text = flatAncestorText(record, path);
return text ? freeformAncestorNode(text) : null;
}
const KIT_AGE_WINDOW_DAYS = 56;
const sortColumns = {
name: rabbits.name,
dob: rabbits.dob,
weight_lbs: rabbits.weightLbs,
created_at: rabbits.createdAt,
} as const;
router.get('/', (req, res) => {
const { sex, status, search, sort } = req.query;
const conditions: SQL[] = [];
if (sex === 'kits') {
const cutoff = new Date(Date.now() - KIT_AGE_WINDOW_DAYS * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
conditions.push(gte(rabbits.dob, cutoff));
} else if (typeof sex === 'string' && sex) {
conditions.push(eq(rabbits.sex, sex));
}
if (typeof status === 'string' && status) {
conditions.push(eq(rabbits.status, status));
}
if (typeof search === 'string' && search) {
conditions.push(nameContains(search));
}
const sortColumn =
typeof sort === 'string' && sort in sortColumns
? sortColumns[sort as keyof typeof sortColumns]
: sortColumns.name;
const results = db
.select()
.from(rabbits)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(asc(sortColumn))
.all();
res.json(results.map(toSnakeCase));
});
router.get('/search', (req, res) => {
const { q, sex } = req.query;
const conditions: SQL[] = [];
if (typeof q === 'string' && q) {
conditions.push(nameContains(q));
}
if (typeof sex === 'string' && sex) {
conditions.push(eq(rabbits.sex, sex));
}
const results = db
.select({
id: rabbits.id,
name: rabbits.name,
farm: rabbits.farm,
sex: rabbits.sex,
})
.from(rabbits)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(asc(rabbits.name))
.limit(10)
.all();
res.json(results.map(toSnakeCase));
});
// Quick-search bar (top of every page): prefix-matches `q` against any of the
// selected fields (defaults to all), and tags each hit with the field(s) it
// matched on so the dropdown can show why it surfaced. Must be registered
// before `GET /:id` so "quick-search" isn't swallowed as an id.
router.get('/quick-search', (req, res) => {
const { q, fields } = req.query;
const term = typeof q === 'string' ? q.trim() : '';
if (!term) {
res.json([]);
return;
}
// Default to every field; when `fields` is given, keep only known keys.
const requested: QuickSearchField[] =
typeof fields === 'string' && fields
? (fields.split(',').filter((f): f is QuickSearchField => f in QUICK_SEARCH_FIELDS))
: (Object.keys(QUICK_SEARCH_FIELDS) as QuickSearchField[]);
if (requested.length === 0) {
res.json([]);
return;
}
const matchers = requested.map((f) => fieldStartsWith(QUICK_SEARCH_FIELDS[f], term));
const results = db
.select({
id: rabbits.id,
name: rabbits.name,
farm: rabbits.farm,
sex: rabbits.sex,
earNumber: rabbits.earNumber,
colorName: rabbits.colorName,
})
.from(rabbits)
.where(or(...matchers))
.orderBy(asc(rabbits.name))
.limit(10)
.all();
// Re-derive which of the requested fields each row matched, mirroring the
// LIKE prefix match, so the client can label the hit (e.g. "Ear #", "Color").
const lowerTerm = term.toLowerCase();
const labeled = results.map(toSnakeCase).map((row: any) => {
const valueByField: Record<QuickSearchField, string | null> = {
farm: row.farm,
name: row.name,
ear_number: row.ear_number,
color: row.color_name,
};
const matched_fields = requested.filter((f) =>
(valueByField[f] ?? '').toLowerCase().startsWith(lowerTerm)
);
return { ...row, matched_fields };
});
res.json(labeled);
});
router.get('/:id', (req, res) => {
const rabbit = fetchRabbitById(Number(req.params.id));
if (!rabbit) {
res.status(404).json({ error: 'Rabbit not found' });
return;
}
res.json(toSnakeCase(rabbit));
});
router.get('/:id/ancestors', (req, res) => {
const root = fetchRabbitById(Number(req.params.id));
if (!root) {
res.status(404).json({ error: 'Rabbit not found' });
return;
}
const sire = resolveAncestorNode(root, ['sire']);
const dam = resolveAncestorNode(root, ['dam']);
const paternalGrandsire = resolveAncestorNode(root, ['sire', 'sire']);
const paternalGranddam = resolveAncestorNode(root, ['sire', 'dam']);
const maternalGrandsire = resolveAncestorNode(root, ['dam', 'sire']);
const maternalGranddam = resolveAncestorNode(root, ['dam', 'dam']);
if (sire) {
sire.sire = paternalGrandsire;
sire.dam = paternalGranddam;
}
if (dam) {
dam.sire = maternalGrandsire;
dam.dam = maternalGranddam;
}
if (paternalGrandsire) {
paternalGrandsire.sire = resolveAncestorNode(root, ['sire', 'sire', 'sire']);
paternalGrandsire.dam = resolveAncestorNode(root, ['sire', 'sire', 'dam']);
}
if (paternalGranddam) {
paternalGranddam.sire = resolveAncestorNode(root, ['sire', 'dam', 'sire']);
paternalGranddam.dam = resolveAncestorNode(root, ['sire', 'dam', 'dam']);
}
if (maternalGrandsire) {
maternalGrandsire.sire = resolveAncestorNode(root, ['dam', 'sire', 'sire']);
maternalGrandsire.dam = resolveAncestorNode(root, ['dam', 'sire', 'dam']);
}
if (maternalGranddam) {
maternalGranddam.sire = resolveAncestorNode(root, ['dam', 'dam', 'sire']);
maternalGranddam.dam = resolveAncestorNode(root, ['dam', 'dam', 'dam']);
}
res.json({
id: root.id,
name: root.name,
farm: root.farm,
sex: root.sex,
ear_number: root.earNumber,
weight_lbs: root.weightLbs,
dob: root.dob,
color_name: root.colorName,
sire_id: root.sireId,
dam_id: root.damId,
sire,
dam,
});
});
router.post('/', (req, res) => {
const { name } = req.body;
if (typeof name !== 'string' || name.trim() === '') {
res.status(400).json({ error: 'Name is required' });
return;
}
const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert;
const enumError = invalidEnumError(values);
if (enumError) {
res.status(400).json({ error: enumError });
return;
}
const created = db.insert(rabbits).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), RABBIT_WRITABLE) as Partial<
typeof rabbits.$inferInsert
>;
const enumError = invalidEnumError(values);
if (enumError) {
res.status(400).json({ error: enumError });
return;
}
const updated = db
.update(rabbits)
.set({ ...values, updatedAt: new Date().toISOString() })
.where(eq(rabbits.id, id))
.returning()
.get();
if (!updated) {
res.status(404).json({ error: 'Rabbit not found' });
return;
}
res.json(toSnakeCase(updated));
});
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
const existing = db.select().from(rabbits).where(eq(rabbits.id, id)).get();
if (!existing) {
res.status(404).json({ error: 'Rabbit not found' });
return;
}
// Chain-authority: when a rabbit is deleted, its offspring's link to it is
// broken, so snapshot the deleted rabbit (and the two generations above it
// that the offspring used to see through it) into the offspring's freeform
// `_name_text` columns. Without this, deleting a sire would silently erase
// that whole branch of every descendant's pedigree instead of degrading it to
// text. All offspring of a given rabbit share the same snapshot, so it's
// computed once. Wrapped in a transaction with the delete so a partial failure
// can't leave dangling FKs.
const nameAt = (path: Direction[]) => resolveAncestorNode(existing, path)?.name ?? null;
const sireSnapshot = {
sireId: null,
sireNameText: existing.name,
paternalGrandsireNameText: nameAt(['sire']),
paternalGranddamNameText: nameAt(['dam']),
ppGrandsireNameText: nameAt(['sire', 'sire']),
ppGranddamNameText: nameAt(['sire', 'dam']),
pmGrandsireNameText: nameAt(['dam', 'sire']),
pmGranddamNameText: nameAt(['dam', 'dam']),
};
const damSnapshot = {
damId: null,
damNameText: existing.name,
maternalGrandsireNameText: nameAt(['sire']),
maternalGranddamNameText: nameAt(['dam']),
mpGrandsireNameText: nameAt(['sire', 'sire']),
mpGranddamNameText: nameAt(['sire', 'dam']),
mmGrandsireNameText: nameAt(['dam', 'sire']),
mmGranddamNameText: nameAt(['dam', 'dam']),
};
db.transaction((tx) => {
tx.update(rabbits).set(sireSnapshot).where(eq(rabbits.sireId, id)).run();
tx.update(rabbits).set(damSnapshot).where(eq(rabbits.damId, id)).run();
tx.delete(rabbits).where(eq(rabbits.id, id)).run();
});
res.status(204).end();
});
router.post('/:id/photo', (req, res) => {
const id = Number(req.params.id);
if (!fetchRabbitById(id)) {
res.status(404).json({ error: 'Rabbit not found' });
return;
}
photoUpload(req, res, (err) => {
if (err) {
res.status(400).json({ error: 'Invalid file type' });
return;
}
const file = req.file;
if (!file) {
res.status(400).json({ error: 'Invalid file type' });
return;
}
const photoPath = `/uploads/${path.basename(file.path)}`;
const updated = db
.update(rabbits)
.set({ photoPath, updatedAt: new Date().toISOString() })
.where(eq(rabbits.id, id))
.returning()
.get()!;
res.json(toSnakeCase(updated));
});
});
export default router;

View File

@ -0,0 +1,40 @@
import { Router } from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import { settings } from '../db/schema';
const router = Router();
const ALLOWED_KEYS = ['rabbitry_name', 'weight_unit', 'kit_prefix', 'theme'] as const;
router.get('/', (req, res) => {
const rows = db.select().from(settings).all();
const result: Record<string, string | null> = {};
for (const row of rows) {
result[row.key] = row.value;
}
res.json(result);
});
router.patch('/', (req, res) => {
const keys = Object.keys(req.body);
const unknownKey = keys.find((key) => !ALLOWED_KEYS.includes(key as (typeof ALLOWED_KEYS)[number]));
if (unknownKey) {
res.status(400).json({ error: `Unknown setting: ${unknownKey}` });
return;
}
for (const key of keys) {
db.update(settings).set({ value: req.body[key] }).where(eq(settings.key, key)).run();
}
const rows = db.select().from(settings).all();
const result: Record<string, string | null> = {};
for (const row of rows) {
result[row.key] = row.value;
}
res.json(result);
});
export default router;

View File

@ -0,0 +1,39 @@
import { Router } from 'express';
import { and, eq, gte, inArray, sql, SQL } from 'drizzle-orm';
import { db } from '../db/client';
import { rabbits } from '../db/schema';
const router = Router();
const KIT_AGE_WINDOW_DAYS = 56;
function count(...conditions: SQL[]) {
return db
.select({ value: sql<number>`count(*)` })
.from(rabbits)
.where(conditions.length ? and(...conditions) : undefined)
.get()!.value;
}
router.get('/', (req, res) => {
const kitCutoff = new Date(Date.now() - KIT_AGE_WINDOW_DAYS * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
res.json({
herd: count(eq(rabbits.status, 'active')),
bucks: count(eq(rabbits.status, 'active'), eq(rabbits.sex, 'buck')),
does: count(eq(rabbits.status, 'active'), eq(rabbits.sex, 'doe')),
kits: count(eq(rabbits.status, 'active'), gte(rabbits.dob, kitCutoff)),
for_sale: count(eq(rabbits.status, 'for_sale')),
on_hold: count(eq(rabbits.status, 'on_hold')),
sold: count(eq(rabbits.status, 'sold')),
archived: db
.select({ value: sql<number>`count(*)` })
.from(rabbits)
.where(inArray(rabbits.status, ['other', 'deceased']))
.get()!.value,
});
});
export default router;

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"ignoreDeprecations": "6.0",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { toSnakeCase, toCamelCase, pick } from './case';
test('toSnakeCase converts keys', () => {
assert.deepEqual(toSnakeCase({ weightLbs: 3.5, sireId: 1, locusEn: 'Enen' }), {
weight_lbs: 3.5,
sire_id: 1,
locus_en: 'Enen',
});
});
test('toCamelCase converts keys', () => {
assert.deepEqual(toCamelCase({ weight_lbs: 3.5, sire_id: 1, pp_grandsire_name_text: 'Bob' }), {
weightLbs: 3.5,
sireId: 1,
ppGrandsireNameText: 'Bob',
});
});
test('snake<->camel round-trips for schema-style keys', () => {
const original = { maternal_granddam_name_text: 'X', registration_number: 'R1' };
assert.deepEqual(toSnakeCase(toCamelCase(original)), original);
});
test('pick keeps only allowed keys', () => {
const result = pick(
{ name: 'Daisy', id: 99, createdAt: 'now', photoPath: '/x.jpg' },
['name', 'sex']
);
assert.deepEqual(result, { name: 'Daisy' });
});
test('pick drops everything when nothing is allowed', () => {
assert.deepEqual(pick({ id: 1, name: 'x' }, []), {});
});

Some files were not shown because too many files have changed in this diff Show More