Compare commits

..

No commits in common. "5d29e2b37003acb9d377a860300bc9c749efee10" and "afff8388b092cb43adcf99625e19f872a588d213" have entirely different histories.

13 changed files with 346 additions and 743 deletions

View File

@ -62,12 +62,10 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `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/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/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/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. **Responsive layout:** at `≥1280px` (`useMediaQuery`, `hooks/useMediaQuery.js`) it renders the 3-column tree; below that the same `AncestorSlot`s render as a stacked, indented list (`StackedBranch`) in "Sire's side"/"Dam's side" cards, so phones never scroll sideways. Popup drop-direction accounts for the bottom chrome (action bar + mobile nav). | | `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. Primary fields (Name, Farm, Ear #, Sex, DOB, Breed, Color, Weight, Photo) come first; Status/Location/Acquired/Death sit in a "More details" section that `collapseSecondary` (Add flow only) starts closed. The name error only shows when `showValidation` is set (Add sets it after a failed Next). | | `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. Optional `onUseColor(color)` adds a "Use as color name" button (Add + Edit wire it to `details.color_name`). | | `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. Formats values for display (sex/status labels, `formatDate`, `formatWeight` in the user's unit), hides empty rows (Sire/Dam always shown; deeper ancestors only when set) and shows genetics as "Not set" when empty. Each section has an Edit button calling `onEdit(step)`. Action buttons live in `AddRabbit`'s `StickyActionBar`, not here. | | `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/components/StickyActionBar.jsx` | Bottom-pinned form action row (sticky above the mobile bottom nav, `bottom-0` on desktop). Used by the Add Rabbit flow for Back/Next/Save. |
| `src/pages/AddRabbit.jsx` (flow notes) | The current step is a `?step=` URL param (pushed, so the Back gesture moves between steps; step 3 falls back to 2 without a name). All steps stay mounted and are hidden with CSS so `PedigreeTree`'s internal state survives going back / Review "Edit". Step 1 has an optional name field feeding the tree's root label. A `beforeunload` guard warns on reload/close with unsaved input (in-app link navigation is not guarded — `BrowserRouter` has no `useBlocker`). On save, a failed photo upload still navigates to the created rabbit with a `location.state.notice` (shown by `RabbitDetail`) instead of leaving the user on Review, where re-saving would create a duplicate. |
| `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/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/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/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). |

View File

@ -90,7 +90,7 @@ and reverse-proxies `/api` + `/uploads` to the Node server. The SQLite database
and uploaded photos live in the `server-data` Docker volume. and uploaded photos live in the `server-data` Docker volume.
To serve on a different host port, set `MYKINDLEBOX_PORT` (e.g. in a `.env` file To serve on a different host port, set `MYKINDLEBOX_PORT` (e.g. in a `.env` file
next to `docker-compose.yml`): next to `docker-compose.yml`, alongside `MYKINDLEBOX_PASSWORD`):
```env ```env
MYKINDLEBOX_PORT=3000 MYKINDLEBOX_PORT=3000
@ -100,51 +100,6 @@ Then re-run `docker compose up -d` — the app is served on that port instead of
8080. (Only the host side changes; inside the compose network nginx still 8080. (Only the host side changes; inside the compose network nginx still
listens on 80.) listens on 80.)
### Updating an existing deployment
Compose builds from whatever is checked out on the host, so pull first, then
rebuild. Run from the repo root on the server:
```bash
# 1. Back up the database (schema migrations run automatically on startup)
cd mykindlebox
docker compose cp server:/app/data ./data-backup-$(date +%F)
# 2. Pull the latest code
cd ..
git checkout main
git pull
git log --oneline -1 # confirm you're on the commit you expect
# 3. Rebuild and restart both services
cd mykindlebox
docker compose up --build -d
```
**Verify both containers were recreated:**
```bash
docker compose ps
```
Both `client` and `server` should show a recent **CREATED** time (seconds or
minutes ago), and `server` should reach **(healthy)** within ~15 seconds. If
either still shows an old CREATED time, Compose reused a cached image — force
it:
```bash
docker compose build --no-cache server # or client
docker compose up -d --force-recreate server
```
The client and server must be from the same version; a new client against an
old server will fail on login and newer pages. If the server never becomes
healthy, check `docker compose logs server --tail 50`.
Finally, hard-refresh the browser (Ctrl+Shift+R). On an installed phone PWA,
fully close and reopen the app to pick up the new build. Once everything looks
right, the `data-backup-*` folder can be deleted.
### Authentication ### Authentication
The app uses **user accounts** — everyone shares the same herd; accounts are The app uses **user accounts** — everyone shares the same herd; accounts are
@ -161,9 +116,4 @@ last 30 days, surviving server restarts.
The herd database (`server/data/`), uploaded photos, and `.env` files are The herd database (`server/data/`), uploaded photos, and `.env` files are
**gitignored** — they are never committed. In production they persist in the **gitignored** — they are never committed. In production they persist in the
`server-data` volume; back that volume up to preserve your data. A quick `server-data` volume; back that volume up to preserve your data.
snapshot from a running stack:
```bash
docker compose cp server:/app/data ./data-backup-$(date +%F)
```

View File

@ -1,5 +1,3 @@
import { STATUS_LABELS } from '../lib/format'
const STATUS_CLASSES = { const STATUS_CLASSES = {
active: 'bg-accent text-surface', active: 'bg-accent text-surface',
for_sale: 'bg-accent-warm text-surface', for_sale: 'bg-accent-warm text-surface',
@ -9,6 +7,15 @@ const STATUS_CLASSES = {
other: 'bg-border text-text-primary', other: 'bg-border text-text-primary',
} }
const STATUS_LABELS = {
active: 'Active',
for_sale: 'For Sale',
on_hold: 'On Hold',
sold: 'Sold',
deceased: 'Deceased',
other: 'Other',
}
function Badge({ status }) { function Badge({ status }) {
return ( return (
<span <span

View File

@ -1,24 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { LOCI, generateColorString } from '../lib/genetics' import { LOCI, generateColorString } from '../lib/genetics'
function ChevronIcon({ open }) { function GeneticsForm({ value, onChange, defaultExpanded = false }) {
return (
<svg
viewBox="0 0 20 20"
className={`h-4 w-4 text-text-secondary transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<path d="m5 8 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// `onUseColor`, when given, adds a button that copies the generated color
// string into the rabbit's Color Name field.
function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor }) {
const [expanded, setExpanded] = useState(defaultExpanded) const [expanded, setExpanded] = useState(defaultExpanded)
const colorString = generateColorString(value) const colorString = generateColorString(value)
@ -31,26 +14,24 @@ function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor })
<button <button
type="button" type="button"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center justify-between px-4 py-3 font-body text-sm font-medium text-text-primary" className="flex w-full items-center justify-between px-4 py-3 font-body text-sm font-medium text-text-primary"
> >
<span>Genetics (optional)</span> <span>Genetics (optional)</span>
<ChevronIcon open={expanded} /> <span className="text-text-secondary">{expanded ? '▲' : '▼'}</span>
</button> </button>
{expanded && ( {expanded && (
<div className="space-y-3 border-t border-border px-4 py-4"> <div className="space-y-3 border-t border-border px-4 py-4">
{LOCI.map((locus) => ( {LOCI.map((locus) => (
<div key={locus.key} className="flex flex-col gap-1.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2"> <div key={locus.key} className="flex flex-wrap items-center gap-2">
<span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span> <span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{locus.options.map((opt) => ( {locus.options.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
aria-pressed={value[locus.key] === opt.value}
onClick={() => setLocus(locus.key, opt.value)} onClick={() => setLocus(locus.key, opt.value)}
className={`rounded-full border px-3 py-1.5 font-body text-sm md:py-1 md:text-xs ${ className={`rounded-full border px-3 py-1 font-body text-xs ${
value[locus.key] === opt.value value[locus.key] === opt.value
? 'border-accent bg-accent text-surface' ? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken' : 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
@ -67,25 +48,13 @@ function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor })
<label className="block font-body text-sm font-medium text-text-primary"> <label className="block font-body text-sm font-medium text-text-primary">
Generated color string Generated color string
</label> </label>
<div className="mt-1 flex gap-2">
<input <input
type="text" type="text"
readOnly readOnly
value={colorString} value={colorString}
placeholder="Select loci to generate a description" placeholder="Select loci to generate a description"
className="min-w-0 flex-1 rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary" className="mt-1 w-full rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary"
/> />
{onUseColor && (
<button
type="button"
disabled={!colorString}
onClick={() => onUseColor(colorString)}
className="shrink-0 rounded-md border border-border px-3 py-2 font-body text-sm font-medium text-text-primary hover:bg-surface-sunken disabled:cursor-not-allowed disabled:opacity-50"
>
Use as color name
</button>
)}
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -1,9 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { searchRabbits, getRabbitAncestors } from '../api' import { searchRabbits, getRabbitAncestors } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav' import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { import {
ALL_SLOTS,
GEN1, GEN1,
GEN2, GEN2,
GEN3, GEN3,
@ -23,32 +21,6 @@ const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0',
// mounts, so the very first click is positioned correctly. // mounts, so the very first click is positioned correctly.
const POPUP_MAX_HEIGHT = 360 const POPUP_MAX_HEIGHT = 360
// Viewport space at the bottom covered by fixed/sticky chrome (the Add Rabbit
// action bar, plus the bottom nav on phones), so a popup that would open
// underneath it flips upward instead.
function bottomChromeHeight() {
return window.matchMedia('(max-width: 767px)').matches ? 136 : 72
}
// The 3-column tree needs ~900px of content width; below that (phones,
// tablets, small laptops with the sidebar) the same slots render as a stacked,
// indented list instead of a sideways-scrolling tree.
const WIDE_TREE_QUERY = '(min-width: 1280px)'
const SLOT_BY_PATH = new Map(ALL_SLOTS.map((entry) => [entry.path.join('.'), entry]))
function LockIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 text-text-secondary" fill="currentColor" aria-label="Locked">
<path
fillRule="evenodd"
d="M10 2a4 4 0 0 0-4 4v2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-1V6a4 4 0 0 0-4-4Zm2 6V6a2 2 0 1 0-4 0v2h4Z"
clipRule="evenodd"
/>
</svg>
)
}
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown` // `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
// is the separate "Unknown" checkbox. They collapse into one stored string // is the separate "Unknown" checkbox. They collapse into one stored string
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor. // ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
@ -83,7 +55,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
function updateDropUp() { function updateDropUp() {
if (!triggerRef.current) return if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect() const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top const spaceAbove = rect.top
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow) setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
} }
@ -130,7 +102,9 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<p className="truncate font-body text-xs text-text-secondary">{label}</p> <p className="truncate font-body text-xs text-text-secondary">{label}</p>
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p> <p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
</div> </div>
<LockIcon /> <span aria-label="locked" className="text-text-secondary">
🔒
</span>
</div> </div>
) )
} }
@ -192,7 +166,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
{open && ( {open && (
<div <div
className={`absolute left-0 z-30 w-full min-w-[16rem] max-w-sm rounded-md border border-border bg-surface-raised p-2 shadow-lg ${ className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${
dropUp ? 'bottom-full mb-1' : 'top-full mt-1' dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`} }`}
> >
@ -200,14 +174,14 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<button <button
type="button" type="button"
onClick={() => setMode('search')} onClick={() => setMode('search')}
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`} className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
> >
Search existing Search existing
</button> </button>
<button <button
type="button" type="button"
onClick={() => setMode('manual')} onClick={() => setMode('manual')}
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`} className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
> >
Enter manually Enter manually
</button> </button>
@ -223,7 +197,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })} onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
placeholder="Search by name…" placeholder="Search by name…"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1" className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<ul className="mt-1 max-h-40 overflow-auto"> <ul className="mt-1 max-h-40 overflow-auto">
{results.map((rabbit, index) => ( {results.map((rabbit, index) => (
@ -239,7 +213,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
close, close,
}) })
} }
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1" className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken"
> >
{rabbit.name} {rabbit.name}
{rabbit.farm ? ` (${rabbit.farm})` : ''} {rabbit.farm ? ` (${rabbit.farm})` : ''}
@ -261,21 +235,21 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
value={manualForm.farm} value={manualForm.farm}
onChange={(e) => setManualField('farm', e.target.value)} onChange={(e) => setManualField('farm', e.target.value)}
placeholder="Farm Name *" placeholder="Farm Name *"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1" className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<input <input
type="text" type="text"
value={manualForm.name} value={manualForm.name}
onChange={(e) => setManualField('name', e.target.value)} onChange={(e) => setManualField('name', e.target.value)}
placeholder="Rabbit Name *" placeholder="Rabbit Name *"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1" className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<input <input
type="text" type="text"
value={manualForm.ear_number} value={manualForm.ear_number}
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())} onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
placeholder="Ear Number *" placeholder="Ear Number *"
className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent" className="w-full rounded border border-border px-2 py-1 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<div> <div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label> <label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
@ -284,7 +258,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
step="0.01" step="0.01"
value={manualForm.weight_lbs} value={manualForm.weight_lbs}
onChange={(e) => setManualField('weight_lbs', e.target.value)} onChange={(e) => setManualField('weight_lbs', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent" className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
<div> <div>
@ -294,7 +268,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
value={manualForm.dob} value={manualForm.dob}
disabled={manualForm.dobUnknown} disabled={manualForm.dobUnknown}
onChange={(e) => setManualField('dob', e.target.value)} onChange={(e) => setManualField('dob', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary" className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
/> />
</div> </div>
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary"> <label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
@ -316,7 +290,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
type="button" type="button"
onClick={saveManual} onClick={saveManual}
disabled={!canSaveManual} disabled={!canSaveManual}
className="w-full rounded bg-accent px-2 py-2 text-sm font-medium text-surface disabled:opacity-50 md:py-1.5 md:text-xs" className="w-full rounded bg-accent px-2 py-1 text-xs text-surface disabled:opacity-50"
> >
Save Save
</button> </button>
@ -326,7 +300,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<button <button
type="button" type="button"
onClick={markUnknown} onClick={markUnknown}
className="mt-2 w-full rounded px-2 py-2 text-left text-sm text-text-secondary hover:bg-surface-sunken md:py-1 md:text-xs" className="mt-2 w-full rounded px-2 py-1 text-left text-xs text-text-secondary hover:bg-surface-sunken"
> >
Mark as Unknown Mark as Unknown
</button> </button>
@ -355,29 +329,6 @@ function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }
) )
} }
// One ancestor plus, indented beneath it, its own sire/dam branches — the
// stacked (narrow-screen) rendering of the same slots the wide tree shows.
function StackedBranch({ path, slotProps }) {
const entry = SLOT_BY_PATH.get(path.join('.'))
const childPaths = [
[...path, 'sire'],
[...path, 'dam'],
].filter((p) => SLOT_BY_PATH.has(p.join('.')))
return (
<div className="flex flex-col gap-2">
<AncestorSlot path={entry.path} slot={entry.slot} label={entry.label} {...slotProps} />
{childPaths.length > 0 && (
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
{childPaths.map((childPath) => (
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
))}
</div>
)}
</div>
)
}
function PedigreeTree({ function PedigreeTree({
subjectName = 'New Rabbit', subjectName = 'New Rabbit',
initialState, initialState,
@ -390,7 +341,6 @@ function PedigreeTree({
// sets state synchronously (it's called from a mount effect below). An id is // sets state synchronously (it's called from a mount effect below). An id is
// never removed: on failure the cache stores null, and we don't retry. // never removed: on failure the cache stores null, and we don't retry.
const cascadeFetchStarted = useRef(new Set()) const cascadeFetchStarted = useRef(new Set())
const wide = useMediaQuery(WIDE_TREE_QUERY)
function setSlotState(slot, value) { function setSlotState(slot, value) {
setState((prev) => ({ ...prev, [slot]: value })) setState((prev) => ({ ...prev, [slot]: value }))
@ -420,24 +370,12 @@ function PedigreeTree({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
if (!wide) {
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
return ( return (
<div className="flex flex-col gap-4"> // The tree needs ~900px; on narrower screens the outer wrapper scrolls
{GEN1.map(({ path, slot }) => ( // sideways rather than the min-width stretching the whole page.
<section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm"> <div className="-mx-4 overflow-x-auto md:mx-0">
<h3 className="mb-2 font-display text-base font-bold text-text-primary"> <p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p>
{slot === 'sire' ? "Sire's side" : "Dam's side"} <div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]">
</h3>
<StackedBranch path={path} slotProps={slotProps} />
</section>
))}
</div>
)
}
return (
<div className="flex w-full items-center gap-6 p-4 pb-[300px]">
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center"> <div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span> <span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
</div> </div>
@ -445,6 +383,7 @@ function PedigreeTree({
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> <Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> <Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
</div> </div>
</div>
) )
} }

View File

@ -16,10 +16,6 @@ const SEX_OPTIONS = [
{ value: 'unknown', label: 'Unknown' }, { value: 'unknown', label: 'Unknown' },
] ]
const INPUT_CLASS =
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
const LABEL_CLASS = 'block font-body text-sm font-medium text-text-primary'
function lbsToOz(decimalLbs) { function lbsToOz(decimalLbs) {
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' } if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
const total = Number(decimalLbs) const total = Number(decimalLbs)
@ -35,26 +31,8 @@ function ozToLbs(lbs, oz) {
return l + o / 16 return l + o / 16
} }
// The fields most people fill in when adding a rabbit come first; the rest function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decimal_lbs', showValidation = false }) {
// (status, location, acquired/death dates) sit in a "More details" section.
// With `collapseSecondary` (the Add flow) that section starts closed unless it
// already holds a non-default value; Edit shows everything open.
function RabbitDetailsForm({
value,
onChange,
onPhotoChange,
weightUnit = 'decimal_lbs',
showValidation = false,
collapseSecondary = false,
nameInputId = 'rabbit-name',
}) {
const [photoPreview, setPhotoPreview] = useState(null) const [photoPreview, setPhotoPreview] = useState(null)
const [moreOpen, setMoreOpen] = useState(
() =>
!collapseSecondary ||
Boolean(value.location || value.acquired_date || value.death_date) ||
(value.status ?? 'active') !== 'active'
)
const { lbs: ozLbs, oz: ozOz } = lbsToOz(value.weight_lbs) const { lbs: ozLbs, oz: ozOz } = lbsToOz(value.weight_lbs)
function setField(field, fieldValue) { function setField(field, fieldValue) {
@ -71,60 +49,41 @@ function RabbitDetailsForm({
const nameInvalid = showValidation && !value.name?.trim() const nameInvalid = showValidation && !value.name?.trim()
return ( return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor={nameInputId} className={LABEL_CLASS}> <label className="block font-body text-sm font-medium text-text-primary">
Name <span className="text-danger">*</span> Name <span className="text-danger">*</span>
</label> </label>
<input <input
id={nameInputId}
type="text" type="text"
value={value.name ?? ''} value={value.name ?? ''}
onChange={(e) => setField('name', e.target.value)} onChange={(e) => setField('name', e.target.value)}
aria-invalid={nameInvalid || undefined}
aria-describedby={nameInvalid ? `${nameInputId}-error` : undefined}
className={`mt-1 w-full rounded-md border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent ${ className={`mt-1 w-full rounded-md border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
nameInvalid ? 'border-danger' : 'border-border' nameInvalid ? 'border-danger' : 'border-border'
}`} }`}
/> />
{nameInvalid && ( {nameInvalid && <p className="mt-1 font-body text-xs text-danger">Name is required</p>}
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
Give this rabbit a name to continue.
</p>
)}
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Farm / Prefix</label> <label className="block font-body text-sm font-medium text-text-primary">Farm / Prefix</label>
<input <input
type="text" type="text"
value={value.farm ?? ''} value={value.farm ?? ''}
onChange={(e) => setField('farm', e.target.value)} onChange={(e) => setField('farm', e.target.value)}
className={INPUT_CLASS} className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Ear Number</label> <label className="block font-body text-sm font-medium text-text-primary">Sex</label>
<input <div className="mt-1 flex gap-1">
type="text"
value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
className={`${INPUT_CLASS} uppercase`}
/>
</div>
<div>
<span className={LABEL_CLASS}>Sex</span>
<div className="mt-1 flex gap-1" role="group" aria-label="Sex">
{SEX_OPTIONS.map((opt) => ( {SEX_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
aria-pressed={value.sex === opt.value}
onClick={() => setField('sex', opt.value)} onClick={() => setField('sex', opt.value)}
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-0 ${ className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
value.sex === opt.value value.sex === opt.value
? 'border-accent bg-accent text-surface' ? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken' : 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
@ -137,110 +96,97 @@ function RabbitDetailsForm({
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Date of Birth</label> <label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label>
<input <input
type="date" type="date"
value={value.dob ?? ''} value={value.dob ?? ''}
onChange={(e) => setField('dob', e.target.value)} onChange={(e) => setField('dob', e.target.value)}
className={INPUT_CLASS} className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Breed</label> <label className="block font-body text-sm font-medium text-text-primary">Acquired Date</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
</div>
<div>
<label className={LABEL_CLASS}>Color Name</label>
<input <input
type="text" type="date"
value={value.color_name ?? ''} value={value.acquired_date ?? ''}
onChange={(e) => setField('color_name', e.target.value)} onChange={(e) => setField('acquired_date', e.target.value || null)}
placeholder="e.g. Broken Black Tort" className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
className={INPUT_CLASS} />
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Death Date</label>
<input
type="date"
value={value.death_date ?? ''}
onChange={(e) => setField('death_date', e.target.value || null)}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Weight</label> <label className="block font-body text-sm font-medium text-text-primary">Weight</label>
{weightUnit === 'lbs_oz' ? ( {weightUnit === 'lbs_oz' ? (
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<input <input
type="number" type="number"
inputMode="numeric"
min="0" min="0"
value={ozLbs} value={ozLbs}
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))} onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
placeholder="lbs" placeholder="lbs"
aria-label="Weight, pounds"
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<input <input
type="number" type="number"
inputMode="numeric"
min="0" min="0"
max="15" max="15"
value={ozOz} value={ozOz}
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))} onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
placeholder="oz" placeholder="oz"
aria-label="Weight, ounces"
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
) : ( ) : (
<input <input
type="number" type="number"
inputMode="decimal"
min="0" min="0"
step="0.01" step="0.01"
value={value.weight_lbs ?? ''} value={value.weight_lbs ?? ''}
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))} onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
placeholder="decimal lbs" placeholder="decimal lbs"
className={INPUT_CLASS} className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
)} )}
</div> </div>
<div className="md:col-span-2">
<label className={LABEL_CLASS}>Photo</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoSelect}
className="mt-1 block w-full font-body text-sm text-text-secondary file:mr-3 file:min-h-[40px] file:cursor-pointer file:rounded-md file:border file:border-solid file:border-border file:bg-surface-raised file:px-3 file:py-2 file:font-body file:text-sm file:font-medium file:text-text-primary hover:file:bg-surface-sunken"
/>
{photoPreview && (
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
)}
</div>
</div>
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
{collapseSecondary && (
<button
type="button"
onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left font-body text-sm font-medium text-text-primary"
>
<span>More details</span>
<span className="font-normal text-text-secondary">Status, location, dates</span>
</button>
)}
{moreOpen && (
<div
className={`grid grid-cols-1 gap-4 md:grid-cols-2 ${
collapseSecondary ? 'border-t border-border px-4 py-4' : ''
}`}
>
<div> <div>
<label className={LABEL_CLASS}>Status</label> <label className="block font-body text-sm font-medium text-text-primary">Ear Number</label>
<input
type="text"
value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Location</label>
<input
type="text"
value={value.location ?? ''}
onChange={(e) => setField('location', e.target.value)}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Status</label>
<select <select
value={value.status ?? 'active'} value={value.status ?? 'active'}
onChange={(e) => setField('status', e.target.value)} onChange={(e) => setField('status', e.target.value)}
className={`${INPUT_CLASS} bg-surface-raised`} className="mt-1 w-full rounded-md border border-border bg-surface-raised px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
> >
{STATUS_OPTIONS.map((opt) => ( {STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
@ -251,37 +197,31 @@ function RabbitDetailsForm({
</div> </div>
<div> <div>
<label className={LABEL_CLASS}>Location</label> <label className="block font-body text-sm font-medium text-text-primary">Breed</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label>
<input <input
type="text" type="text"
value={value.location ?? ''} value={value.color_name ?? ''}
onChange={(e) => setField('location', e.target.value)} onChange={(e) => setField('color_name', e.target.value)}
placeholder="Cage or hutch" placeholder="e.g. Broken Black Tort"
className={INPUT_CLASS} className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
<div> <div className="md:col-span-2">
<label className={LABEL_CLASS}>Acquired Date</label> <label className="block font-body text-sm font-medium text-text-primary">Photo</label>
<input <input
type="date" type="file"
value={value.acquired_date ?? ''} accept="image/jpeg,image/png,image/webp"
onChange={(e) => setField('acquired_date', e.target.value || null)} onChange={handlePhotoSelect}
className={INPUT_CLASS} className="mt-1 block w-full font-body text-sm text-text-secondary"
/> />
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p> {photoPreview && (
</div> <img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
<div>
<label className={LABEL_CLASS}>Death Date</label>
<input
type="date"
value={value.death_date ?? ''}
onChange={(e) => setField('death_date', e.target.value || null)}
className={INPUT_CLASS}
/>
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,98 +1,74 @@
import Button from './Button'
import Card from './Card' import Card from './Card'
import { LOCI, generateColorString } from '../lib/genetics' import { LOCI, generateColorString } from '../lib/genetics'
import { SEX_LABELS, STATUS_LABELS, formatDate, formatWeight } from '../lib/format'
// Display order + formatting for the Details card. Empty values are skipped const DETAIL_FIELDS = [
// (except Name, which is required), so the review shows what was entered { key: 'name', label: 'Name' },
// rather than a wall of dashes. { key: 'farm', label: 'Farm / Prefix' },
function detailRows(details, weightUnit) { { key: 'sex', label: 'Sex' },
return [ { key: 'dob', label: 'Date of Birth' },
['Name', details.name], { key: 'weight_lbs', label: 'Weight (lbs)' },
['Farm / Prefix', details.farm], { key: 'ear_number', label: 'Ear Number' },
['Ear Number', details.ear_number], { key: 'location', label: 'Location' },
['Sex', SEX_LABELS[details.sex] ?? details.sex], { key: 'status', label: 'Status' },
['Date of Birth', formatDate(details.dob)], { key: 'breed', label: 'Breed' },
['Breed', details.breed], { key: 'color_name', label: 'Color Name' },
['Color Name', details.color_name], ]
['Weight', formatWeight(details.weight_lbs, weightUnit)],
['Status', STATUS_LABELS[details.status] ?? details.status], function dash(value) {
['Location', details.location], return value === null || value === undefined || value === '' ? '—' : String(value)
['Acquired', formatDate(details.acquired_date)],
['Death Date', formatDate(details.death_date)],
].filter(([label, value]) => label === 'Name' || (value !== null && value !== undefined && value !== ''))
} }
function locusLabel(locus, value) { function ReviewStep({ details, pedigreeSummary, genetics, onBack, onSave, saving = false }) {
return locus.options.find((opt) => opt.value === value)?.label ?? value
}
function Section({ title, onEdit, children }) {
return ( return (
<div className="space-y-6">
<Card className="p-4"> <Card className="p-4">
<div className="mb-3 flex items-center justify-between gap-2"> <h3 className="mb-3 font-display text-lg text-text-primary">Pedigree</h3>
<h3 className="font-display text-lg font-bold text-text-primary">{title}</h3> <dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{onEdit && ( {pedigreeSummary.map((node) => (
<button <div key={node.slot} className="flex justify-between gap-2 border-b border-border py-1">
type="button" <dt className="font-body text-sm text-text-secondary">{node.label}</dt>
onClick={onEdit} <dd className="font-body text-sm text-text-primary">{dash(node.display)}</dd>
className="-my-2 rounded-md px-3 py-2 font-body text-sm font-medium text-accent hover:bg-surface-sunken"
>
Edit
</button>
)}
</div>
{children}
</Card>
)
}
function Rows({ rows }) {
return (
<dl className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 border-b border-border py-2">
<dt className="shrink-0 font-body text-sm text-text-secondary">{label}</dt>
<dd className="min-w-0 break-words text-right font-body text-sm text-text-primary">{value || '—'}</dd>
</div> </div>
))} ))}
</dl> </dl>
) </Card>
}
function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) { <Card className="p-4">
// Sire and Dam always show (so "Unknown" parents are visible at a glance); <h3 className="mb-3 font-display text-lg text-text-primary">Details</h3>
// deeper ancestors only when something was actually entered. <dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
const pedigreeRows = pedigreeSummary {DETAIL_FIELDS.map((field) => (
.filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown')) <div key={field.key} className="flex justify-between gap-2 border-b border-border py-1">
.map((node) => [node.label, node.display]) <dt className="font-body text-sm text-text-secondary">{field.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(details[field.key])}</dd>
</div>
))}
</dl>
</Card>
const setLoci = LOCI.filter((locus) => genetics[locus.key]) <Card className="p-4">
const colorString = generateColorString(genetics) <h3 className="mb-3 font-display text-lg text-text-primary">Genetics</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
return ( {LOCI.map((locus) => (
<div className="space-y-4"> <div key={locus.key} className="flex justify-between gap-2 border-b border-border py-1">
<Section title="Pedigree" onEdit={() => onEdit(1)}> <dt className="font-body text-sm text-text-secondary">{locus.label}</dt>
<Rows rows={pedigreeRows} /> <dd className="font-body text-sm text-text-primary">{dash(genetics[locus.key])}</dd>
</Section> </div>
))}
<Section title="Details" onEdit={() => onEdit(2)}> </dl>
<Rows rows={detailRows(details, weightUnit)} />
</Section>
<Section title="Genetics" onEdit={() => onEdit(2)}>
{setLoci.length === 0 ? (
<p className="font-body text-sm text-text-secondary">Not set — you can add genotypes later from Edit.</p>
) : (
<>
<Rows rows={setLoci.map((locus) => [locus.label, locusLabel(locus, genetics[locus.key])])} />
{colorString && (
<p className="mt-3 font-body text-sm text-text-secondary"> <p className="mt-3 font-body text-sm text-text-secondary">
Color: <span className="text-text-primary">{colorString}</span> Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</span>
</p> </p>
)} </Card>
</>
)} <div className="flex justify-end gap-3">
</Section> <Button type="button" variant="secondary" onClick={onBack} disabled={saving}>
Back
</Button>
<Button type="button" variant="primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
</Button>
</div>
</div> </div>
) )
} }

View File

@ -1,13 +0,0 @@
// Form actions pinned to the bottom of the viewport while the page scrolls, so
// Next/Save is always in thumb reach. On phones it sits just above the fixed
// bottom nav (56px + safe-area inset, see Layout.jsx); the negative margins
// stretch it across <main>'s gutter.
function StickyActionBar({ children }) {
return (
<div className="sticky bottom-[calc(3.5rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t border-border bg-surface px-4 py-3 md:bottom-0 md:-mx-8 md:px-8">
<div className="flex items-center justify-end gap-3">{children}</div>
</div>
)
}
export default StickyActionBar

View File

@ -1,14 +0,0 @@
import { useSyncExternalStore } from 'react'
// Live boolean for a CSS media query, for the rare layout switch that CSS
// alone can't do (e.g. rendering a structurally different component tree).
export function useMediaQuery(query) {
return useSyncExternalStore(
(onChange) => {
const mql = window.matchMedia(query)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
},
() => window.matchMedia(query).matches
)
}

View File

@ -4,15 +4,6 @@ export const SEX_LABELS = {
unknown: 'Unknown', unknown: 'Unknown',
} }
export const STATUS_LABELS = {
active: 'Active',
for_sale: 'For Sale',
on_hold: 'On Hold',
sold: 'Sold',
deceased: 'Deceased',
other: 'Other',
}
export function formatAge(dob) { export function formatAge(dob) {
if (!dob) return 'Age unknown' if (!dob) return 'Age unknown'

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
@ -6,13 +6,11 @@ import PedigreeTree from '../components/PedigreeTree'
import RabbitDetailsForm from '../components/RabbitDetailsForm' import RabbitDetailsForm from '../components/RabbitDetailsForm'
import GeneticsForm from '../components/GeneticsForm' import GeneticsForm from '../components/GeneticsForm'
import ReviewStep from '../components/ReviewStep' import ReviewStep from '../components/ReviewStep'
import StickyActionBar from '../components/StickyActionBar'
import Button from '../components/Button' import Button from '../components/Button'
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api' import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
import { createInitialPedigreeState } from '../lib/pedigree' import { createInitialPedigreeState } from '../lib/pedigree'
const STEP_LABELS = ['Pedigree', 'Details', 'Review'] const STEP_LABELS = ['Pedigree', 'Details', 'Review']
const NAME_INPUT_ID = 'add-rabbit-name'
const INITIAL_DETAILS = { const INITIAL_DETAILS = {
name: '', name: '',
@ -37,54 +35,14 @@ const INITIAL_GENETICS = {
locus_v: null, locus_v: null,
} }
function Stepper({ step, canReach, onSelect }) {
return (
<ol className="flex items-center gap-2">
{STEP_LABELS.map((label, i) => {
const n = i + 1
const current = n === step
const done = n < step
return (
<li key={label} className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
onClick={() => onSelect(n)}
disabled={current || !canReach(n)}
aria-current={current ? 'step' : undefined}
className="flex min-h-[40px] min-w-0 items-center gap-2 rounded-md pr-2 text-left disabled:cursor-default"
>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full font-body text-sm font-medium ${
current
? 'bg-accent text-surface'
: done
? 'border-2 border-accent text-accent'
: 'border border-border text-text-secondary'
}`}
>
{n}
</span>
<span
className={`truncate font-body text-sm ${current ? 'font-medium text-text-primary' : 'text-text-secondary'}`}
>
{label}
</span>
</button>
{n < STEP_LABELS.length && <span className="h-px flex-1 bg-border" aria-hidden="true" />}
</li>
)
})}
</ol>
)
}
function AddRabbit() { function AddRabbit() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams] = useSearchParams()
const sireId = searchParams.get('sire') const sireId = searchParams.get('sire')
const damId = searchParams.get('dam') const damId = searchParams.get('dam')
const prefillDob = searchParams.get('dob') const prefillDob = searchParams.get('dob')
const [step, setStep] = useState(1)
const [details, setDetails] = useState(() => ({ const [details, setDetails] = useState(() => ({
...INITIAL_DETAILS, ...INITIAL_DETAILS,
dob: prefillDob ?? INITIAL_DETAILS.dob, dob: prefillDob ?? INITIAL_DETAILS.dob,
@ -96,35 +54,6 @@ function AddRabbit() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [detailsDefaulted, setDetailsDefaulted] = useState(false) const [detailsDefaulted, setDetailsDefaulted] = useState(false)
// The name error only shows once the user has tried to move past step 2,
// not the moment they arrive on an empty form.
const [triedNext, setTriedNext] = useState(false)
const step2Valid = details.name.trim().length > 0
// The step lives in the URL (pushed, not replaced) so the browser / Android
// back gesture moves between steps instead of abandoning the whole flow.
// Review can't be reached without a name (e.g. after a reload, which resets
// the form), so it falls back to Details.
const requestedStep = Math.min(3, Math.max(1, Number(searchParams.get('step')) || 1))
const step = requestedStep === 3 && !step2Valid ? 2 : requestedStep
const isDirty =
Boolean(details.name.trim()) ||
Boolean(photoFile) ||
Object.values(genetics).some(Boolean) ||
pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
// Warn before a reload / tab close throws away an in-progress entry.
useEffect(() => {
if (!isDirty || saving) return
function onBeforeUnload(e) {
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [isDirty, saving])
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings }) const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const { data: sireRabbit, isLoading: sireLoading } = useQuery({ const { data: sireRabbit, isLoading: sireLoading } = useQuery({
@ -173,145 +102,85 @@ function AddRabbit() {
setGenetics((prev) => ({ ...prev, ...partial })) setGenetics((prev) => ({ ...prev, ...partial }))
} }
function goToStep(n) {
const next = new URLSearchParams(searchParams)
if (n === 1) next.delete('step')
else next.set('step', String(n))
setSearchParams(next)
window.scrollTo(0, 0)
}
function handleNextFromDetails() {
if (!step2Valid) {
setTriedNext(true)
document.getElementById(NAME_INPUT_ID)?.focus()
return
}
goToStep(3)
}
async function handleSave() { async function handleSave() {
setSaving(true) setSaving(true)
setError(null) setError(null)
let created
try { try {
created = await createRabbit({ ...details, ...pedigreeFields, ...genetics }) const created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
if (photoFile) {
await uploadRabbitPhoto(created.id, photoFile)
}
navigate(`/herd/${created.id}`)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
setSaving(false) setSaving(false)
return
}
// The rabbit exists from here on. A failed photo upload must not leave the
// user on Review, where pressing Save again would create a duplicate —
// go to the new rabbit and say what happened instead.
if (photoFile) {
try {
await uploadRabbitPhoto(created.id, photoFile)
} catch {
navigate(`/herd/${created.id}`, {
replace: true,
state: { notice: 'Rabbit saved, but the photo didn’t upload. You can add it again from Edit.' },
})
return
} }
} }
navigate(`/herd/${created.id}`, { replace: true })
} const step2Valid = details.name.trim().length > 0
const headerAction =
step === 1 ? (
<Button type="button" onClick={() => setStep(2)}>
Next
</Button>
) : step === 2 ? (
<div className="flex gap-3">
<Button type="button" variant="secondary" onClick={() => setStep(1)}>
Back
</Button>
<Button type="button" onClick={() => setStep(3)} disabled={!step2Valid}>
Next
</Button>
</div>
) : null
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader title="Add Rabbit" /> <PageHeader
title="Add Rabbit"
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} /> subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
action={headerAction}
{/* All three steps stay mounted and are hidden with CSS, so going back
(or jumping from Review via "Edit") keeps the pedigree tree's
internal state, the photo preview, and any open sections intact. */}
<div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
<div>
<label htmlFor="add-rabbit-subject-name" className="block font-body text-sm font-medium text-text-primary">
Rabbit name
</label>
<input
id="add-rabbit-subject-name"
type="text"
value={details.name}
onChange={(e) => updateDetails({ name: e.target.value })}
placeholder="You can also fill this in on the next step"
className="mt-1 w-full max-w-md rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div>
<p className="font-body text-sm text-text-secondary"> <div>
Start with the parents. Tap any ancestor to search your herd, enter them manually, or mark them unknown. {step === 1 && (
Picking a rabbit from your herd fills in its ancestors automatically.
</p>
<PedigreeTree <PedigreeTree
subjectName={details.name.trim() || 'New Rabbit'}
initialState={pedigreeInitialState} initialState={pedigreeInitialState}
onChange={(fields, summary) => { onChange={(fields, summary) => {
setPedigreeFields(fields) setPedigreeFields(fields)
setPedigreeSummary(summary) setPedigreeSummary(summary)
}} }}
/> />
</div> )}
<div className={step === 2 ? 'space-y-6' : 'hidden'}> {step === 2 && (
<div className="space-y-6">
<RabbitDetailsForm <RabbitDetailsForm
value={details} value={details}
onChange={updateDetails} onChange={updateDetails}
onPhotoChange={setPhotoFile} onPhotoChange={setPhotoFile}
weightUnit={settings?.weight_unit} weightUnit={settings?.weight_unit}
showValidation={triedNext} showValidation={!step2Valid}
collapseSecondary
nameInputId={NAME_INPUT_ID}
/>
<GeneticsForm
value={genetics}
onChange={updateGenetics}
defaultExpanded={false}
onUseColor={(color) => updateDetails({ color_name: color })}
/> />
<GeneticsForm value={genetics} onChange={updateGenetics} defaultExpanded={false} />
</div> </div>
)}
{step === 3 && ( {step === 3 && (
<div> <div>
{error && ( {error && <p className="mb-4 font-body text-sm text-danger">{error}</p>}
<p role="alert" className="mb-4 font-body text-sm text-danger">
{error}
</p>
)}
<ReviewStep <ReviewStep
details={details} details={details}
pedigreeSummary={pedigreeSummary} pedigreeSummary={pedigreeSummary}
genetics={genetics} genetics={genetics}
weightUnit={settings?.weight_unit} onBack={() => setStep(2)}
onEdit={goToStep} onSave={handleSave}
saving={saving}
/> />
</div> </div>
)} )}
</div>
<StickyActionBar>
{step > 1 && (
<Button type="button" variant="secondary" onClick={() => goToStep(step - 1)} disabled={saving}>
Back
</Button>
)}
{step === 1 && (
<Button type="button" onClick={() => goToStep(2)}>
Next: Details
</Button>
)}
{step === 2 && (
<Button type="button" onClick={handleNextFromDetails}>
Next: Review
</Button>
)}
{step === 3 && (
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
</Button>
)}
</StickyActionBar>
</div> </div>
) )
} }

View File

@ -104,7 +104,6 @@ function EditRabbit() {
value={genetics} value={genetics}
onChange={updateGenetics} onChange={updateGenetics}
defaultExpanded={hasAnyLocusSet(genetics)} defaultExpanded={hasAnyLocusSet(genetics)}
onUseColor={(color) => updateDetails({ color_name: color })}
/> />
{error && <p className="font-body text-sm text-danger">{error}</p>} {error && <p className="font-body text-sm text-danger">{error}</p>}

View File

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useLocation, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import Card from '../components/Card' import Card from '../components/Card'
@ -140,9 +140,6 @@ function LitterHistory({ rabbitId }) {
function RabbitDetail() { function RabbitDetail() {
const { id } = useParams() const { id } = useParams()
const rabbitId = Number(id) const rabbitId = Number(id)
// One-off message handed over by the page that navigated here (e.g. Add
// Rabbit when the photo upload failed after the rabbit was created).
const notice = useLocation().state?.notice
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -234,11 +231,6 @@ function RabbitDetail() {
/> />
<div className="space-y-6"> <div className="space-y-6">
{notice && (
<p role="status" className="rounded-md border border-accent-warm bg-surface-raised px-4 py-3 font-body text-sm text-text-primary">
{notice}
</p>
)}
{error && <p className="font-body text-sm text-danger">{error}</p>} {error && <p className="font-body text-sm text-danger">{error}</p>}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start"> <div className="flex flex-col gap-6 lg:flex-row lg:items-start">