56 KiB
56 KiB
MyKindlebox — Architecture
Audience: AI developers. This file explains what each file/directory does and why, as the codebase grows. Keep it current — update after each major feature or milestone per CLAUDE.md.
Repo layout
mykindlebox/
server/ — Express + TypeScript REST API, SQLite via Drizzle ORM
client/ — React + Vite frontend
docker-compose.yml — orchestrates both services for self-hosted deployment
mykindlebox/server
| File | Purpose |
|---|---|
index.ts |
Entry point. Creates the Express app, applies cors() (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and express.json(), imports db/client (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001. |
db/client.ts |
Opens the SQLite connection and exports the Drizzle db instance used by all query code. Ensures server/data/ exists before opening the file — better-sqlite3 will not create missing parent directories on its own. The DB file lives at server/data/mykindlebox.db. On startup it now (Step 2.5) calls drizzle-orm/better-sqlite3/migrator's migrate() to apply any pending migrations, then seeds the two default settings rows (rabbitry_name, weight_unit) via raw INSERT OR IGNORE keyed on the key primary key — idempotent across restarts. Also exports dataDir and uploadsDir (data/uploads/, created here) so other modules (routes/rabbits.ts's multer config, index.ts's static file serving) share one canonical path instead of each recomputing it. |
db/schema.ts |
Drizzle schema definitions: rabbits (24 base fields incl. acquired_date/death_date — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority _name_text ancestor columns; sex/status enforced via SQLite CHECK constraints, not just TS union types; sire_id/dam_id self-referencing FKs), litters (sire_id/dam_id FKs to rabbits.id, breed_date, kindle_date, kit_count, notes, created_at — a litter row spans the breeding→kindle lifecycle: breed_date is set when a breeding is recorded, kindle_date is nullable until the doe kindles; at least one of the two is required, enforced in the route), rabbit_events (rabbit_id FK with ON DELETE cascade, type (show/sale/other, CHECK-constrained), event_date/title notNull, notes, plus show_class/placement which are only meaningful for type='show' — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and settings (key text primary key, value text — simple key-value store, not a typed config object). The rabbits table also carries a nullable breed column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). arba_shows_cache (id, fetched_at, data — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see lib/arba.ts + routes/shows.ts). show_events (name notNull, notes, created_at/updated_at — a user-created grouping of ARBA shows; no user_id, single-user app) and show_event_shows (show_event_id FK with ON DELETE cascade, plus a snapshot of the ARBA row at add-time: sanction_number/club_name notNull, show_date/show_type/specialty_breed/city/state/address/secretary/secretary_email/catalog_url/maps_url/display_order — copied rather than referencing the cache because ARBA listings are transient). show_event_entries (a herd rabbit entered into a specific show: show_event_show_id FK ON DELETE cascade, rabbit_id FK to rabbits without cascade, entry_class/entry_number/notes free text, created_at). show_event_judges (show_event_show_id cascade FK, judge_name notNull, judge_arba_number/notes). show_event_placements (one per entry — entry_id is a UNIQUE cascade FK, making the PUT endpoint a clean upsert; placement free text, legs_earned int 0–3, bob/bos/bis/ris boolean-mode integers, notes). The Shows feature's data model is now complete (Phases 1–4). User accounts (FEATURE_User_Accounts.md): users (username notNull UNIQUE — case-sensitive index, case-insensitive enforcement lives in lower() queries; password_hash notNull "scrypt:<salt>:<hash>"; nullable per-user theme/weight_unit prefs — NULL inherits the global settings value; created_at) and auth_sessions (token text PK, user_id FK with a declarative-only cascade — the app runs with SQLite foreign_keys OFF, so routes/users.ts deletes sessions explicitly; expires_at ISO, purged lazily on session checks). |
db/migrations/ |
Generated by drizzle-kit generate, applied either manually via drizzle-kit migrate (CLI) or automatically on server startup via db/client.ts's in-code migrate() call. Both share the same __drizzle_migrations tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (0000_modern_power_pack.sql — rabbits, 0001_military_lord_tyger.sql — litters, 0002_luxuriant_the_stranger.sql — settings) — these are committed and intentionally not excluded by .dockerignore (only node_modules and data are). |
drizzle.config.ts |
Points drizzle-kit at ./db/schema.ts for migration generation and ./data/mykindlebox.db as the target DB. |
tsconfig.json |
TypeScript config: CommonJS module output (matches package.json's "type": "commonjs"), strict mode on, compiles to dist/. |
package.json scripts |
dev — tsx watch index.ts (hot-reload local dev, no separate build step needed). build — tsc compiles to dist/. start — runs the compiled output, used in production/Docker. |
Dockerfile |
Builds a Node 20 Alpine image, installs deps, runs npm run dev (tsx) inside the container — not yet switched to a production build for the container; revisit when deploying for real. |
.dockerignore |
Excludes node_modules and data/ from the Docker build context. |
routes/rabbits.ts |
Express Router mounted at /api/rabbits in index.ts. GET / (list, with sex/status/search/sort filters — sex=kits is a special case matching any sex with dob in the last 56 days), GET /search (autocomplete by substring on name only, must be registered before GET /:id), GET /quick-search (the global top-of-page quick-search bar — distinct from /search: a prefix match — "wildcard at the end" — across a chooseable subset of farm/name/ear_number/color via ?fields= comma-list, defaulting to all four; returns each hit plus a matched_fields array re-derived in JS so the dropdown can label which filter the row flagged on. Empty q or no valid fields → []. Also must be registered before GET /:id), GET /:id (404 if missing), POST / (validates name, 201 on success), PATCH /:id (partial update, refreshes updated_at), DELETE /:id (nulls referencing sire_id/dam_id before deleting), GET /:id/ancestors (nested 4-generation pedigree tree — see chain-authority implementation note below; each node carries id/name/farm/sex/ear_number/weight_lbs/dob/color_name — ear_number/weight_lbs/dob were added so the printable pedigree slip can show full per-ancestor detail), POST /:id/photo (multer disk upload, JPEG/PNG/WebP only, invoked manually inside the handler so fileFilter rejections become this route's own 400 rather than the global error handler's 500). |
routes/litters.ts |
Express Router mounted at /api/litters. GET / and GET /:id join rabbits twice (via alias()) to embed sire/dam { id, name }. GET /:id derives its kits list from rabbits matching sire_id/dam_id/dob (the same no-Kit-table derivation the design doc specifies elsewhere). POST / (requires a breed_date or kindle_date; only generates kits when a kindle_date is present), PATCH /:id, DELETE /:id. Recording a breeding (POST with breed_date, no kindle_date) creates a litter-in-progress with no kits yet. |
routes/events.ts |
Express Router mounted at /api/events (behind requireAuth). Manually-logged timeline events (show/sale/other). GET /?rabbit_id=X (required filter — no all-events view; ordered oldest-first so the client can splice them straight into the timeline), POST / (requires rabbit_id/type/event_date/title; a sale also flips the rabbit's status to sold in the same transaction as the insert), PATCH /:id (drops rabbit_id and type from the patch — an event keeps its rabbit and kind), DELETE /:id. Same mass-assignment guard + snake/camel boundary conversion as the other routers. |
routes/shows.ts |
Express Router mounted at /api/shows (behind requireAuth) — Phase 1 of the Shows feature (browse-only). GET /arba returns the cached ARBA listing as { fetched_at, stale, shows: [...] }, re-scraping first if the cache is missing or older than 24h; if a needed scrape fails but a cache exists, it serves the stale cache (flagged stale: true) rather than erroring (only a cold miss returns 502). POST /arba/refresh forces a re-scrape, leaving the old cache intact on failure. The cache is a single row in arba_shows_cache (delete-then-insert in a transaction). Show objects are stored camelCase (from lib/arba.ts) and converted to snake_case at the boundary like every other route. |
routes/showEvents.ts |
Express Router mounted at /api/shows/events (behind requireAuth) — Phase 2 of the Shows feature. Event CRUD (GET / list with derived show_count/first_show_date/last_show_date from one grouped aggregate query, newest-updated_at first; GET /:id returns the event + its snapshotted shows in date order; POST /, PATCH /:id, DELETE /:id) plus POST /:id/shows (snapshots an ARBA show into the event — accepts the snake_case ARBA show object GET /api/shows/arba returns and maps it: secretary_email←email, show_type←show_type_full, show_date←date‖raw_date, specialty_breed←special_breed; idempotent per (event, sanction_number) — re-adding returns the existing row with 200) and DELETE /:id/shows/:showId. Add/remove also bump the event's updated_at. Mounted before /api/shows in index.ts so /api/shows/events/* resolves here, not to the ARBA browser router. Phase 3 (entries) adds: GET /:id now embeds an entries array per show (each entry left-joined to its rabbit's name/ear_number/breed/color_name/sex via entriesByShow); POST/PATCH/DELETE /:id/shows/:showId/entries[/:entryId] (PATCH only touches entry_class/entry_number/notes — never the rabbit or which show); and GET /:id/report, which builds the plain-text entries report ({ report }) — one section per show that has entries, owner line from settings.rabbitry_name, ARBA membership # deliberately omitted (resolved decision), dates rendered MM/DD/YYYY. Phase 4 (judges + placements) adds: GET /:id also embeds a judges array per show and a placement object (or null) per entry; POST/PATCH/DELETE /:id/shows/:showId/judges[/:judgeId]; and PUT /:id/shows/:showId/entries/:entryId/placement — a full-object upsert keyed on the entry's UNIQUE placement row (legs clamped 0–3, the four flags coerced to booleans). |
lib/arba.ts |
The ARBA scraper/normalizer. fetchArbaShows() fetches https://arba.net/show-search/ and parses it with cheerio. Two non-obvious constraints, both load-bearing (see convention note below): (1) ARBA sits behind Cloudflare — the request MUST send browser-like User-Agent + Accept headers or it gets a block page (we deliberately don't set Accept-Encoding, letting global-fetch own decompression); (2) the page is a WordPress Ninja Tables table whose ~2,000 rows are server-rendered as plain positional <td> cells (the cells carry no column classes — only the <thead> does), so rows are read by cell index 0–10. parseShowInformation() splits the showInformation string (CLUB, (OPEN|YOUTH) (ALL BREED|SPECIALTY) [FAIR] SHOW[, BREED]) by anchoring on the show-type phrase (club names contain commas) into clubName/showType bucket/showTypeFull/specialBreed. Cavy shows (CAVY anywhere in showInformation) and non-US rows (state not in the 50+DC allowlist — excludes Canadian provinces and blanks) are dropped at normalization. Throws on a Cloudflare block / non-OK / missing table so the route can fall back to cache. |
routes/settings.ts |
Express Router mounted at /api/settings. GET / flattens the settings table into a single object; PATCH / rejects any request containing a key outside the rabbitry_name/weight_unit allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly. |
routes/stats.ts |
Express Router mounted at /api/stats. Returns dashboard counts (herd, bucks, does, kits, for_sale, on_hold, sold, archived) computed live from rabbits on every request — no cached counters. kits reuses the same 56-day dob window as the rabbits list's sex=kits filter; archived sums status IN ('other', 'deceased'). |
routes/import.ts |
Express Router mounted at /api/import (behind requireAuth). POST / accepts a single multipart file (multer memory storage, 10 MB cap) and replaces the entire herd: it wipes litters then rabbits and inserts the file's rabbits, all inside one db.transaction so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: legacy CSV (the old PHP Kindlebox export — header has ID/GENDER/SIRE_ID), native CSV (snake_case headers matching the API), and JSON (a rabbit array or { rabbits: [...] }). Legacy mapping: GENDER 1→buck/0→doe; numeric STATUS via LEGACY_STATUS_MAP (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original editrabbit.php status <select>); "0"/blank sentinels for ear/registration numbers → null; WEIGHT of 0 → null (unweighed); gene columns → locus_*. Parentage is preserved by importing original ids and a two-pass insert (insert all rows with null sire_id/dam_id, then set the FK only when it points at an id actually present in the file — legacy 9999 "unknown parent" sentinels and references to non-imported rabbits become null). Includes a hand-rolled RFC-4180-ish parseCsv (quoted fields, "" escapes, CR/LF, BOM strip) rather than adding a CSV dependency, matching the project's dependency-light ethos. Returns { format, rabbits_imported }. Scope is rabbits only (per user decision) — litters are wiped but not reconstructed from the legacy LITTER_ID column. Rejects non-UTF-8 input early (file.buffer.includes(0) — a NUL byte means a UTF-16 Excel "Unicode Text" export or a binary .xlsx/image) with an actionable message, since decoding such a file as UTF-8 would otherwise import garbage/phantom rows. The Settings page also pre-validates extension + 10 MB size before uploading, because an over-limit file is rejected by multer mid-stream and the browser surfaces that as an opaque "network error" rather than the server's 400. |
utils/case.ts |
toSnakeCase/toCamelCase — generic recursive key-casing converters used at the API boundary. See casing convention below. |
Why TypeScript: the implementation plan specifies db/client.ts for the Drizzle setup. The server started as plain CommonJS JS in Step 1.2; it was converted to TypeScript at Step 2.1 specifically so the schema/DB layer has type safety, per explicit user decision (plain JS was the lower-effort alternative but not chosen).
mykindlebox/client
| File | Purpose |
|---|---|
src/App.jsx |
Root component. Wraps the app in QueryClientProvider (React Query) and BrowserRouter, defining every IA route nested under a single Layout layout-route (<Route element={<Layout />}>) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all * → NotFound route. |
src/api.js |
Plain JS (not .ts — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, events CRUD (getEvents/createEvent/updateEvent/deleteEvent), settings, stats, export, importData which POSTs a FormData file to /api/import, shows (getArbaShows/refreshArbaShows), show events (getShowEvents/getShowEvent/createShowEvent/updateShowEvent/deleteShowEvent/addShowToEvent/removeShowFromEvent), show entries + report (addShowEntry/updateShowEntry/removeShowEntry/getEventReport), judges + placements (addShowJudge/updateShowJudge/removeShowJudge/setShowEntryPlacement)), all targeting http://localhost:3001/api. Shared request() helper throws on non-2xx (surfacing the JSON { error } body's message) and only sets Content-Type: application/json when the body is a JSON string, so the multipart FormData photo-upload call isn't given the wrong header. |
src/components/Layout.jsx |
Persistent app shell rendered via React Router's <Outlet />. Desktop: 240px left sidebar (hidden md:flex) with NavLinks for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (≤768px): fixed-bottom nav (md:hidden) with Dashboard/Herd/Litters/Add (→ /herd/add)/Settings. Active-route styling comes from NavLink's isActive callback, not manual path matching. Also renders <QuickSearch /> at the top of the <main> content area (above <Outlet />), so the quick-search bar appears on every page. |
src/components/QuickSearch.jsx |
Global quick-search bar (top of every page via Layout). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), all selected by default, controlling which fields GET /api/rabbits/quick-search prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per matched_fields entry (value-bearing chips for ear/color, e.g. Ear #: A15). Selecting a result navigates to /herd/:id. Reuses lib/keyboardNav.js's handleSearchInputKeyDown/handleResultKeyDown for the same <ul><li><button> roving-focus dropdown shape as RabbitAutocomplete/PedigreeTree. The debounce effect deliberately does not call setState synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show. |
src/components/Button.jsx |
variant (primary/secondary/danger) × size (sm/md) class-map component. |
src/components/Badge.jsx |
Status → color/label class map covering all six Rabbit status values; unrecognized values fall back to the other style rather than crashing. |
src/components/Card.jsx |
Bordered surface-raised container with shadow; just forwards className/props. |
src/components/PageHeader.jsx |
Title + optional subtitle + optional right-side action slot. |
src/components/EmptyState.jsx |
Centered title + optional description + optional action, used by list pages with no results. |
src/pages/*.jsx |
One file per IA route (Dashboard, Herd, AddRabbit, RabbitDetail, EditRabbit, Litters, AddLitter, LitterDetail, Genetics, Settings, NotFound). Dashboard.jsx is built out (Phase 5): a stats bar (8 Card tiles from GET /api/stats, each linking to /herd with a sex or status query param), a quick-actions row (Buttons linking to /herd/add, /litters/add, /herd), and a two-column (md:grid-cols-2) widget row with Recent Litters (top 3 by kindle_date from GET /api/litters) and Upcoming Birthdays (a user-requested addition not in the original plan — computed client-side from GET /api/rabbits?status=active's dob field, no backend support; see the "Upcoming Birthdays" convention note below). Herd.jsx is built out (Phase 6): a sex/sort/search/view filter bar synced to URL query params (?sex=&sort=&q=&view=), and a RabbitGrid/RabbitListView pair sharing one useQuery over GET /api/rabbits — see the Herd filtering convention note below. AddRabbit.jsx (Phase 7) is a 3-step wizard (Pedigree → Details+Genetics → Review) composing the Step 7.1–7.4 components, holding details/genetics/pedigreeFields state and calling createRabbit then (if a photo was picked) uploadRabbitPhoto on save. EditRabbit.jsx (Phase 7) is a single page with all three sections visible, pre-populated from GET /api/rabbits/:id + GET /api/rabbits/:id/ancestors, sending only the changed fields via PATCH — see the pedigree convention note below for why it must wait on the ancestors fetch before mounting PedigreeTree. All other pages remain placeholders rendering just a heading — real content for them starts in Phase 8+. |
src/pages/Shows.jsx |
The /shows ARBA show browser (Shows feature Phase 1 — browse-only, no event/entry actions yet). One useQuery(['arba-shows']) over getArbaShows; a "Refresh Data" button mutates via refreshArbaShows and writes the result straight into the query cache (setQueryData). All filtering/pagination is client-side over the loaded list: State/District/Show Type multi-selects (an inline MultiSelect checkbox-popover component with outside-click close; State/District options derived from the data, Show Type a fixed 5-bucket list matching lib/arba.ts's ShowType), Date From (defaults to today) / Date To, and a keyword box (club/city/secretary). 50 rows/page. Reads the snake_case show fields the API returns (raw_date, club_name, show_type/show_type_full, special_breed, maps_url, catalog_url, etc.). |
src/pages/ShowEvents.jsx + src/pages/ShowEventDetail.jsx |
The Shows feature Phase 2 event UI. ShowEvents.jsx (/shows/events) lists events as cards (name, date range, show count) over getShowEvents, with an inline NewEventForm toggled by "New Event". ShowEventDetail.jsx (/shows/events/:id) shows the event header (inline EditEventForm for rename/notes, Delete with a window.confirm, "Add Shows" link back to /shows) and the Shows tab (the only tab in Phase 2 — judges/entries/placements come later): each ShowCard renders the snapshot (club, date, type, sanction #, location, secretary, mailto/map/catalog links) with a Remove button. Mutations invalidate ['show-events'] and ['show-event', id]. The "Add to Event" flow lives in components/AddToEventModal.jsx (launched per-row from Shows.jsx): pick an existing event or create one inline, then addShowToEvent; shows a success state with a link to the event. Phase 3 adds a Shows/Entries tab switch to ShowEventDetail.jsx: the Entries tab groups entries per show, with an "Add Rabbit" picker (components/RabbitPicker.jsx — a modal that client-side searches the herd by name/tattoo/breed/variety and disables rabbits already entered in that show), inline-editable entry_class/notes (saved on blur via updateShowEntry), a remove action, and an "in multiple shows" badge for a rabbit entered across more than one show in the event. "Generate Entries Report" opens components/ReportModal.jsx, which fetches getEventReport and offers Copy to Clipboard. Phase 4 adds a third Results tab plus: a JudgesSection on each Shows-tab card (list + inline add/remove of judges), and a per-entry PlacementEditor (toggled by an "Add/Edit results" link in the Entries tab) — placement text, a legs +/- counter (0–3), and BOB/BOS/BIS/RIS checkboxes that persist immediately via setShowEntryPlacement (the full-object PUT; legs/checkboxes save on change, text on blur). The Results tab (ResultsTab) is a read-only rollup aggregated client-side from the embedded placements — one card per rabbit, sorted by total legs, with leg/flag badges. |
src/pages/PedigreePrint.jsx |
Standalone print-optimized 4-generation pedigree slip at /herd/:id/pedigree, deliberately routed outside <Layout> (in App.jsx) so the sidebar/bottom-nav don't render or print. Reuses GET /api/rabbits/:id/ancestors + GET /api/settings (rabbitry name) + getRabbit. Modeled on the original Kindlebox PHP slip. Renders a rabbitry header band (rabbitry name, subject farm/name, printed-on date) above a CSS-grid bracket layout (subject column + parents + grandparents + great-grandparents, each generation's row-spans aligning it against the next). The leftmost column stacks a fill-in Sold To block, the subject box (color, ear number, DOB, weight, sex), and a certification + Signed line — mirroring the old slip. Each ancestor box shows farm+name, color, ear, weight, and DOB via a nodeInfo() helper that normalizes linked DB nodes, manual-JSON nodes (parsed via parseManualAncestor), and missing nodes (→ "Unknown"); fmtDob renders MM-DD-YYYY (or "Unknown"), fmtWeight omits unset/zero weights. The user produces the PDF via the browser's Print → Save as PDF — browser print was the chosen method over a bundled PDF library to keep the self-hosted footprint dependency-free. A component-scoped <style> block sets @page { size: landscape } (scoped to this mounted page so it doesn't leak to other routes), hides the on-screen Back/Print buttons (.no-print) when printing, and forces print-color-adjust: exact so the accent header band prints. A "Print Pedigree" button on RabbitDetail.jsx's action bar links here. |
src/components/RabbitTimeline.jsx |
The rabbit-detail timeline (a Card in RabbitDetail.jsx's right column). Merges derived events (birth from dob; per-litter "Bred with X" at breed_date and "Litter … kindled" at kindle_date, derived from the ['litters'] query filtered to this rabbit) with stored events (acquired_date/death_date off the rabbit row; logged show/sale/other events from GET /api/events), sorts them by ISO date with a per-type order tiebreaker (EVENT_TYPES[*].order), and renders a vertical rail. Logged events are the editable ones: an "Add Event" button opens an inline EventForm with a type dropdown (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through createEvent/updateEvent/deleteEvent then invalidateQueries(['events', id]); a sale additionally invalidates ['rabbit'] + ['stats'] because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
src/components/RabbitCard.jsx |
Used by Herd.jsx's grid view. Photo or inline SVG silhouette placeholder, name, color_name • Sex, age • weight, and a hardcoded 0 litters • 0 kits line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in progress.md before wiring this up for real). |
src/lib/format.js |
formatAge(dob) (years/months string or "Age unknown"), formatWeight(weightLbs) ("X.XX lbs" or null), SEX_LABELS. Shared between RabbitCard.jsx and Herd.jsx's list view — add any future age/weight formatting here rather than duplicating. |
src/lib/ocrPreprocess.js |
OCR Pedigree Scanning, Phase 2. Canvas-only (no jimp) image normalization for OCR. Exports preprocessImage(file) — decode → downscale to MAX_WIDTH (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG Blob. Also exports the tunable MAX_WIDTH and BINARIZE_THRESHOLD (binarization cutoff, tuned to 103 during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
src/lib/pedigree.js |
Pedigree slot definitions (GEN1/GEN2/GEN3, 14 ancestor slots mapping 1:1 to the schema's _name_text columns) and the client-side chain-authority resolver. resolveEffectiveNode(path, state, cascadeCache) mirrors server/routes/rabbits.ts's resolveAncestorNode — see the pedigree convention note below. Also exports seedPedigreeStateFromRabbit/cascadeFromAncestors (used by EditRabbit.jsx to pre-populate from an existing record) and buildPedigreePayload/buildPedigreeSummary (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). serializeManualAncestor/parseManualAncestor/formatManualAncestorDisplay encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same _name_text column — see the manual-ancestor convention note below. |
src/lib/genetics.js |
LOCI (option config for all seven loci pill-groups) and generateColorString(loci), which combines A/B/D/E/C/En into a single description string (vv and cc short-circuit to "BEW"/"REW"). Also hasAnyLocusSet (drives GeneticsForm's default-expanded state on Edit). |
src/components/PedigreeTree.jsx |
Self-contained pedigree input (Phase 7 Step 7.1) — manages its own state/cascadeCache and reports out via onChange(fields, summary) rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional initialState/initialCascade for EditRabbit.jsx. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
src/components/RabbitDetailsForm.jsx |
Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a weightUnit prop; photo selection is reported via a separate onPhotoChange(file) callback since it's always a second request (uploadRabbitPhoto) after the rabbit row exists. |
src/components/GeneticsForm.jsx |
Collapsible pill-group genetics section (Step 7.3) over lib/genetics.js's LOCI config, with a read-only generated color string field. |
src/components/ReviewStep.jsx |
Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared dash() helper for empty values. |
src/lib/theme.js |
Theme registry (post-Phase-11 feature) — the single source of truth for the list of selectable themes (classic/midnight/ocean/berry/sunset/carnival), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome theme-color. The actual palettes are CSS variables in index.css (one [data-theme='…'] block per theme); this module only knows ids + preview metadata. applyTheme(id) sets document.documentElement.dataset.theme (which swaps the variables), updates the theme-color meta, and mirrors the id to localStorage (key mykindlebox-theme). normalizeTheme falls back to classic for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below. |
src/lib/breeds.js |
The ARBA-recognized rabbit breed list (ARBA_BREEDS) powering the breed picker, plus isCustomBreed(breed) (true for a non-empty value not on the list). Suggestion list only — BreedSelect keeps a free-text fallback, so a breed not on this list still saves. Add new ARBA breeds here. |
src/components/BreedSelect.jsx |
The breed picker (combobox): a text input bound directly to value (so anything typed is a valid free-text breed) with a filtered dropdown of ARBA_BREEDS underneath for guided selection. Reuses lib/keyboardNav.js for the same arrow-key roving focus as RabbitAutocomplete/PedigreeTree. Shows a "Custom breed" hint when the value isn't an ARBA breed. Used by RabbitDetailsForm.jsx (the rabbit Breed field) and Settings.jsx (the Default Breed setting). |
src/lib/keyboardNav.js |
handleSearchInputKeyDown/handleResultKeyDown (Phase 11 Step 11.4) — arrow-key roving focus for the <ul><li><button> search-results dropdown shape shared by PedigreeTree.jsx's AncestorSlot and RabbitAutocomplete.jsx. ArrowDown/ArrowUp move between the input and result buttons, Escape closes the dropdown and refocuses its trigger button. |
src/index.css |
Tailwind's three @tailwind directives, plus (Phase 11) a global :focus-visible accent-outline rule (!important — see the focus-ring convention note below) and a prefers-reduced-motion: reduce media query zeroing all animation/transition durations app-wide. |
index.html |
Loads Google Fonts (DM Sans + Inter) via <link> tags, applies font-body to <body> as the default typeface, and (Phase 11) links manifest.json plus a theme-color meta tag. |
public/manifest.json |
PWA manifest (Phase 11 Step 11.1) — name/short_name/start_url/display: standalone/background_color/theme_color plus one 192×192 icon (icon-192.png, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
public/sw.js |
Service worker (Phase 11 Step 11.2) registered from main.jsx on window.load. Network-first with a cache (mykindlebox-shell-v1) fallback for the app shell; explicitly ignores /api/* and /uploads/* so API calls fail fast offline (surfaced via Layout.jsx's OfflineBanner) instead of serving stale cached JSON silently. OCR addition: the five vendored /tesseract/ assets (see public/tesseract/ below) are precached on install (a TESSERACT_URLS list alongside the existing SHELL_URLS, same cache) and served cache-first via a dedicated fetch-handler branch — they're large/static/versioned, so once stored they're served straight from cache with no network round-trip (the rest of the shell stays network-first). See the OCR-assets convention note below. |
public/tesseract/ |
Vendored Tesseract OCR runtime (OCR Phase 1 Step 1.2), served from the app's own origin so the pedigree scanner makes no CDN calls and works offline. Five files: worker.min.js (the tesseract.js web-worker script); eng.traineddata (the uncompressed English LSTM language data); and three feature-detect WASM core builds — tesseract-core-lstm.wasm.js, tesseract-core-simd-lstm.wasm.js, tesseract-core-relaxedsimd-lstm.wasm.js (each ~3.9 MB, self-contained — the WASM is base64-embedded in the .wasm.js). At runtime tesseract.js (default OEM LSTM_ONLY) feature-detects and loads exactly one of the three cores; all three are vendored so a directory-based corePath auto-selects correctly on any browser. See the OCR-assets convention note below. |
tailwind.config.js |
Defines the ten design-token colors (surface, border, accent, etc., sourced from app-design-document.md) and the two font families (font-display → DM Sans, font-body → Inter). Deliberately Tailwind v3, not v4 — v4 replaced JS-based theme config with CSS @theme blocks, which doesn't match how the implementation plan expects tokens to be configured. |
postcss.config.js |
Standard Tailwind + Autoprefixer PostCSS pipeline (generated by tailwindcss init -p). |
Dockerfile |
Multi-stage production build: a Node 20 Alpine stage runs npm ci && npm run build, then an nginx 1.27 Alpine stage serves the built dist/ with nginx.conf (which reverse-proxies /api + /uploads to the server container and serves the SPA fallback + sw.js no-cache header). The Vite dev server is never run in Docker. |
Root
| File | Purpose |
|---|---|
docker-compose.yml |
Two services, server (port 3001) and client (port 5173), each built from their own Dockerfile. A named volume server-data is mounted at /app/data in the server container so the SQLite file persists across container rebuilds. Not yet validated — Docker isn't installed on the dev machine; correctness here is unconfirmed until someone runs docker compose up. |
Key conventions to remember when extending this code
- Chain-authority rule (pedigree ancestors): a
_name_textfreeform column is only ever written/read when the immediate parent slot has no FK. Once a chain resolves through a real DB record, all deeper data comes from that ancestor's own columns, never the subject's flat columns. This is encoded in therabbitsschema as 14 plain nullable text columns (Step 2.2) — the DB schema itself doesn't enforce the rule, application code must. Implemented inroutes/rabbits.ts'sresolveAncestorNode(record, path): it walks a direction path (['sire','dam',...]) one step at a time, recursing into the FK'd DB row whenever one exists for the current step, and only consulting the current record's flat column when the path still has remaining depth but no FK to recurse through. Because recursion always switches the "current record" to the fetched ancestor, the original root's flat columns become structurally unreachable as soon as any FK in the path resolves — there's no separate "is broken" flag to get out of sync. Any future write path (the rabbit details/pedigree form, Phase 7) must preserve this: write a_name_textcolumn only when the immediate parent FK for that slot is null, and clear deeper-generation flat columns for a branch once a FK is set for an ancestor in that branch (or at minimum, never read them — the read path already ignores them correctly). - Migrations are file-based via
drizzle-kit, not auto-generated at runtime. After editingdb/schema.ts, runnpx drizzle-kit generate(writes a new SQL file todb/migrations/). Applying them happens automatically on server startup (db/client.tscallsmigrate()) — runningnpx drizzle-kit migrateby hand also works and shares the same tracking table, but is no longer required day-to-day. - No separate Kit table — kits are full
Rabbitrows; a litter's kit list is derived by matchingsire_id/dam_id/dobagainst the litter, not via a join table. - Settings is a simple key-value table (
settings), not a typed config object in code. Seeded withrabbitry_name/weight_unit/kit_prefix/theme/default_breeddefaults (plus the non-editablekit_counter) on every startup viaINSERT OR IGNORE. The editable allowlist lives inroutes/settings.ts'sALLOWED_KEYS.default_breedpre-fills the Breed field on new rabbits only —AddRabbit.jsxseedsdetails.breedfrom it via the same one-shotdetailsDefaultedref guard that defaultsfarmfromrabbitry_name(Edit never overrides an existing breed). - API JSON casing: Drizzle schema field names are camelCase (idiomatic JS/TS), but the REST API's request/response JSON bodies are snake_case, matching the design doc and implementation plan's literal examples (
weight_lbs,sire_id, etc.). Every route handler must convert: incoming bodies viatoCamelCasebefore passing to Drizzle, outgoing rows viatoSnakeCasebeforeres.json(). Don'tres.json()a raw Drizzle row. - Upcoming Birthdays (Dashboard widget, added post-Phase-5, not in the implementation plan or design doc): there is intentionally no
/api/birthdaysendpoint.Dashboard.jsx'sgetUpcomingBirthdays()fetches active rabbits and computes the next occurrence of eachdob's month/day client-side, windowed to 30 days. If this logic is ever needed elsewhere (e.g. a notifications feature), lift it intoapi.jsor a backend endpoint rather than duplicating the date math. - Herd "Archived" filter:
?status=archivedis a frontend-only convenience value, not a realstatuscolumn value — it mirrors the sameother+deceasedgroupingroutes/stats.tsuses for the Dashboard's Archived count.Herd.jsxstripsstatus=archivedbefore calling the backend (which only does exact-match status filtering) and instead filters the response client-side tostatus IN ('other', 'deceased'). Any other status value passed in the URL goes straight to the backend unmodified. - URL-synced debounced inputs: don't sync a prop into local state via a
useEffectthat callssetState— this project's ESLint config (react-hooks/set-state-in-effect) treats that as an error.Herd.jsx'sSearchBoxis the established pattern: give the input componentkey={externalValue}from the parent so an external change (e.g. browser back/forward updating the URL) remounts the component and re-initializes its local state from props, instead of reconciling it via an effect. - One-shot default-fill from async data: when a query result should seed local state exactly once (e.g.
AddRabbit.jsxdefaultingdetails.farmfromsettings.rabbitry_name, orEditRabbit.jsxseedingdetails/geneticsfrom the loaded rabbit), callsetStatedirectly in the render body guarded by a condition that becomes false after the first call (auseRefflag, ordetails === null) — this is the React-docs-sanctioned "adjusting state during render" pattern, not the banneduseEffect-based prop-sync. It's distinct from theSearchBoxcase above because there's nothing to re-sync on every external change, only a single seed. - Pedigree chain-authority on the client (
lib/pedigree.js, used byPedigreeTree.jsx/AddRabbit.jsx/EditRabbit.jsx): mirrors the backend'sresolveAncestorNode(see the server-side note above) viaresolveEffectiveNode(path, state, cascadeCache)— a node is "locked" (non-interactive, cascade-derived) whenever its immediate parent's effective node ismode: 'linked', sourcing its value from that ancestor's ownAncestorNodesubtree (fetched viaGET /api/rabbits/:id/ancestors, cached by id) instead of any state on the subject. Because therabbitstable has no FK column belowsire_id/dam_id, an editable gen2/gen3 node's "Search existing" option can only ever save freeform text (the matched rabbit's name) — there is no column to link it to. When seedingPedigreeTreefor editing,EditRabbit.jsxmust wait for the ancestors fetch to resolve before mounting it: a linked gen1 node's display name comes fromancestors.sire.name/ancestors.dam.name, not from the raw rabbit record (which only hassire_id, no name) — mountingPedigreeTreeearly renders a blank name for an otherwise-correctly-linked Sire/Dam (this was caught by Playwright during Step 7.6 validation, seeprogress.md). - Manually-entered ancestors store structured data, not a bare name (per explicit user request, post-Phase-7): each
_name_textcolumn is still a single text column (no schema migration), but "Enter manually" now collects Farm Name, Rabbit Name, and Ear Number (all required — the Save button is disabled until all three are non-empty) plus Weight (defaults to0) and DOB (defaults to the literal string"Unknown", since a real ancestor record's actual DOB is virtually never known). These five fields are JSON-encoded into the_name_textcolumn viaserializeManualAncestor. Every place that reads a manual node'snamefor display (thePedigreeTreeslot button/locked-node text,buildPedigreeSummary'sdisplayfield used byReviewStep) must run it throughformatManualAncestorDisplayfirst, which renders"Name (Farm) • Ear #X"— never rendernode.nameraw for a manual node, it's JSON.parseManualAncestoris defensive: a plain non-JSON string (a legacy entry, or hand-edited in the DB) is treated as just thenamefield with the other four defaulted, so old data doesn't break. Re-opening an already-manual node's editor (PedigreeTree.jsx's buttononClick) callsparseManualAncestorto restore all five fields for further editing, rather than starting blank.- DOB is a date picker + separate "Unknown" checkbox in the form, but one string in storage: the manual-entry form's local state keeps
dob(a real date-input value) anddobUnknown(the checkbox, checked by default) as two separate fields — they only collapse into the single stored string ("Unknown"or the real date) at save time.manualFormFromParsed(parsed)is the inverse: given a parsed ancestor record, it setsdobUnknown: trueand blanks the date input whenever the storeddobis"Unknown"(or missing), otherwise populates the date input and unchecks the box. Don't bind the date<input type="date">directly to a value that might be the string"Unknown"— it's not a valid date and the input will silently show blank, which is why the split exists. - Weight and DOB need real
<label>elements, not placeholders: every other manual-entry field (Farm/Name/Ear Number) relies onplaceholdertext since they start empty, but Weight always has a default value (0) andtype="date"inputs don't reliably render placeholders at all — aplaceholderattribute on either is effectively invisible. Use a<label>above the input instead.
- DOB is a date picker + separate "Unknown" checkbox in the form, but one string in storage: the manual-entry form's local state keeps
- Focus ring is a global CSS rule, not per-component classes (Phase 11 Step 11.4):
index.css's:focus-visible { outline: 2px solid theme('colors.accent') !important; ... }is the single source of the accent-colored keyboard focus ring across the whole app. The!importantis required — many inputs/buttons already carryfocus:outline-none(paired with their ownfocus:ring-2), which has higher CSS specificity than a bare:focus-visibleselector and would otherwise win, leaving keyboard focus invisible on exactly those elements. Don't try to remove this rule or "clean it up" into per-componentfocus-visible:utility classes without re-auditing every interactive element for a visible ring first. - Search-dropdown keyboard nav lives in
lib/keyboardNav.js, not duplicated per component:PedigreeTree.jsx'sAncestorSlotandRabbitAutocomplete.jsxboth render an identical<input>+<ul><li><button>results dropdown. Both wire theironKeyDownhandlers to the samehandleSearchInputKeyDown/handleResultKeyDownexports rather than each implementing their own arrow-key logic. Any third component with this same dropdown shape should reuse these instead of re-deriving roving focus. - Theming is CSS-variable swaps driven by
data-theme, not a JS theme object (post-Phase-11 feature): the ten design tokens intailwind.config.jsresolve to CSS variables (var(--color-surface)etc.) rather than literal hex.src/index.cssdefines each theme as a[data-theme='…']block overriding those ten variables (:root= the default Classic palette); settingdocument.documentElement.dataset.themere-themes every existing utility class with no markup changes, which is why adding a theme is purely: a CSS block inindex.css+ an entry insrc/lib/theme.js'sTHEMESarray (keep the two id lists in sync, including the inline allowlist array inindex.html). No alpha modifiers (bg-accent/20) are used on theme tokens, so the simplevar(--x)format suffices — if you ever need one, the variables must switch to space-separated RGB channels + thergb(var(--x) / <alpha-value>)format. Persistence is dual: the backendsettings.themerow (added toroutes/settings.ts'sALLOWED_KEYSand seeded'classic'indb/client.ts) is the cross-device source of truth;localStorageis only a fast-path cache so the inline<script>inindex.htmlcan apply the last-known theme before first paint (avoiding a flash of the default palette).App.jsx'sThemeSync(a render-nothing child of the providers) reconciles the document withsettings.themeonce that query resolves.Settings.jsx's picker callsapplyThemeon click for instant live preview, but only Save persists it to the backend — an un-saved preview lingers inlocalStorage/the current tab until the next backend reconcile, which is acceptable. The:focus-visiblerule'stheme('colors.accent')now compiles tovar(--color-accent), so the focus ring themes correctly too.- Per-widget color (Dashboard stat tiles) is a second tier of tokens on top of the ten base ones:
index.css's:rootdefines--stat-1..--stat-8(+ matching--stat-N-hover) plus--stat-fg/--stat-fg-muted, all defaulting through the base tokens (var(--color-surface-raised)/surface-sunken/text-primary/text-secondary) so by default every tile looks like a plain raised Card in every theme. Only thecarnivaltheme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here.Dashboard.jsx'sSTAT_CARDScarries the binding as literalbg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value usetext-[var(--stat-fg-muted)]/text-[var(--stat-fg)]. To add another widget-coloring theme, override these stat vars in its[data-theme]block; to color a different widget, add a new--…-token tier the same way rather than hardcoding hex.
- Per-widget color (Dashboard stat tiles) is a second tier of tokens on top of the ten base ones:
- Rabbit timeline = derived events + column-backed dates + a generic events table (
RabbitTimeline.jsx, post-Phase-11 feature): the per-rabbit timeline is built inbuildEvents()by merging three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) Derived (no storage): birth (rabbit.dob) and breedings/kindles (thelitterswhere this rabbit is sire/dam — one "Bred with {partner}" event perbreed_date, one "… kindled" event perkindle_date). Because breedings are auto-derived, the "Add Event" dropdown deliberately omits Breeding — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) Column-backed: acquired and death are nullable date columns onrabbits(acquired_dateonly renders when set and different fromdob, since a same-day acquired date is just birth), edited through the normal rabbitPATCH(they're inEditRabbit.jsx'sDETAIL_KEYSand rendered byRabbitDetailsForm.jsx). (3) Logged events: therabbit_eventstable (routes/events.ts) holdsshow/sale/other— the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. NOTE the history here: this started as ashows-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized torabbit_events(migrations0005_drop_shows+0006_add_rabbit_events— drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A sale event also flips the rabbit'sstatustosoldserver-side (so the client invalidates['rabbit']/['stats']after one). Sorting is lexicographic on the ISO date string withEVENT_TYPES[type].orderas the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death). - ARBA scraping (Shows feature) — the show listing comes from scraping
https://arba.net/show-search/, and two facts about that page are load-bearing; don't "simplify" them away (lib/arba.ts): (1) ARBA is behind Cloudflare — a bare request returns a "you have been blocked" challenge page, so the scraper sends browserUser-Agent+Acceptheaders (and deliberately omitsAccept-Encodingso undici/global-fetch negotiates + decompresses). (2) The data is a WordPress Ninja Tables table; its ~2,000 rows are server-rendered into the page HTML as positional<td>cells (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-<a>, secretary, email, catalog-<a>). It was tempting to hit the Ninja Tablesadmin-ajax.phpdata endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parsesshowInformationinto club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached inarba_shows_cache(one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. All four phases are built (the Shows feature is feature-complete permemory-bank/mykindlebox-shows-design-doc.md): Phase 1 = browse + cache (routes/shows.ts,Shows.jsx); Phase 2 = show events + the "Add to Event" flow (routes/showEvents.ts,ShowEvents.jsx/ShowEventDetail.jsx/AddToEventModal.jsx); Phase 3 = entries + entries-report (show_event_entries, the entry/report endpoints, the Entries tab +RabbitPicker.jsx/ReportModal.jsx); Phase 4 = judges + placements + Results tab (show_event_judges/show_event_placements, the judge/placement endpoints, theJudgesSection/PlacementEditor/ResultsTabUI). Not built (explicitly out of scope or deferred): the design doc's optional Report Settings (custom header/footer, toggles), bulk multi-select "Add selected to Event" from the browser (single per-row add is implemented instead), and judge editing UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc'suser_id/userstable andtattoo/varietyfields don't exist here — single-user, withear_number/color_name/thebreedcolumn instead. - OCR/Tesseract assets are vendored, not CDN-fetched, and the
eng.traineddatais stored uncompressed (OCR Pedigree Scanning feature,public/tesseract/): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives underpublic/tesseract/and the worker must be configured with explicit local paths. Two load-bearing facts when wiring upocrRun.js(OCR Phase 3): (1)gzip: falseis required alongsidelangPath: '/tesseract/', because the vendoredeng.traineddatais decompressed — the defaultgzip: truemakes the worker requesteng.traineddata.gz, which 404s. (2)corePathshould point at the directory'/tesseract/', not a specific file, so tesseract.js'swasm-feature-detectstep picks the matching core (relaxedsimd-lstm→simd-lstm→lstm) for the running browser; all three-lstm.wasm.jscores are vendored for exactly this reason (the app uses the defaultLSTM_ONLYOEM, so the non--lstm/.asmcores are never requested and aren't vendored).workerPathis'/tesseract/worker.min.js'. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch intesseract.js/src/worker-script/browser/getCore.js. The service worker precaches + cache-firsts these (seepublic/sw.jsabove). - Auth is account-based and always on (
FEATURE_User_Accounts.md, replacing the original optional shared-password mode): accounts live inusers, sessions inauth_sessions(so restarts don't sign anyone out), passwords are scrypt-hashed vianode:crypto(deliberately not bcrypt/argon2 — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created throughPOST /api/auth/setup, which only works while theuserstable is empty — the client'sAuthProvidershows aSetupScreenwhenGET /api/auth/statusreportsconfigured: false. Any signed-in user manages users at/api/users(Settings → Users card); you can't delete yourself, which transitively protects the last account./api/settingskept its exact single-object shape but storage split:theme/weight_unitread/write the caller'susersrow (NULL falls back to the global settings value), the rest stay in thesettingstable — client consumers (ThemeSync,useWeightUnit, the Settings page) required no changes. The oldAUTH_PASSWORD/MYKINDLEBOX_PASSWORDenv var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping. - Offline handling is one app-wide banner, not per-query state:
Layout.jsx'sOfflineBannerlistens to theonline/offlinewindowevents directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its ownisErrorfallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" within a specific page's content (not just a banner), that's new logic, not an extension of this banner.