# MyKindlebox — Progress Log **Audience:** AI developers picking up this codebase later. --- ## Phase 1 — Project Scaffolding (complete, validated) - **Step 1.1**: Created `mykindlebox/server`, `mykindlebox/client`, and root `README.md`. - **Step 1.2**: Initialized the backend with Express. Originally `index.js` (CommonJS), later converted to TypeScript in Step 2.1 prep (see below). `GET /health` returns `{ "status": "ok" }`. Validated against a running server on port 3001. - **Step 1.3**: Scaffolded the frontend with `npm create vite@latest -- --template react`, removed the default Vite boilerplate (counter button, logos, docs/social sections) from `App.jsx`/`App.css`, and replaced with a minimal centered "MyKindlebox" heading. Installed React Router v7 (`react-router-dom@7`) — not yet wired into routes. - **Step 1.4**: Installed **Tailwind v3** (not v4) deliberately — v4 moved theme config into CSS (`@theme`), but the plan's Step 1.4/1.5 assume a classic `tailwind.config.js` object for custom tokens, so v3 was chosen to match. Added the ten design tokens (`surface`, `surface-raised`, `surface-sunken`, `border`, `text-primary`, `text-secondary`, `accent`, `accent-hover`, `accent-warm`, `danger`) to `tailwind.config.js` theme.extend.colors, sourced from `app-design-document.md`. Validated visually (cream background, dark text). - **Step 1.5**: Loaded DM Sans + Inter via Google Fonts `` tags in `client/index.html`. Mapped `font-display` → DM Sans and `font-body` → Inter in `tailwind.config.js`. Applied `font-body` to `
`. Validated both fonts render correctly via devtools computed style. - **Step 1.6**: Added `docker-compose.yml` at the repo root with `server` and `client` services, plus a named volume (`server-data`) mounted at `/app/data` in the server container for the SQLite file. Added a `Dockerfile` to each of `server/` and `client/`, plus `.dockerignore` files excluding `node_modules`. - **Not validated** — Docker is not installed on this development machine. The user will validate `docker compose up` manually later. Do not assume this step is confirmed working until that happens. ## Phase 2 — Database (in progress) - **Step 2.1**: Installed `drizzle-orm` and `better-sqlite3` (+ `@types/better-sqlite3`) in `server`. - **TypeScript decision**: The plan names the file `db/client.ts`, which doesn't fit a plain-JS server. Per explicit user choice, the backend was converted to TypeScript at this point: `index.js` → `index.ts`, added `typescript`, `tsx` (used for `npm run dev` via `tsx watch`), `@types/node`, `@types/express`, and a `tsconfig.json` (CommonJS module output, `strict: true`, builds to `dist/`). `npm run build` (tsc) + `npm run start` (node dist/index.js) are the production path; `npm run dev` is tsx watch mode for local dev. - Created `server/db/client.ts`: ensures `server/data/` exists (`fs.mkdirSync(..., { recursive: true })` — better-sqlite3 does **not** create missing parent directories itself), opens `server/data/mykindlebox.db`, and exports a Drizzle `db` instance. - `index.ts` imports `./db/client` so the DB file is created/opened on server startup. - Validated: deleted `data/`, restarted server, confirmed `data/mykindlebox.db` was recreated with no errors. - **Step 2.2**: Defined the `rabbits` table in `server/db/schema.ts` — all 24 base fields from the data model (including self-referencing `sire_id`/`dam_id` FKs and `created_at`/`updated_at` timestamp defaults), the 7 genetics loci columns, and all 14 chain-authority `_name_text` ancestor columns (2 parents, 4 grandparents, 8 great-grandparents). `sex` and `status` are enforced with SQLite `CHECK` constraints rather than just TypeScript types, so invalid values are rejected at the DB layer too. - **Migration tooling decision**: Installed `drizzle-kit` (not previously in the plan's Step 2.1 install list) and added `server/drizzle.config.ts` pointing at `./db/schema.ts` with migrations output to `server/db/migrations/`. Generated the initial migration (`0000_modern_power_pack.sql`) via `npx drizzle-kit generate` and applied it via `npx drizzle-kit migrate`. This establishes the migration-file workflow that Step 2.5 (auto-run migrations on startup) will hook into — `db/client.ts` does not yet run migrations automatically. - Validated: inspected `server/data/mykindlebox.db` directly — all 38 columns present, FKs on `sire_id`/`dam_id` present, and the `sex_check`/`status_check` CHECK constraints correctly reject invalid values while accepting valid ones (verified with manual inserts, then cleaned up). - **Step 2.3**: Added the `litters` table to `server/db/schema.ts` (`id`, `sire_id`/`dam_id` FKs to `rabbits.id`, `kindle_date`, `kit_count`, `notes`, `created_at`). Generated migration `0001_military_lord_tyger.sql` and applied via `npx drizzle-kit migrate`. - Validated: inspected the DB directly — `litters` table present with both FK constraints registered against `rabbits.id`. - **Step 2.4**: Added the `settings` table (`key` text primary key, `value` text) to `server/db/schema.ts`. Generated migration `0002_luxuriant_the_stranger.sql`. - Seeding logic (raw `INSERT OR IGNORE`, since Drizzle has no native upsert-on-conflict-do-nothing seed helper used elsewhere in this codebase yet) lives in `db/client.ts`, added as part of Step 2.5 below — seeds `rabbitry_name` → `"My Rabbitry"` and `weight_unit` → `"decimal_lbs"`. - **Step 2.5**: Wired `drizzle-orm/better-sqlite3/migrator`'s `migrate()` into `db/client.ts` so all pending migrations run automatically on startup, before the settings seed step. Both `drizzle-kit migrate` (CLI, used manually in Steps 2.2/2.3) and this in-code `migrate()` share the same `__drizzle_migrations` tracking table, so there's no double-application risk between the manual and automatic paths. - Validated: deleted `server/data/`, ran the startup path — `rabbits`, `litters`, and `settings` tables were all recreated and `settings` contained exactly the two seed rows. Ran it again without deleting — no errors, settings still exactly two rows (seed uses `INSERT OR IGNORE` keyed on the `key` primary key, so it's idempotent). Confirmed `GET /health` still returns `{ "status": "ok" }` with this startup logic in place. **Phase 2 — Database is now complete.** ## Phase 3 — Backend API (in progress) - **Step 3.1**: Added a request logger middleware (`${method} ${path}` to console) as the first `app.use` in `index.ts`, a catch-all 404 handler (`{ "error": "Not found" }`) after all routes, and a 4-arg Express error-handling middleware last in the chain returning HTTP 500 with `{ "error": "Internal server error" }`. - Validated: added a temporary `GET /__test-error` route that synchronously threw, confirmed it produced HTTP 500 with the correct JSON body and was logged via `console.error`, then removed the route. Confirmed `GET /nonexistent` returns HTTP 404 with the correct body and is logged by the request logger. Confirmed `GET /health` still returns 200 after the test route was removed. - **Step 3.2**: Created `server/routes/rabbits.ts` (an Express `Router`) with `GET /api/rabbits`, mounted at `/api/rabbits` in `index.ts`. Supports `sex` (`buck`/`doe`/`unknown`, or the special `kits` value — matches any sex with `dob` within the last 56 days, computed in JS as an ISO cutoff date and compared with `gte`), `status` (exact match), `search` (SQLite `LIKE '%...%'` on `name`, case-insensitive by default for ASCII), and `sort` (`name`/`dob`/`weight_lbs`/`created_at`, default `name`, always ascending). Conditions are combined with `and(...)` only when present, so an empty query returns all rows. - Validated: with an empty DB, the endpoint returned `[]`. Inserted two rabbits directly via `better-sqlite3` (one buck "Jimmy", one doe "Daisy") — `GET /api/rabbits` returned both, `?sex=buck` returned only Jimmy, and `?search=jim` (case-insensitive) returned only Jimmy. Cleaned up the test rows afterward. - **Step 3.3**: Added `GET /api/rabbits/:id` to `server/routes/rabbits.ts`. Returns the full rabbit record for a valid ID, or HTTP 404 with `{ "error": "Rabbit not found" }` if no row matches. - Validated: inserted a test rabbit directly, confirmed `GET /api/rabbits/:id` with its real ID returned the full record, and `GET /api/rabbits/99999` returned 404 with the expected body. Cleaned up the test row afterward. - **Step 3.4**: Added `POST /api/rabbits`. Validates `name` is present and non-empty (HTTP 400 + `{ "error": "Name is required" }` if not), inserts the record, and returns it with HTTP 201. - **JSON casing decision**: Drizzle's schema field names are camelCase (`weightLbs`, `sireId`, etc. — see `db/schema.ts`), but the implementation plan's API examples and the design doc use snake_case (`weight_lbs`, `sire_id`). Per explicit user choice, the REST API boundary uses snake_case for both request and response bodies, matching the plan/design doc literally. Added `server/utils/case.ts` (`toSnakeCase`/`toCamelCase`, generic key-mapping, not a per-field allowlist) and applied it: responses are converted camelCase → snake_case before `res.json()`, incoming bodies are converted snake_case → camelCase before being passed to Drizzle. Retrofitted the Step 3.2/3.3 GET endpoints' responses to use this too — they were not yet snake_case before this step. - `express.json()` body parsing was added globally in `index.ts` (not per-route), since later POST/PATCH endpoints across rabbits/litters/settings will all need it. - Validated: `POST /api/rabbits` with `{ "name": "Test Rabbit", "sex": "buck" }` returned HTTP 201 with the new record (snake_case keys, `id` assigned). A subsequent `GET /api/rabbits/:id` with that ID returned the identical record. `POST /api/rabbits` with `{ "sex": "buck" }` (no name) returned HTTP 400 with the expected error body. Cleaned up the test row afterward. - **Step 3.5**: Added `PATCH /api/rabbits/:id`. Accepts a partial snake_case body, converts it via `toCamelCase`, and updates only the provided fields plus `updated_at` (set to the current ISO timestamp on every PATCH — the column has an insert-time default but nothing previously kept it current on update). Returns the full updated record, or HTTP 404 with `{ "error": "Rabbit not found" }` if `update(...).returning().get()` finds no matching row. - Validated: created a rabbit via POST, sent `PATCH { "weight_lbs": 3.5 }`, confirmed the response had `weight_lbs: 3.5` with every other field unchanged (and `updated_at` advanced past `created_at`). `PATCH` to a nonexistent ID returned 404 with the expected body. Cleaned up the test row afterward. - **Step 3.6**: Added `DELETE /api/rabbits/:id`. Checks existence first (404 if missing), then nulls out `sire_id`/`dam_id` on any rabbit referencing the target via two `update` calls, then deletes the row, returning HTTP 204. - Validated: created a sire and a child rabbit (child's `sire_id` pointing at the sire). Deleted the sire — got 204. Confirmed the child's `sire_id` was now `null` and the sire 404s on a subsequent GET. Confirmed deleting a nonexistent ID also returns 404. Cleaned up test rows afterward. - **Step 3.7**: Added `GET /api/rabbits/:id/ancestors` to `server/routes/rabbits.ts`, returning a nested 4-generation pedigree tree (`{ ...root fields, sire: AncestorNode|null, dam: AncestorNode|null }`, each `AncestorNode` itself having `sire`/`dam` children down to depth 3). - **Implementation approach**: `resolveAncestorNode(record, path)` takes a rabbit row and a direction path (e.g. `['sire','sire','dam']` = paternal grandsire's dam) and walks it generically: at each step, if the current record's FK for that direction is set, it fetches that DB row and recurses into *it* with the remaining path; only when an FK is unset and the path still has remaining depth does it fall back to the current record's own flat `_name_text` column (via `flatAncestorText`, a switch on the joined path matching the 12 gen2/gen3 column names). This means the chain-authority rule falls directly out of the FK-presence check at each recursive step, rather than needing separately-tracked "is this branch broken" state — once a path recurses into a real DB row, that row's own columns are authoritative for everything deeper in that branch, and the root's flat columns are structurally unreachable from that point on. The full tree is assembled by calling this resolver independently for all 14 ancestor slot paths, then wiring each computed node's `.sire`/`.dam` to the next-deeper computed node. - Validated all three scenarios from the plan: (1) built a full 15-rabbit unbroken chain (root → 2 parents → 4 grandparents → 8 great-grandparents) via direct DB inserts and confirmed all 14 ancestors appeared at the correct nested positions; (2) broken-chain case — root with `sire_id` null, `sire_name_text = "Bob's Buck"`, `paternal_grandsire_name_text = "Old Bob"` — confirmed `sire` resolved to `{ id: null, name: "Bob's Buck" }` and `sire.sire` resolved to `{ id: null, name: "Old Bob" }`; (3) unbroken-chain-with-stale-data case — root with a real `sire_id` pointing at a sire with no further ancestors, plus a stale `paternal_grandsire_name_text` directly on the root — confirmed `sire.sire` was `null`, correctly ignoring the root's stale flat column and reading from the real Sire record's (empty) ancestry instead. Ran `npx tsc --noEmit` to confirm no type errors (tsx watch only transpiles, doesn't typecheck). Cleaned up all test rows afterward. - **Step 3.8**: Added `GET /api/rabbits/search` (registered *before* `GET /:id` in `routes/rabbits.ts` — Express matches routes in registration order, and `/:id` would otherwise greedily match the literal string `"search"` as an id). Selects only `id`/`name`/`farm`/`sex` via Drizzle's partial `.select({...})`, filters on `q` (case-insensitive `LIKE` on `name`) and optional `sex`, sorted by name, capped at `.limit(10)`. - Validated: with "Jimmy", "Jimbo", "Daisy", and an unrelated rabbit in the DB, `?q=jim` returned only the two Jim-prefixed bucks; `?q=&sex=buck` and `?q=&sex=doe` correctly restricted to each sex; each result had exactly the four specified fields. Inserted 12 additional matching rabbits and confirmed the response was capped at exactly 10. Cleaned up all test rows afterward. - **Step 3.9**: Created `server/routes/litters.ts`, mounted at `/api/litters`. `GET /` and `GET /:id` join `rabbits` twice via two `alias()`'d copies of the table (`sireRabbits`/`damRabbits`) to embed `{ id, name }` for sire/dam (`null` if unset) rather than bare IDs, sorted by `kindle_date` descending for the list. `GET /:id` additionally derives its `kits` array by querying `rabbits` for rows matching the litter's `sire_id`/`dam_id` (using `isNull` when a parent is unset, since `eq(col, null)` is always false in SQL) and `dob` equal to the litter's `kindle_date` — same derivation approach as the design doc's no-Kit-table convention, just reused here instead of in the dashboard stats. `POST /` requires `kindle_date` (400 + `{ "error": "Kindle date is required" }` otherwise), `PATCH /:id` does a partial update, `DELETE /:id` returns 204 (404 if missing). POST/PATCH return the plain litter row (snake_case, no joined names) rather than the joined shape — only the GET endpoints need the embedded sire/dam objects. - Validated: created a sire and dam rabbit, then a litter referencing both via `POST /api/litters` — appeared correctly in `GET /api/litters` with joined `sire`/`dam` name+id objects. Added a rabbit with matching `sire_id`/`dam_id`/`dob` and confirmed it appeared in `GET /api/litters/:id`'s `kits` array. Deleted the litter and confirmed a subsequent `GET` 404s. Ran `npx tsc --noEmit` clean. Cleaned up all test rows afterward. - **Step 3.10**: Created `server/routes/settings.ts`, mounted at `/api/settings`. `GET /` flattens the `settings` key-value rows into a single JSON object. `PATCH /` rejects the whole request with HTTP 400 + `{ "error": "Unknown setting: