# 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` `` 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/`. 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 ``s inside the `flex-col` button (and the label/value `

`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 `