# 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 `` tags in `client/index.html`. Mapped `font-display` → DM Sans and `font-body` → Inter in `tailwind.config.js`. Applied `font-body` to `
`. 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: " }` 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 `.`. On success, updates `photo_path` to `/uploads/` 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/.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 (`}>` wrapping all page routes in `App.jsx`) using `react-router-dom`'s nested-route ``. 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 `` 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 ``.
- 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` `