303 lines
85 KiB
Markdown
303 lines
85 KiB
Markdown
# 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.**
|
||
|
||
---
|
||
|
||
# OCR Pedigree Scanning (post-base feature)
|
||
|
||
**Reference:** `memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md` (step-by-step plan), `memory-bank/FEATURE_OCR_Pedigree_Scan.md` (feature spec). All OCR processing is client-side; no server changes for the base feature (Phase 5 of that plan adds one herd-matching API *call*, not a new endpoint).
|
||
|
||
## OCR Phase 1 — Install and vendor OCR dependencies (complete, validated)
|
||
|
||
- **Step 1.1 — Install packages**: added `tesseract.js@^7.0.0` and `react-image-crop@^11.1.2` to `client/package.json`. Deliberately did **not** install `jimp` — all image preprocessing is done with the browser canvas API (OCR Phase 2), per the plan.
|
||
- Validated by the user: client dev server runs, no import errors, both packages present in `package.json`.
|
||
|
||
- **Step 1.2 — Vendor Tesseract assets locally** into `client/public/tesseract/` so nothing is fetched from a CDN at runtime (the app is self-hosted and must work offline). Five files:
|
||
- `worker.min.js` (copied from `tesseract.js/dist/`).
|
||
- `eng.traineddata` (5.0 MB, **uncompressed**). Downloaded as `.gz` from `@tesseract.js-data/eng/4.0.0_best_int` and decompressed before vendoring. Verified the result is a real tessdata binary (header bytes `18 00 00 00`), not an HTML error page or a still-gzipped blob.
|
||
- Three WASM core variants: `tesseract-core-lstm.wasm.js`, `tesseract-core-simd-lstm.wasm.js`, `tesseract-core-relaxedsimd-lstm.wasm.js` (~3.8 MB each, copied from the `tesseract.js-core` package).
|
||
- **Why three core files (decision):** tesseract.js v7 defaults to OEM `LSTM_ONLY`, so at worker-load time it runs `wasm-feature-detect` and requests exactly **one** of the three `-lstm` cores from the `corePath` directory — `relaxedsimd-lstm` if the browser has relaxed-SIMD, else `simd-lstm`, else plain `lstm` (logic in `tesseract.js/src/worker-script/browser/getCore.js`). Vendoring all three lets a **directory-based** `corePath` ('/tesseract/') auto-select the correct core on any browser, instead of hard-pinning one variant. The non-`-lstm` and `.asm` cores are never requested unless `legacyCore`/a non-LSTM OEM is set, so they're intentionally omitted.
|
||
- **Why only the `.wasm.js` files, not the bare `.wasm`:** the `tesseract-core-*-lstm.wasm.js` files (~3.9 MB) embed the WASM binary as base64 — they're the self-contained single-file builds that `getCore.js` loads via `importScripts`. The separate `.wasm` files pair with the small (~89 KB) non-`.wasm.js` loaders, which we don't use. Confirmed by the size ratio (3.9 MB JS ≈ 89 KB loader + base64 of the 2.85 MB binary) and by inspecting the file head.
|
||
- **HANDOFF NOTE for OCR Phase 3 (`ocrRun.js`):** because the vendored `eng.traineddata` is **uncompressed**, the Tesseract worker must be initialized with `langPath: '/tesseract/'` **and `gzip: false`** — the default `gzip: true` makes the worker request `eng.traineddata.gz` (404 here). Pair with `corePath: '/tesseract/'` (directory → auto-select, per above) and `workerPath: '/tesseract/worker.min.js'`.
|
||
- Validated by the user (asset files present and served; full offline-recognize check is only fully exercisable once Phase 3 wires up `ocrRun.js`).
|
||
|
||
- **Step 1.3 — Service worker caching** (`client/public/sw.js`): the five `/tesseract/` assets are added to a `TESSERACT_URLS` list and precached on `install` alongside the existing `SHELL_URLS` (same `mykindlebox-shell-v1` cache, which the `activate` cleanup already preserves). The `fetch` handler gained a dedicated **cache-first** branch for `/tesseract/*` (serve the stored copy outright when present — no network round-trip — and only fetch+store on a cache miss), because these assets are large, static, and versioned with the app. The rest of the app shell stays **network-first** (unchanged); `/api/*` and `/uploads/*` are still skipped entirely. `node --check` passes.
|
||
- **Note:** `cache.addAll` is atomic, so all five `/tesseract/` URLs must be served correctly or the SW won't install — they resolve from `public/` at the site root in both Vite dev and the nginx/dist production build.
|
||
- Validated by the user: after one online load, the Tesseract assets appear in Cache Storage and the app still resolves them offline.
|
||
|
||
**OCR Phase 1 is complete and user-validated.**
|
||
|
||
## OCR Phase 2 — Image preprocessing utility (complete, validated)
|
||
|
||
- **Step 2.1 — Create `client/src/lib/ocrPreprocess.js`**: a canvas-only (no `jimp`) module exporting `async preprocessImage(file)` plus two tunable constants, `MAX_WIDTH` (1600) and `BINARIZE_THRESHOLD`. `preprocessImage` accepts a `File`/`Blob` and resolves to a PNG `Blob`: decode via `URL.createObjectURL` (object URL revoked on load), draw to a canvas scaling **down** proportionally only when wider than `MAX_WIDTH` (never upscales), then a single pass over the `getImageData` buffer does grayscale (luminance `0.299R + 0.587G + 0.114B`) and binarization to pure black/white in one loop, and `toBlob` returns it as PNG. `BINARIZE_THRESHOLD` is exported (not inlined) precisely so the Step 2.2 tuning is a one-line change and is reusable if any later step needs the same cutoff.
|
||
- ESLint clean.
|
||
|
||
- **Step 2.2 — Validate on a real pedigree photo + threshold tuning**: validated against a real photographed Holland Lop pedigree (2880×2160 phone photo) via a **temporary** dev harness — `src/pages/OcrDev.jsx` on a throwaway `/ocr-dev` route (file picker + original / module-output / live-threshold-preview panels with dimension readouts). Both the harness file and its route were **removed** after validation (App.jsx reverted), consistent with the project's remove-temporary-scaffolding convention.
|
||
- **Threshold tuned 128 → 103**: at the 128 starting point, the photo's uneven lighting and shadowed/torn paper edges binarized into large black blobs that swallowed the bottom-row ancestor cells. Dropping to **103** cleared the blobs and kept every cell legible (confirmed visually in the harness, then baked into `BINARIZE_THRESHOLD`). Output correctly downscaled 2880→1600px wide (1600×1200).
|
||
- **Decision — keep a fixed global threshold, do not add Otsu (yet)**: a single fixed global threshold is inherently lighting-sensitive (103 is tuned to typical photos; a much darker/brighter one could still blob or fade). Otsu's method (per-image optimal global threshold from the histogram) was offered as a more robust, still-global alternative. The user chose to **keep the fixed 103** and stay within the plan's Phase 2 scope, revisiting only if real photos give trouble. If that day comes, Otsu is the intended upgrade — it slots into the same single binarization pass without touching the rest of the pipeline.
|
||
|
||
**OCR Phase 2 is complete and user-validated.**
|
||
|
||
## OCR Phase 3 — OCR + layout/parse utilities (complete, pending user validation)
|
||
|
||
All four modules are pure client-side libs under `client/src/lib/` with no server changes. The two stages that are pure data transforms (`ocrLayout.js`, `ocrParse.js`) got Node `--test` unit tests (matching the existing `pedigree.test.js`/`genetics.test.js` convention); the two browser/WASM-dependent stages (`ocrRun.js`, `ocrPipeline.js`) can't run under `node --test` (Tesseract worker + `createImageBitmap`) and get the plan's manual browser validation instead — which is what's pending user sign-off.
|
||
|
||
- **Step 3.1 — `ocrRun.js`** exports `runOCR(imageBlob)` and the `MIN_WORD_CONFIDENCE` (60) constant. A **single lazily-created worker** is memoized in a module-level `workerPromise` and reused across scans (loading the ~9MB core + traineddata is expensive). The worker is created via `createWorker('eng', undefined, {...})` with the OCR-Phase-1 handoff paths: `workerPath: '/tesseract/worker.min.js'`, `corePath: '/tesseract/'` (a **directory**, so tesseract.js v7 auto-selects the lstm/simd/relaxedsimd core for the browser), `langPath: '/tesseract/'`, and **`gzip: false`** (the vendored `eng.traineddata` is uncompressed — default `gzip:true` would 404 on `.gz`).
|
||
- **v7 output shape**: `Page` has no top-level `words`/`lines`; they're nested `blocks → paragraphs → lines → words`, and that structure is only populated when `blocks: true` is requested (default output is text-only). So `runOCR` calls `worker.recognize(blob, {}, { blocks: true })` and **flattens** the nesting into plain `{ words, lines }` arrays — the shape the layout stage expects — dropping any word with `confidence < 60` during the flatten.
|
||
|
||
- **Step 3.2–3.4 — `ocrLayout.js`** (three pure exports + `LINE_GROUP_TOLERANCE` = 10px). `detectColumns(words, imageWidth)` buckets words into 3 equal vertical bands by `bbox.x0` (clamped to 0..2 so an edge word can't overflow). `splitSireDam(columnWords)` sorts by `y0` and cuts at the largest adjacent vertical gap (degrades cleanly for 0/1-word columns). `aggregateCellText(words)` groups words into lines by anchoring each line to its first word's `y0` and admitting later words within `LINE_GROUP_TOLERANCE`, orders each line left-to-right by `x0`, and joins lines with `\n`.
|
||
- `ocrLayout.test.js`: synthetic word objects (just `text` + `bbox`) drive deterministic checks of band bucketing/edge-clamp, the largest-gap split, the empty/single-word degraders, and line grouping + left-to-right ordering from a deliberately out-of-order input.
|
||
|
||
- **Step 3.5 — `ocrParse.js`** exports `parseCellFields(cellText)` → `{ farmName, rabbitName, earNumber, dob, weight }` (nulls for missing fields — the `"Unknown"`/`0` sentinels are applied later at save, never here). Name uses the possessive split `^(.+?'s?)\s+(.+)$` on the **first non-label line** (lines starting with a known field-label keyword, or that are themselves a detectable ear/date/weight value, are skipped). Ear number prefers a labelled value (`Tattoo:`/`Ear #:`/etc., label matched case-insensitively, value extracted case-sensitively as `[A-Z0-9]{2,8}` so trailing lowercase letters of a following word aren't absorbed), falling back to a bare alphanumeric token alone on a line. DOB parses `M/D/YY(YY)` with `/` or `-`, maps 2-digit years to `20xx`, validates the month/day range, and emits `YYYY-MM-DD`. Weight checks the `lbs+oz` form first (`8 lbs 4 oz` → 8.25) then `lbs`-only; a bare number with no unit is intentionally **not** a weight.
|
||
- `ocrParse.test.js`: the plan's three required cases (all-fields-labelled, name+ear-only, name-without-possessive) plus per-rule coverage (apostrophe-only possessive, decimal-lbs, bare-number-rejected, 2-digit-year + dash dates, invalid-month rejection, labelled-ear-beats-bare-token, all-whitespace → all nulls).
|
||
|
||
- **Step 3.6 — `ocrPipeline.js`** exports `extractPedigree(imageBlob)` and a testable helper `splitGrandparentQuadrants(words)`. `extractPedigree` runs `runOCR` and reads the image's true width (`createImageBitmap`, since `detectColumns` needs it and the `Page` doesn't expose it) **in parallel** (`Promise.all`), splits into subject/parents/grandparents columns, splits parents via `splitSireDam`, splits grandparents into a 2×2 grid, and returns the 7-key `{ subject, sire, dam, sire_sire, sire_dam, dam_sire, dam_dam }` shape (each value the `parseCellFields` output).
|
||
- **Quadrant split deviation (documented)**: the plan says split the grandparent column "at the horizontal and vertical midpoints of the column." Image *height* isn't carried through the pipeline, so `splitGrandparentQuadrants` instead takes the midpoints of the grandparent **words' own bounding extent** (min/max of their `x0/x1`, `y0/y1`) and assigns each word by its center — top-left→`sire_sire`, bottom-left→`sire_dam`, top-right→`dam_sire`, bottom-right→`dam_dam`, per the plan's mapping. Using the text extent (vs. band geometry) also better reflects where the four cells actually sit.
|
||
|
||
- **Validation so far**: `npm test` — 34 pass / 0 fail (15 new across the two test files). `npx eslint` clean on all six new files. `npm run build` succeeds (the new `tesseract.js` import resolves; it isn't pulled into the app bundle yet since nothing imports the pipeline until plan Phase 5). **Per the user's request, stopping here for them to validate the Phase 3 tests (especially the browser-only `runOCR`/`extractPedigree` paths against a real pedigree image) before OCR Phase 4 — the UI components — begins.**
|