MyKindlebox/memory-bank/progress.md

89 KiB
Raw Permalink Blame History

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 + NavLinks 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.logs 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 Cards 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 Cards (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' hrefs 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 LitterCards — 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 Links 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 useQuerys, 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.

OCR → AI vision pivot (2026-10-28)

Tesseract phases 1–3 above are superseded. User decisions: Claude API (not a local model), claude-opus-5-5, remove Tesseract entirely, don't store the scanned photo.

  • Removed: client/public/tesseract/ (~16 MB), lib/ocr*.js + tests, pages/OcrDev.jsx + /ocr-dev route, tesseract.js / react-image-crop deps; service-worker cache bumped to mykindlebox-shell-v2 so installed clients purge the old precache.
  • Added server lib/pedigreeScan.ts + routes/pedigreeScan.ts (@anthropic-ai/sdk, zod), ANTHROPIC_API_KEY passthrough in docker-compose, nginx proxy_read_timeout 180s for /api/.
  • Added client PedigreeScan.jsx, lib/imageResize.js, scan helpers in lib/pedigree.js (+2 tests), "Check" highlighting in PedigreeTree, wiring in AddRabbit.jsx.
  • Validated: client lint / test (21 pass) / build; server tsc; local smoke test of GET/POST /api/pedigree-scan with no key (disabled → 503), a wrong file type (400), and an invalid key (reaches Anthropic → 502 "key rejected"). Pending: end-to-end scans of real pedigrees with a real key, and prompt tuning on a varied sample.

User Accounts & Onboarding (2026-07-02)

Replaced the optional shared-password auth with real user accounts — see memory-bank/FEATURE_User_Accounts.md for the user-confirmed scope (shared herd, in-app account creation, everyone equal, per-user theme/weight-unit).

  • Schema (0011_add_users.sql): users (username UNIQUE, scrypt password_hash, nullable per-user theme/weight_unit) + auth_sessions (token PK, user_id, expires_at — persisted so restarts don't sign anyone out; cascade is declarative-only since foreign_keys is OFF, so user deletion removes sessions explicitly).
  • Server: auth.ts rewritten — hashPassword/verifyPassword (node:crypto scrypt, timingSafeEqual; deliberately no bcrypt/argon2 native dep), DB sessions with lazy expiry purge, requireAuth attaches req.user. New POST /api/auth/setup (first account only — 409 after), username+password login with a flat 350 ms failed-login delay, and routes/users.ts (list/create/reset-password/delete; can't delete self; password reset invalidates the target's other sessions). /api/settings kept its exact shape but theme/weight_unit now read/write the caller's user row (NULL inherits the global value) — zero client-consumer changes.
  • Client: AuthProvider gained a third gate (configured: false → SetupScreen), the login screen takes username+password, context exposes user, and Settings gained a UsersCard (add/reset/remove) plus "Signed in as …" in the Account card.
  • Retired: AUTH_PASSWORD/MYKINDLEBOX_PASSWORD (removed from compose + README; the server logs a warning if it's still set). Upgrading deployments see the one-time setup screen; herd data untouched.
  • Validation: server tsc clean; client lint/41-tests/build clean. Full API lifecycle driven over HTTP against a scratch DB: fresh-install status → 401 on protected routes → weak-password 400 → setup → second setup 409 → wrong-password 401 (~400 ms) → case-insensitive login → duplicate-username 409 → per-user theme divergence with shared rabbitry_name → self-delete 400 → password reset killing the target's session → delete → logout → session surviving a server restart.