73 KiB
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 rootREADME.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 /healthreturns{ "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) fromApp.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 classictailwind.config.jsobject 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) totailwind.config.jstheme.extend.colors, sourced fromapp-design-document.md. Validated visually (cream background, dark text). - Step 1.5: Loaded DM Sans + Inter via Google Fonts
<link>tags inclient/index.html. Mappedfont-display→ DM Sans andfont-body→ Inter intailwind.config.js. Appliedfont-bodyto<body>. Validated both fonts render correctly via devtools computed style. - Step 1.6: Added
docker-compose.ymlat the repo root withserverandclientservices, plus a named volume (server-data) mounted at/app/datain the server container for the SQLite file. Added aDockerfileto each ofserver/andclient/, plus.dockerignorefiles excludingnode_modules.- Not validated — Docker is not installed on this development machine. The user will validate
docker compose upmanually later. Do not assume this step is confirmed working until that happens.
- Not validated — Docker is not installed on this development machine. The user will validate
Phase 2 — Database (in progress)
-
Step 2.1: Installed
drizzle-ormandbetter-sqlite3(+@types/better-sqlite3) inserver.- 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, addedtypescript,tsx(used fornpm run devviatsx watch),@types/node,@types/express, and atsconfig.json(CommonJS module output,strict: true, builds todist/).npm run build(tsc) +npm run start(node dist/index.js) are the production path;npm run devis tsx watch mode for local dev. - Created
server/db/client.ts: ensuresserver/data/exists (fs.mkdirSync(..., { recursive: true })— better-sqlite3 does not create missing parent directories itself), opensserver/data/mykindlebox.db, and exports a Drizzledbinstance. index.tsimports./db/clientso the DB file is created/opened on server startup.- Validated: deleted
data/, restarted server, confirmeddata/mykindlebox.dbwas recreated with no errors.
- TypeScript decision: The plan names the file
-
Step 2.2: Defined the
rabbitstable inserver/db/schema.ts— all 24 base fields from the data model (including self-referencingsire_id/dam_idFKs andcreated_at/updated_attimestamp defaults), the 7 genetics loci columns, and all 14 chain-authority_name_textancestor columns (2 parents, 4 grandparents, 8 great-grandparents).sexandstatusare enforced with SQLiteCHECKconstraints 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 addedserver/drizzle.config.tspointing at./db/schema.tswith migrations output toserver/db/migrations/. Generated the initial migration (0000_modern_power_pack.sql) vianpx drizzle-kit generateand applied it vianpx drizzle-kit migrate. This establishes the migration-file workflow that Step 2.5 (auto-run migrations on startup) will hook into —db/client.tsdoes not yet run migrations automatically. - Validated: inspected
server/data/mykindlebox.dbdirectly — all 38 columns present, FKs onsire_id/dam_idpresent, and thesex_check/status_checkCHECK constraints correctly reject invalid values while accepting valid ones (verified with manual inserts, then cleaned up).
- Migration tooling decision: Installed
-
Step 2.3: Added the
litterstable toserver/db/schema.ts(id,sire_id/dam_idFKs torabbits.id,kindle_date,kit_count,notes,created_at). Generated migration0001_military_lord_tyger.sqland applied vianpx drizzle-kit migrate.- Validated: inspected the DB directly —
litterstable present with both FK constraints registered againstrabbits.id.
- Validated: inspected the DB directly —
-
Step 2.4: Added the
settingstable (keytext primary key,valuetext) toserver/db/schema.ts. Generated migration0002_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 indb/client.ts, added as part of Step 2.5 below — seedsrabbitry_name→"My Rabbitry"andweight_unit→"decimal_lbs".
- Seeding logic (raw
-
Step 2.5: Wired
drizzle-orm/better-sqlite3/migrator'smigrate()intodb/client.tsso all pending migrations run automatically on startup, before the settings seed step. Bothdrizzle-kit migrate(CLI, used manually in Steps 2.2/2.3) and this in-codemigrate()share the same__drizzle_migrationstracking table, so there's no double-application risk between the manual and automatic paths.- Validated: deleted
server/data/, ran the startup path —rabbits,litters, andsettingstables were all recreated andsettingscontained exactly the two seed rows. Ran it again without deleting — no errors, settings still exactly two rows (seed usesINSERT OR IGNOREkeyed on thekeyprimary key, so it's idempotent). ConfirmedGET /healthstill returns{ "status": "ok" }with this startup logic in place.
- Validated: deleted
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 firstapp.useinindex.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-errorroute that synchronously threw, confirmed it produced HTTP 500 with the correct JSON body and was logged viaconsole.error, then removed the route. ConfirmedGET /nonexistentreturns HTTP 404 with the correct body and is logged by the request logger. ConfirmedGET /healthstill returns 200 after the test route was removed.
- Validated: added a temporary
-
Step 3.2: Created
server/routes/rabbits.ts(an ExpressRouter) withGET /api/rabbits, mounted at/api/rabbitsinindex.ts. Supportssex(buck/doe/unknown, or the specialkitsvalue — matches any sex withdobwithin the last 56 days, computed in JS as an ISO cutoff date and compared withgte),status(exact match),search(SQLiteLIKE '%...%'onname, case-insensitive by default for ASCII), andsort(name/dob/weight_lbs/created_at, defaultname, always ascending). Conditions are combined withand(...)only when present, so an empty query returns all rows.- Validated: with an empty DB, the endpoint returned
[]. Inserted two rabbits directly viabetter-sqlite3(one buck "Jimmy", one doe "Daisy") —GET /api/rabbitsreturned both,?sex=buckreturned only Jimmy, and?search=jim(case-insensitive) returned only Jimmy. Cleaned up the test rows afterward.
- Validated: with an empty DB, the endpoint returned
-
Step 3.3: Added
GET /api/rabbits/:idtoserver/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/:idwith its real ID returned the full record, andGET /api/rabbits/99999returned 404 with the expected body. Cleaned up the test row afterward.
- Validated: inserted a test rabbit directly, confirmed
-
Step 3.4: Added
POST /api/rabbits. Validatesnameis 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. — seedb/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. Addedserver/utils/case.ts(toSnakeCase/toCamelCase, generic key-mapping, not a per-field allowlist) and applied it: responses are converted camelCase → snake_case beforeres.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 inindex.ts(not per-route), since later POST/PATCH endpoints across rabbits/litters/settings will all need it.- Validated:
POST /api/rabbitswith{ "name": "Test Rabbit", "sex": "buck" }returned HTTP 201 with the new record (snake_case keys,idassigned). A subsequentGET /api/rabbits/:idwith that ID returned the identical record.POST /api/rabbitswith{ "sex": "buck" }(no name) returned HTTP 400 with the expected error body. Cleaned up the test row afterward.
- JSON casing decision: Drizzle's schema field names are camelCase (
-
Step 3.5: Added
PATCH /api/rabbits/:id. Accepts a partial snake_case body, converts it viatoCamelCase, and updates only the provided fields plusupdated_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" }ifupdate(...).returning().get()finds no matching row.- Validated: created a rabbit via POST, sent
PATCH { "weight_lbs": 3.5 }, confirmed the response hadweight_lbs: 3.5with every other field unchanged (andupdated_atadvanced pastcreated_at).PATCHto a nonexistent ID returned 404 with the expected body. Cleaned up the test row afterward.
- Validated: created a rabbit via POST, sent
-
Step 3.6: Added
DELETE /api/rabbits/:id. Checks existence first (404 if missing), then nulls outsire_id/dam_idon any rabbit referencing the target via twoupdatecalls, then deletes the row, returning HTTP 204.- Validated: created a sire and a child rabbit (child's
sire_idpointing at the sire). Deleted the sire — got 204. Confirmed the child'ssire_idwas nownulland the sire 404s on a subsequent GET. Confirmed deleting a nonexistent ID also returns 404. Cleaned up test rows afterward.
- Validated: created a sire and a child rabbit (child's
-
Step 3.7: Added
GET /api/rabbits/:id/ancestorstoserver/routes/rabbits.ts, returning a nested 4-generation pedigree tree ({ ...root fields, sire: AncestorNode|null, dam: AncestorNode|null }, eachAncestorNodeitself havingsire/damchildren 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_textcolumn (viaflatAncestorText, 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/.damto 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_idnull,sire_name_text = "Bob's Buck",paternal_grandsire_name_text = "Old Bob"— confirmedsireresolved to{ id: null, name: "Bob's Buck" }andsire.sireresolved to{ id: null, name: "Old Bob" }; (3) unbroken-chain-with-stale-data case — root with a realsire_idpointing at a sire with no further ancestors, plus a stalepaternal_grandsire_name_textdirectly on the root — confirmedsire.sirewasnull, correctly ignoring the root's stale flat column and reading from the real Sire record's (empty) ancestry instead. Rannpx tsc --noEmitto confirm no type errors (tsx watch only transpiles, doesn't typecheck). Cleaned up all test rows afterward.
- Implementation approach:
-
Step 3.8: Added
GET /api/rabbits/search(registered beforeGET /:idinroutes/rabbits.ts— Express matches routes in registration order, and/:idwould otherwise greedily match the literal string"search"as an id). Selects onlyid/name/farm/sexvia Drizzle's partial.select({...}), filters onq(case-insensitiveLIKEonname) and optionalsex, sorted by name, capped at.limit(10).- Validated: with "Jimmy", "Jimbo", "Daisy", and an unrelated rabbit in the DB,
?q=jimreturned only the two Jim-prefixed bucks;?q=&sex=buckand?q=&sex=doecorrectly 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.
- Validated: with "Jimmy", "Jimbo", "Daisy", and an unrelated rabbit in the DB,
-
Step 3.9: Created
server/routes/litters.ts, mounted at/api/litters.GET /andGET /:idjoinrabbitstwice via twoalias()'d copies of the table (sireRabbits/damRabbits) to embed{ id, name }for sire/dam (nullif unset) rather than bare IDs, sorted bykindle_datedescending for the list.GET /:idadditionally derives itskitsarray by queryingrabbitsfor rows matching the litter'ssire_id/dam_id(usingisNullwhen a parent is unset, sinceeq(col, null)is always false in SQL) anddobequal to the litter'skindle_date— same derivation approach as the design doc's no-Kit-table convention, just reused here instead of in the dashboard stats.POST /requireskindle_date(400 +{ "error": "Kindle date is required" }otherwise),PATCH /:iddoes a partial update,DELETE /:idreturns 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 inGET /api/litterswith joinedsire/damname+id objects. Added a rabbit with matchingsire_id/dam_id/doband confirmed it appeared inGET /api/litters/:id'skitsarray. Deleted the litter and confirmed a subsequentGET404s. Rannpx tsc --noEmitclean. Cleaned up all test rows afterward.
- Validated: created a sire and dam rabbit, then a litter referencing both via
-
Step 3.10: Created
server/routes/settings.ts, mounted at/api/settings.GET /flattens thesettingskey-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 therabbitry_name/weight_unitallowlist, otherwise updates each provided key and returns the full flattened settings object. No snake_case/camelCase conversion needed here —settings.keyvalues are already the snake_case strings used at the API boundary, unlike therabbits/litterstables which have real typed columns.- Validated:
GET /api/settingsreturned 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. Resetrabbitry_nameback to the seeded default afterward.
- Validated:
-
Step 3.11: Created
server/routes/stats.ts, mounted at/api/stats. Returnsherd/bucks/does/kits(all scoped tostatus = 'active';kitsadditionally uses the same 56-daydobage-window logic as the rabbits-listsex=kitsfilter, not the litter-join method),for_sale/on_hold/sold(status-only counts), andarchived(statusotherORdeceasedcombined, 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
dobof today and one withdob57 days ago.GET /api/statsreturned 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 fromkitsandarchivedcorrectly summeddeceased+other. Cleaned up all test rows afterward.
- Validated: inserted rabbits spanning every status, plus one with
-
Step 3.12: Added
POST /api/rabbits/:id/phototoroutes/rabbits.ts, plus static serving of/uploads/*inindex.ts. Installedmulter+@types/multer. Checks the rabbit exists first (404 before touching the upload, so no orphan files get written for nonexistent IDs), then runs amulterdisk-storage upload (manually invoked asphotoUpload(req, res, callback)inside the route rather than as ordinary middleware, sofileFilterrejections surface as this route's own HTTP 400 instead of falling through to the global 500 handler). Onlyimage/jpeg/image/png/image/webpare accepted; the saved filename is<rabbit-id>.<ext>. On success, updatesphoto_pathto/uploads/<filename>and returns the full updated rabbit record.- Shared uploads path: Exported
uploadsDir(alongside the existingdataDir) fromdb/client.tsso bothroutes/rabbits.ts(multer's destination) andindex.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 todata/uploads/,photo_pathwas set to/uploads/<id>.jpg, and fetching that URL directly returned the image withContent-Type: image/jpeg. Uploading a.txtfile returned HTTP 400 with{ "error": "Invalid file type" }. Rannpx tsc --noEmitclean. Cleaned up the test rabbit, uploaded file, and local test fixtures afterward.
- Shared uploads path: Exported
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 underclient/src/pages/(Dashboard,Herd,AddRabbit,RabbitDetail,EditRabbit,Litters,AddLitter,LitterDetail,Genetics,Settings), each rendering just a heading, plus aNotFoundpage wired to a catch-all*route.- Validated: confirmed via the Vite dev server's module endpoint (
curl'ing/src/App.jsxand 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.
- Validated: confirmed via the Vite dev server's module endpoint (
-
Step 4.2: Created
client/src/components/Layout.jsx, a layout route (<Route element={<Layout />}>wrapping all page routes inApp.jsx) usingreact-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/addper the design doc). Active-route highlighting usesNavLink'sisActiverender-prop callback (accent-green fill on desktop, accent text color on mobile) rather than manualuseLocationpath-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
/herdhighlights the "Herd" sidebar link in accent green with white text.
- 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
-
Step 4.3: Installed
@tanstack/react-query, wrappedApp.jsxin aQueryClientProvider. Createdclient/src/api.js(notapi.ts— the client was deliberately scaffolded as plain JS in Step 1.3, so.tsfrom 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 targetinghttp://localhost:3001/api. A sharedrequest()helper throws on non-2xx responses (reading the{ error }body) and only setsContent-Type: application/jsonwhen the body is a JSON string (notFormData, 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-originfetchcalls from the Vite dev server (port 5173) to the API (port 3001) are blocked by the browser without it. Added thecorspackage (+@types/cors) toserver, applied asapp.use(cors())(allow-all) inindex.ts, ahead ofexpress.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.jsxcallsuseQuery({ queryKey: ['stats'], queryFn: getStats })andconsole.logs the result. Confirmed viacurl -H "Origin: http://localhost:5173"against/api/statsthat the response includesAccess-Control-Allow-Origin, and confirmed the Dashboard module compiles cleanly through Vite's transform pipeline.
- CORS decision: the backend had no CORS middleware (Phase 3 never needed it — only
-
Step 4.4: Built five components under
client/src/components/:Button(variant × size class maps forprimary/secondary/danger×sm/md),Badge(status → color/label maps covering all six Rabbit status values, defaulting to theotherstyle for unrecognized values),Card(borderedsurface-raisedcontainer),PageHeader(title/subtitle/optional right-side action slot),EmptyState(centered title/description/optional action).- Validated visually: added a temporary
/devroute rendering all five components with representative props, screenshotted with Playwright (installed temporarily for this one check, thennpm uninstall'd — it is not a project dependency), confirmed colors/spacing/typography matched the design tokens, then deletedpages/Dev.jsxand removed the route fromApp.jsx.
- Validated visually: added a temporary
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 8Card-wrapped stat tiles in agrid-cols-2 md:grid-cols-4layout (Herd, Bucks, Does, Kits, For Sale, On Hold, Sold, Archived — matchingGET /api/stats's response keys exactly). Each card is aLinkto/herdwith a query param:sex=buck/sex=doe/sex=kitsfor the sex-axis cards,status=for_sale/on_hold/sold/archivedfor the status-axis cards, and/herdwith no params for the plain "Herd" card. Note for Phase 6:archivedisn't a realstatuscolumn value (the backend'sarchivedstat sumsstatus IN ('other', 'deceased')) — the Herd filter bar (Step 6.2) must special-case?status=archivedto match both, the same wayroutes/stats.tsdoes, rather than treating it as a literal exact-match status filter. WhileisLoading, each tile renders as aCardwithanimate-pulse bg-surface-sunkeninstead of the linked content. OnisError, a plain error message renders in place of the grid.- Validated: seeded rabbits via direct
POST /api/rabbitscalls (one buck, one doe, one for_sale, one sold) and confirmed via Playwright screenshot (installed temporarily, thennpm 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.
- Validated: seeded rabbits via direct
-
Step 5.2: Built the quick actions row below the stats bar — "Add Rabbit" (
Buttonprimary, links to/herd/add), "Record Litter" (Buttonsecondary, links to/litters/add), "View Herd" (Buttonsecondary, 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 bykindle_datedescending, and slices to the first 3. Each row (in a singledivide-yCard) showssire.name × dam.name(falling back to "Unknown" for either side if the litter has no sire/dam — reusing the joined{ id, name }shapeGET /api/littersalready returns), the kindle date formatted as "Month D, YYYY" viatoLocaleDateString, and a singular/plural-aware kit count. Renders nothing (returnsnull) while loading or on error — the section heading "Recent Litters" stays above it regardless. With zero litters, renders theEmptyStatecomponent inside aCardinstead.- 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 —EmptyStateitself was already validated in Step 4.4 and the conditional here is a straightforward zero-length check. Cleaned up the test litter/rabbits afterward.
- 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
-
Layout padding (post-Step-5.1 fix):
Layout.jsx's<main>had no padding around page content (onlypb-16/pb-0for the mobile bottom-nav clearance), so every page's content sat flush against the sidebar/top edge. Changed top-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.
- Validated visually via a temporary Playwright screenshot (installed, used,
-
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-2section (stacked on mobile, side-by-side on desktop).UpcomingBirthdays(inDashboard.jsx) fetchesGET /api/rabbits?status=activeand computes upcoming birthdays client-side — there is no backend endpoint for this. For each rabbit with adob, it re-anchors the month/day onto the current (or next, if already passed) year to find the next occurrence, computesdaysUntiland the age the rabbit will be turning, filters to aBIRTHDAY_WINDOW_DAYS = 30-day window, and sorts ascending bydaysUntil. Each row links to/herd/:id, shows "Month D · turning N", and a right-aligned "Today" / "Tomorrow" / "in N days" label. Falls back toEmptyStatewhen nothing is in the window.- Validated: seeded three active rabbits with
dobset 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.
- Validated: seeded three active rabbits with
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. WrapsCard, links to/herd/:id. Shows the rabbit's photo (${SERVER_BASE}${photo_path}) or an inline SVG breed-silhouette placeholder ifphoto_pathis null, the name, acolor_name • Sexsubtitle line (omitted entirely if both are empty), anage • weightline, and a0 litters • 0 kitsline — litter/kit counts are hardcoded placeholders per the plan's literal wording, since no API currently aggregates them per-rabbit. Addedclient/src/lib/format.jsexportingformatAge(returns "Xy Ym", "Xm", "<1m", or "Age unknown" for a null/invaliddob) andformatWeight(returns"X.XX lbs"ornullfor a nullweight_lbs) plus a sharedSEX_LABELSmap — used by both the card and the Step 6.4 list view. ExportedSERVER_BASE(http://localhost:3001, the API origin without the/apisuffix) fromapi.jsso 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
dobshows "Age unknown" without crashing, and each card links to the correct/herd/:id.
- 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
-
Step 6.2: Built the filter bar inside
client/src/pages/Herd.jsx(FilterBar/SearchBoxsub-components, not split into their own files — page-specific UI, same patternDashboard.jsxalready uses). Sex segmented tabs (All/Bucks/Does/Kits/Other — "Other" maps tosex=unknown), asort<select>(Name/Age/Recent/Weight, mapped to the backend'sname/dob/created_at/weight_lbssort 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) viauseSearchParams, usingreplace: trueso filter changes don't spam browser history. The search box is its own component (SearchBox) keyed bykey={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-syncinguseEffect— 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'sGET /only does exact-match status filtering.Herd.jsxspecial-cases this: whenstatus === 'archived', thestatusparam is omitted from the backend request entirely, and the returned rows are filtered client-side tostatus === '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 —statusis only ever set by arriving from a Dashboard link. - Recent sort: the backend's
sortparam is always ascending (Step 3.2), so forsort=recent(mapped tocreated_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=bucketc.) and confirmed it survived throughsetSearchParams; 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=listin the URL correctly restored list view (confirmed via a real page reload, not just state inspection).
- Archived special-case: per the note left in Step 5.1,
-
Step 6.3: Built
RabbitGrid(2-col mobile,md:grid-cols-3,lg:grid-cols-4) insideHerd.jsx, fed by a singleuseQuery(['rabbits', {...filters}], () => getRabbits({...}))shared with the Step 6.4 list view so togglingviewdoesn't refetch. ShowsGridSkeleton(8 pulsingCards matching the real card's aspect-square + text-line proportions) while loading, and the sharedEmptyState(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
dobinside the 56-day window; the Archived link from a fresh/herd?status=archivedURL correctly showed only thedeceased+otherrabbits; 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).
- 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
-
Step 6.4: Built
RabbitListViewinsideHerd.jsxfor?view=list, rendering aCard-wrapped<table>with Name/Sex/Color/Age/Weight/Status columns, zebra-striped viabg-surface-sunkenon odd-indexed rows (CSS:nth-childwasn't used since rows are already mapped in JS — indexing the map callback was simpler than relying onodd:/even:Tailwind variants). The Name cell links to/herd/:id; Status uses the existingBadgecomponent. Reuses the exact same filtered/sorted dataset asRabbitGrid— no separate fetch or separate filter logic.- Validated: toggling to list view rendered all expected columns with correct data (including the
Badgecomponent'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.
- Validated: toggling to list view rendered all expected columns with correct data (including the
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.jsxplus a newclient/src/lib/pedigree.jsholding the slot/path definitions (GEN1/GEN2/GEN3, 14 ancestor slots mapped 1:1 to the schema's_name_textcolumns) and the chain-authority resolution logic.resolveEffectiveNode(path, state, cascadeCache)mirrors the backend'sresolveAncestorNoderecursively 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 ismode: 'linked', in which case its value comes from that ancestor's own fetchedAncestorNodesubtree (viaGET /api/rabbits/:id/ancestors, cached in aMapkeyed by rabbit id) rather than from any state stored on the subject.- No FK exists below gen1: the
rabbitstable only hassire_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 inlib/pedigree.js) walk all 14 slots once and produce, respectively, the flat snake_case fields ready to merge into aPOST/PATCH /api/rabbitsbody (locked slots are skipped entirely — leftnull, since they're dead columns per the chain-authority rule) and a display-only{ slot, label, display }array consumed byReviewStep.- 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 apaternal_grandsire_name_textof exactly that text withsire_id/sire_name_textboth null (since Sire itself was Unknown) — confirming the chain-authority write rule end-to-end.
- No FK exists below gen1: the
-
Step 7.2: Created
client/src/components/RabbitDetailsForm.jsx, a controlled component (value/onChangeover 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 vialbsToOz/ozToLbshelpers local to the component) based on aweightUnitprop sourced fromGET /api/settings. Photo is handled via a separateonPhotoChange(file)callback (not part ofvalue) 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(theLOCIconfig — option labels/values for all seven loci, sourced from standard rabbit-color genetics since the design doc only spelled out A/B Locus examples — andgenerateColorString(loci)) andclient/src/components/GeneticsForm.jsx(collapsible pill-group UI,defaultExpandedprop).generateColorStringcombines 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/eproduced 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.
- Validated: selecting
-
Step 7.4: Created
client/src/components/ReviewStep.jsx— three read-onlyCards (Pedigree from thepedigreeSummaryarray, Details, Genetics + generated color string) with a shareddash()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.jsxas a 3-step wizard (stepstate,STEP_LABELSdriving the "Step N of 3" subtitle). Step 1 rendersPedigreeTreeuncontrolled (it manages its own internal state/cascade and reports out viaonChange(fields, summary)); Step 2 rendersRabbitDetailsForm+GeneticsFormover wizard-leveldetails/geneticsstate; Step 3 rendersReviewStep. "Next" on Step 2 is disabled viadetails.name.trim().length > 0. Saving callscreateRabbit({ ...details, ...pedigreeFields, ...genetics })then, if a photo was selected, a follow-upuploadRabbitPhoto, then redirects to/herd/:id.- Farm default-fill:
settings.rabbitry_nameis applied todetails.farmexactly once via a guarded directsetStatecall in the render body (not auseEffect— 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 auseRefflag 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_textand loci values, and the redirect landed on/herd/<new-id>. No console errors during the run.
- Farm default-fill:
-
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-populatingdetails/geneticslocal state via a guarded directsetStatein the render body (same pattern as Step 7.5's farm default, gated ondetails === nullso it only seeds once) and thePedigreeTreevialib/pedigree.js'sseedPedigreeStateFromRabbit(rabbit, ancestors)/cascadeFromAncestors(ancestors).- Ancestors must be loaded before the pedigree tree mounts:
seedPedigreeStateFromRabbitneedsancestors.sire.name/ancestors.dam.nameto give a linked Sire/Dam node a display label (the raw rabbit record only hassire_id/dam_id, not the name) — discovered via Playwright when a linked Sire rendered with a blank name. Fixed by addingancestorsLoadingto the page's loading guard soPedigreeTree(which only readsinitialState/initialCascadeonce, at mount, viauseState(() => ...)) never mounts before the ancestors response is available.PedigreeTreeitself also self-heals via a mount-only effect that callsfetchCascadefor any initially-linked gen1 node not already ininitialCascade, so a slow/failed ancestors fetch degrades to an extraGET /api/rabbits/:id/ancestorscall rather than a permanently blank node. - Saving diffs
details/genetics/pedigreeFieldsagainst the originally-loadedrabbitrecord field-by-field and only includes changed keys in thePATCH /api/rabbits/:idbody (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 subsequentGETshowed the updated value with everything else unchanged.
- Ancestors must be loaded before the pedigree tree mounts:
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.jsxpreviously 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 to0, never required) and DOB (defaults to the literal string"Unknown", never required). Asked the user how to persist this since therabbitsschema only has a single_name_texttext column per ancestor slot — chose structured JSON in the existing column (lib/pedigree.js'sserializeManualAncestor/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'snamedirectly (the slot button, the locked-descendant display,buildPedigreeSummary'sdisplayfield) now runs it throughformatManualAncestorDisplay("Name (Farm) • Ear #X");parseManualAncestorfalls 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'ssire_name_textcolumn 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.
- 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
-
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 theflex-colbutton (and the label/value<p>s inside the locked-nodeflexrow) hadtruncatebut now-full/min-w-0/flex-1— in a flex container a child sizes to its content by default, sotruncate'soverflow-hiddenhad no bounded width to clip against. Fixed by addingmin-w-0to the flex containers andw-fullto the text spans (overflow-hiddenadded 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 noww-full, and eachGenerationcolumn isflex-1so the three generations (plus the fixed-width subject card) share the available width evenly; individual node buttons changed from a fixedw-44tow-full min-w-[140px], so they stretch to fill their column instead of staying a fixed pixel width. Themin-w-[900px]+overflow-x-autoon 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 existingscrollHeight, 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 addedpb-[300px]to the tree's root container to permanently reserve that much space — opening any dropdown, anywhere in the tree, no longer changesdocument.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 intoPageHeader's existingactionslot (top-right, next to the title) via aheaderActionvariable 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 ofReviewStep— 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 atype="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 tracksdob(the raw date input value) anddobUnknown(the checkbox) separately, collapsing to one string only at save time (manualForm.dobUnknown ? 'Unknown' : manualForm.dob) before handing off toserializeManualAncestor. A newmanualFormFromParsed(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
0with no indication of what it was — itsplaceholder="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.
- Visible labels on Weight/DOB: discovered while testing the above that the Weight field showed a bare
Phase 8 — Rabbit Detail Page (complete, pending user validation)
-
Step 8.1: Replaced the
RabbitDetail.jsxplaceholder with aDetailColumncomponent fed byuseQuery(['rabbit', id], () => getRabbit(id)). Photo orSilhouetteplaceholder (the inline SVG previously private toRabbitCard.jsxwas extracted toclient/src/components/Silhouette.jsxand both now share it), name as a Display heading, farm subheading (omitted if null), statusBadge, a 2-column stats grid (Sex, DOB, Age, Weight, Ear Number, Location — each cell rendered via aStatRowhelper that returnsnullfor 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 (reusinglib/genetics.js'sLOCIoption labels via a locallocusLabellookup).- 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 editablePedigreeTree.jsx(sameGEN1/GEN2/GEN3slot layout, subject box, node sizing) but driven directly by theGET /api/rabbits/:id/ancestorsresponse tree instead of client-side pedigree state. AddedgetAncestorAtPath(ancestors, path)tolib/pedigree.js— walks the nestedAncestorNodetree along a slot'spath(e.g.['sire','sire']→ancestors.sire.sire), returningnullif the chain ends early. Each node renders one of three ways:null→ muted "Unknown";id != null→ aLinkto/herd/:idshowingname (farm);id == nullwith aname→ muted freeform text, passed throughformatManualAncestorDisplay(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>.
- Validated: a rabbit with a real
-
Step 8.3: Added a
LitterHistorycomponent toRabbitDetail.jsx. Reuses the existinguseQuery(['litters'], getLitters)(same query key the Dashboard's recent-litters widget uses, so the cache is shared) and filters client-side to litters wherelitter.sire?.id === rabbitId || litter.dam?.id === rabbitId— there's no backend filter param for this, andGET /api/littersalready 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'sactionslot: "Edit" (secondary, links to/herd/:id/edit), "Add Litter" (secondary, links to/litters/add?sire=<id>or?dam=<id>based onrabbit.sex, or plain/litters/addforunknown), "Mark For Sale"/"Mark Active" (primary, toggles based on currentstatus), "Archive" (danger,window.confirm-gated). All three status-mutating actions call a sharedpatchStatus(status)that doesawait updateRabbit(id, { status })thenqueryClient.setQueryData(['rabbit', id], updated)— updates the badge and button labels in place without a full page reload or reusinginvalidateQueries(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 computedgrid-template-columnsas2px 902pxinstead 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 columnlg:w-[360px] lg:shrink-0, right columnmin-w-0 flex-1) which rendered correctly on the first try. Avoid arbitrary-valuegrid-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.confirmfired 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.
- Layout bug found mid-step: the left/right columns were initially built with
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. Renderssire.name × dam.name(falling back to "Unknown" for either side — same joined{ id, name }shapeGET /api/littersreturns), kindle date via the new sharedformatDatehelper, a singular/plural-aware kit count, and "View Kits" (→/litters/:id) / "Edit" (→/litters/:id/edit) buttons. ExtractedformatDate(was a private function duplicated nowhere else yet, but about to be needed in three places this phase) fromRabbitDetail.jsxintolib/format.js, and updatedRabbitDetail.jsxto 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 newEditLitter.jsxpage, and factored the form fields shared between it andAddLitter.jsxintoclient/src/components/LitterForm.jsx(mirrors howRabbitDetailsFormis shared betweenAddRabbit/EditRabbit). - Validated: rendered with a hardcoded litter prop missing
sire— showed "Unknown × Daisy"; both buttons'hrefs matched the litter's real ID.
- 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
-
Step 9.2: Built
client/src/pages/Litters.jsx.useQuery(['litters'], getLitters)feeds a verticalspace-y-3list ofLitterCards — no client-side re-sort needed, sinceGET /api/littersalready sortskindle_datedescending (Step 3.9). "Record Litter" sits inPageHeader'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.
- Validated: with two seeded test litters, both appeared newest-first; the header's "Record Litter" link resolved to
-
Step 9.3: Added
client/src/components/RabbitAutocomplete.jsx— a standalone, reusable version of the search dropdown embedded inPedigreeTree.jsx'sAncestorSlot(controlledvalue: {id, name} | null, sex-filtered viaGET /api/rabbits/search, "Mark as Unknown" to clear). BuiltLitterForm.jsxaround 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 tonullon submit).AddLitter.jsxreads?sire=<id>/?dam=<id>viauseSearchParams, fetches each viagetRabbit(gated byenabled: Boolean(id)), and passes the resolved{id, name}asLitterForm's initial sire/dam — showing a loading placeholder until both resolve, the same patternEditRabbit.jsxuses for its ancestors fetch. Submitting callscreateLitterthen 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.
- 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
-
Step 9.4: Built
client/src/pages/LitterDetail.jsx. Header title is"${sire.name} × ${dam.name}", subtitle the formatted kindle date. A statsCardshows Sire/Dam asLinks to/herd/:id(or muted "Unknown" text if unset), the kindle date, and the expected wean date — computed askindle_date + 56 daysvia a localaddDayshelper (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 fromGET /api/litters/:id's already-derivedkitsarray — no separate query or re-derivation on the frontend. "Add Kit" (header action) links to/herd/addwithsire/dam/dobquery params built from the litter's own sire/dam IDs andkindle_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.jsxnow reads?sire=/?dam=/?dob=viauseSearchParams.dobseedsdetails.dobdirectly in the initialuseState.sire/damare resolved viagetRabbit(gateduseQuerys, same loading-guard pattern asEditRabbit.jsx) and used to build aPedigreeTreeinitialStatewith those slots pre-set tomode: '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/addwith the correctsire/dam/dobparams, 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 viaPATCH /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.
- 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),
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_countkitRabbitrows server-side, instead of requiring the manual "Add Kit" flow for every kit. Implemented inroutes/litters.ts'sPOST /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 newsettingsrows for this:kit_prefix(user-editable — added toroutes/settings.ts'sALLOWED_KEYS, so the eventual Phase 10 Settings form can write it with no backend changes) andkit_counter(internal bookkeeping, deliberately not inALLOWED_KEYS— it's not a user preference, just the last-used sequence number). Both seeded indb/client.ts'sdefaultSettings(kit_prefix: '',kit_counter: '0'). Ifkit_prefixis empty (the default, until Phase 10 builds a way to set it), generated names fall back toKit1,Kit2, etc. - Implementation:
generateKits(sireId, damId, dob, count)runs in adb.transaction(...)— reads both settings rows, loopscounttimes incrementing the in-memory counter and inserting a bareRabbitrow (name,sireId,damId,dob; sex/status left at their schema defaults ofunknown/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
dobis set to the litter'skindle_dateexactly: 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/dobexact match, see Step 3.9/9.4) thatGET /api/litters/:idalready uses to populate itskitsarray. 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'skit_countviaPATCH(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: 3and nokit_prefixset — gotKit1/Kit2/Kit3,kit_counteradvanced to3. Setkit_prefixto"RAG"and created a second litter withkit_count: 2— gotRAG4/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/addform via Playwright (installed temporarily, removed after) — submitting the form producedRAG6/RAG7on the resulting litter detail page's kit table, with zero console errors. Cleaned up all seeded test rabbits/litters and manually resetkit_prefix/kit_counterback to their defaults afterward (the latter has no API for resetting since it's intentionally not user-editable, so this required a directbetter-sqlite3script rather thanPATCH /api/settings).
- Naming/counter design (clarified with the user before implementing): names are
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 directsetState-in-render-body pattern used byAddRabbit.jsx's farm default andEditRabbit.jsx's seeding (auseRefflag gates a one-timesetFormcall once the query resolves, instead of auseEffect). Fields: Rabbitry Name (text input), Weight Display (two-button segmented control mirroringRabbitDetailsForm's Sex control styling,decimal_lbs/lbs_oz), and Kit Prefix (text input, wired to thekit_prefixsetting added in the post-Phase-9 auto-generated-kits work — already inroutes/settings.ts'sALLOWED_KEYS, so no backend change was needed here). "Save Settings" callsupdateSettings(form)(a singlePATCHwith 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.5ssetTimeoutin auseEffectkeyed on the toast message) rather than extracting a new shared component for a single call site — same "page-specific UI stays in the page" conventionHerd.jsx'sFilterBar/SearchBoxalready 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 directPATCH /api/settingscall afterward.
- Toast: no shared toast component existed yet. Built a minimal one locally inside
-
Step 10.2: Added
GET /api/export(server/routes/export.ts, mounted inindex.ts) — callsres.download(dbPath, ...)wheredbPathis the live SQLite file path, now exported fromdb/client.tsalongside the existingdataDir/uploadsDirexports (previously only used internally to construct theDatabaseconnection). Filename ismykindlebox-backup-<YYYY-MM-DD>.dbusing today's ISO date;Content-Type: application/octet-streamis passed explicitly viares.download'sheadersoption since Express's automatic mime lookup doesn't recognize the.dbextension. AddedexportDatabaseUrl()toclient/src/api.js(returns the URL string rather than fetching — the Settings page navigateswindow.location.hrefto 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 -: confirmedContent-Disposition: attachment; filename="mykindlebox-backup-2026-06-20.db"andContent-Type: application/octet-streamheaders, and that the downloaded bytes are a valid SQLite file (filereportedSQLite 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.
- Validated the endpoint directly via
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">) inclient/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 vianode, then deleted; it's not part of the build pipeline.- Validated:
curl'dmanifest.jsonandicon-192.pngthrough the Vite dev server (200, correct JSON fields,image/pngcontent-type) and confirmed the renderedindex.htmlincludes 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.
- Validated:
-
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 inclient/src/main.jsxonwindow.load. Added anOfflineBannertoclient/src/components/Layout.jsx— afixed-positioned top banner toggled by theonline/offlinewindowevents, shown app-wide rather than threaded through every page's query error state, since every page already has its ownisErrorfallback (no crash either way).- Validated via Playwright (installed temporarily, removed after): loaded the app, confirmed
navigator.serviceWorker.controllerbecame 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/herdwhile 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.
- Validated via Playwright (installed temporarily, removed after): loaded the app, confirmed
-
Step 11.3: Added a global
@media (prefers-reduced-motion: reduce)block toclient/src/index.csszeroinganimation-duration/animation-iteration-count/transition-duration/scroll-behavior(all!important) for every element, rather than auditing and tagging each individualanimate-pulse/transition-colorsusage withmotion-reduce:variants across the codebase.- Validated via Playwright (
page.emulateMedia({ reducedMotion: 'reduce' })): confirmed a loading skeleton's.animate-pulseelement computedanimationDurationof1e-05sand a nav link's computedtransitionDurationwas likewise collapsed, both under the emulated preference.
- Validated via Playwright (
-
Step 11.4: Two changes. (1) Added a global
:focus-visible { outline: 2px solid theme('colors.accent') !important; outline-offset: 2px; }rule toindex.css—!importantis necessary because several inputs/buttons already carryfocus:outline-none(paired with their ownfocus:ring-2), which otherwise wins on CSS specificity over a bare:focus-visiblerule and would leave keyboard focus invisible on exactly those elements. This covers every focusable element app-wide without auditing each component individually. (2) Addedclient/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 bothPedigreeTree.jsx'sAncestorSlotandRabbitAutocomplete.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-widthcomputed as the accent green (rgb(74, 103, 65)) /solid/2pxon focus across aLink-wrapped header button, aFilterBarsex-tab button, the sort<select>, the search<input>, and a sidebarNavLink. 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. Rannpm run buildclean and confirmed no new ESLint errors were introduced (the 5 pre-existingreact-hooks/refs/react-hooks/set-state-in-effecterrors inAddRabbit.jsx/Settings.jsx/PedigreeTree.jsxpredate this phase — see the "one-shot default-fill" and mount-only-cascade-fetch conventions inarchitecture.md).
- Validated via Playwright: confirmed
Phase 11 — Polish and PWA is now complete. This was the last phase in the base implementation plan.