Compare commits

..

2 Commits

Author SHA1 Message Date
fireshaper 5d29e2b370 Rework Add Rabbit flow for mobile and clarity
- Pedigree renders as stacked Sire/Dam sections below 1280px instead of a
  sideways-scrolling tree; larger touch targets in the ancestor popup
- Step lives in ?step= so the back gesture moves between steps; sticky
  Back/Next/Save bar and a clickable stepper; beforeunload guard
- Steps stay mounted, fixing pedigree entries lost when going back
- Review shows formatted values, hides empty rows, per-section Edit links
- Failed photo upload after create now lands on the new rabbit with a
  notice instead of allowing a duplicate save
- Details form: primary fields first, secondary fields collapsible on
  Add, name error only after a Next attempt; "Use as color name" button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:05:35 -04:00
fireshaper 3874c33a90 Document updating an existing Docker deployment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:05:35 -04:00
13 changed files with 747 additions and 350 deletions

View File

@ -62,10 +62,12 @@ 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. | | `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/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/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/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/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/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared `dash()` helper for empty values. | | `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/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`, alongside `MYKINDLEBOX_PASSWORD`): next to `docker-compose.yml`):
```env ```env
MYKINDLEBOX_PORT=3000 MYKINDLEBOX_PORT=3000
@ -100,6 +100,51 @@ 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
@ -116,4 +161,9 @@ 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. `server-data` volume; back that volume up to preserve your data. A quick
snapshot from a running stack:
```bash
docker compose cp server:/app/data ./data-backup-$(date +%F)
```

View File

@ -1,3 +1,5 @@
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',
@ -7,15 +9,6 @@ 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,7 +1,24 @@
import { useState } from 'react' import { useState } from 'react'
import { LOCI, generateColorString } from '../lib/genetics' import { LOCI, generateColorString } from '../lib/genetics'
function GeneticsForm({ value, onChange, defaultExpanded = false }) { function ChevronIcon({ open }) {
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)
@ -14,24 +31,26 @@ function GeneticsForm({ value, onChange, defaultExpanded = false }) {
<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>
<span className="text-text-secondary">{expanded ? '▲' : '▼'}</span> <ChevronIcon open={expanded} />
</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-wrap items-center gap-2"> <div key={locus.key} className="flex flex-col gap-1.5 sm:flex-row sm:flex-wrap sm:items-center sm: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 font-body text-xs ${ className={`rounded-full border px-3 py-1.5 font-body text-sm md:py-1 md: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'
@ -48,13 +67,25 @@ function GeneticsForm({ value, onChange, defaultExpanded = false }) {
<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>
<input <div className="mt-1 flex gap-2">
type="text" <input
readOnly type="text"
value={colorString} readOnly
placeholder="Select loci to generate a description" value={colorString}
className="mt-1 w-full rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary" 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"
/>
{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,7 +1,9 @@
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,
@ -21,6 +23,32 @@ 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.
@ -55,7 +83,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 - rect.bottom const spaceBelow = window.innerHeight - bottomChromeHeight() - 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)
} }
@ -102,9 +130,7 @@ 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>
<span aria-label="locked" className="text-text-secondary"> <LockIcon />
🔒
</span>
</div> </div>
) )
} }
@ -166,7 +192,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
{open && ( {open && (
<div <div
className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${ 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 ${
dropUp ? 'bottom-full mb-1' : 'top-full mt-1' dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`} }`}
> >
@ -174,14 +200,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-1 text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`} 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'}`}
> >
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-1 text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`} 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'}`}
> >
Enter manually Enter manually
</button> </button>
@ -197,7 +223,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-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent" 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"
/> />
<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) => (
@ -213,7 +239,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
close, close,
}) })
} }
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken" className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
> >
{rabbit.name} {rabbit.name}
{rabbit.farm ? ` (${rabbit.farm})` : ''} {rabbit.farm ? ` (${rabbit.farm})` : ''}
@ -235,21 +261,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-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent" 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"
/> />
<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-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent" 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"
/> />
<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-1 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent" 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"
/> />
<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>
@ -258,7 +284,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-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent" 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"
/> />
</div> </div>
<div> <div>
@ -268,7 +294,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-1 text-sm 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-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"
/> />
</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">
@ -290,7 +316,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-1 text-xs text-surface disabled:opacity-50" 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"
> >
Save Save
</button> </button>
@ -300,7 +326,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-1 text-left text-xs text-text-secondary hover:bg-surface-sunken" 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"
> >
Mark as Unknown Mark as Unknown
</button> </button>
@ -329,6 +355,29 @@ 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,
@ -341,6 +390,7 @@ 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 }))
@ -370,19 +420,30 @@ function PedigreeTree({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
return ( if (!wide) {
// The tree needs ~900px; on narrower screens the outer wrapper scrolls const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
// sideways rather than the min-width stretching the whole page. return (
<div className="-mx-4 overflow-x-auto md:mx-0"> <div className="flex flex-col gap-4">
<p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p> {GEN1.map(({ path, slot }) => (
<div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]"> <section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm">
<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"> <h3 className="mb-2 font-display text-base font-bold text-text-primary">
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span> {slot === 'sire' ? "Sire's side" : "Dam's side"}
</div> </h3>
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> <StackedBranch path={path} slotProps={slotProps} />
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> </section>
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} /> ))}
</div> </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">
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
</div>
<Generation entries={GEN1} 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} />
</div> </div>
) )
} }

View File

@ -16,6 +16,10 @@ 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)
@ -31,8 +35,26 @@ function ozToLbs(lbs, oz) {
return l + o / 16 return l + o / 16
} }
function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decimal_lbs', showValidation = false }) { // The fields most people fill in when adding a rabbit come first; the rest
// (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) {
@ -49,179 +71,217 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
const nameInvalid = showValidation && !value.name?.trim() const nameInvalid = showValidation && !value.name?.trim()
return ( return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="space-y-4">
<div className="md:col-span-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="block font-body text-sm font-medium text-text-primary"> <div className="md:col-span-2">
Name <span className="text-danger">*</span> <label htmlFor={nameInputId} className={LABEL_CLASS}>
</label> Name <span className="text-danger">*</span>
<input </label>
type="text" <input
value={value.name ?? ''} id={nameInputId}
onChange={(e) => setField('name', e.target.value)} type="text"
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 ${ value={value.name ?? ''}
nameInvalid ? 'border-danger' : 'border-border' onChange={(e) => setField('name', e.target.value)}
}`} aria-invalid={nameInvalid || undefined}
/> aria-describedby={nameInvalid ? `${nameInputId}-error` : undefined}
{nameInvalid && <p className="mt-1 font-body text-xs text-danger">Name is required</p>} 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 ${
</div> nameInvalid ? 'border-danger' : 'border-border'
}`}
/>
{nameInvalid && (
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
Give this rabbit a name to continue.
</p>
)}
</div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Farm / Prefix</label> <label className={LABEL_CLASS}>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="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}
/> />
</div> </div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Sex</label> <label className={LABEL_CLASS}>Ear Number</label>
<div className="mt-1 flex gap-1"> <input
{SEX_OPTIONS.map((opt) => ( type="text"
<button value={value.ear_number ?? ''}
key={opt.value} onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
type="button" className={`${INPUT_CLASS} uppercase`}
onClick={() => setField('sex', opt.value)} />
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${ </div>
value.sex === opt.value
? 'border-accent bg-accent text-surface' <div>
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken' <span className={LABEL_CLASS}>Sex</span>
}`} <div className="mt-1 flex gap-1" role="group" aria-label="Sex">
> {SEX_OPTIONS.map((opt) => (
{opt.label} <button
</button> key={opt.value}
))} type="button"
aria-pressed={value.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 ${
value.sex === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className={LABEL_CLASS}>Date of Birth</label>
<input
type="date"
value={value.dob ?? ''}
onChange={(e) => setField('dob', e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div>
<label className={LABEL_CLASS}>Breed</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
</div>
<div>
<label className={LABEL_CLASS}>Color Name</label>
<input
type="text"
value={value.color_name ?? ''}
onChange={(e) => setField('color_name', e.target.value)}
placeholder="e.g. Broken Black Tort"
className={INPUT_CLASS}
/>
</div>
<div>
<label className={LABEL_CLASS}>Weight</label>
{weightUnit === 'lbs_oz' ? (
<div className="mt-1 flex gap-2">
<input
type="number"
inputMode="numeric"
min="0"
value={ozLbs}
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
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"
/>
<input
type="number"
inputMode="numeric"
min="0"
max="15"
value={ozOz}
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
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"
/>
</div>
) : (
<input
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={value.weight_lbs ?? ''}
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
placeholder="decimal lbs"
className={INPUT_CLASS}
/>
)}
</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> </div>
<div> <div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
<label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label> {collapseSecondary && (
<input <button
type="date" type="button"
value={value.dob ?? ''} onClick={() => setMoreOpen((v) => !v)}
onChange={(e) => setField('dob', e.target.value)} aria-expanded={moreOpen}
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="flex w-full items-center justify-between gap-3 px-4 py-3 text-left font-body text-sm font-medium text-text-primary"
/> >
</div> <span>More details</span>
<span className="font-normal text-text-secondary">Status, location, dates</span>
<div> </button>
<label className="block font-body text-sm font-medium text-text-primary">Acquired Date</label>
<input
type="date"
value={value.acquired_date ?? ''}
onChange={(e) => setField('acquired_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"
/>
<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>
<label className="block font-body text-sm font-medium text-text-primary">Weight</label>
{weightUnit === 'lbs_oz' ? (
<div className="mt-1 flex gap-2">
<input
type="number"
min="0"
value={ozLbs}
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
placeholder="lbs"
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
type="number"
min="0"
max="15"
value={ozOz}
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
placeholder="oz"
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>
) : (
<input
type="number"
min="0"
step="0.01"
value={value.weight_lbs ?? ''}
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
placeholder="decimal lbs"
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> {moreOpen && (
<label className="block font-body text-sm font-medium text-text-primary">Ear Number</label> <div
<input className={`grid grid-cols-1 gap-4 md:grid-cols-2 ${
type="text" collapseSecondary ? 'border-t border-border px-4 py-4' : ''
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>
/> <label className={LABEL_CLASS}>Status</label>
</div> <select
value={value.status ?? 'active'}
onChange={(e) => setField('status', e.target.value)}
className={`${INPUT_CLASS} bg-surface-raised`}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Location</label> <label className={LABEL_CLASS}>Location</label>
<input <input
type="text" type="text"
value={value.location ?? ''} value={value.location ?? ''}
onChange={(e) => setField('location', e.target.value)} 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" placeholder="Cage or hutch"
/> className={INPUT_CLASS}
</div> />
</div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Status</label> <label className={LABEL_CLASS}>Acquired Date</label>
<select <input
value={value.status ?? 'active'} type="date"
onChange={(e) => setField('status', e.target.value)} value={value.acquired_date ?? ''}
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" onChange={(e) => setField('acquired_date', e.target.value || null)}
> className={INPUT_CLASS}
{STATUS_OPTIONS.map((opt) => ( />
<option key={opt.value} value={opt.value}> <p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
{opt.label} </div>
</option>
))}
</select>
</div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Breed</label> <label className={LABEL_CLASS}>Death Date</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} /> <input
</div> type="date"
value={value.death_date ?? ''}
<div> onChange={(e) => setField('death_date', e.target.value || null)}
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label> className={INPUT_CLASS}
<input />
type="text" </div>
value={value.color_name ?? ''} </div>
onChange={(e) => setField('color_name', e.target.value)}
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"
/>
</div>
<div className="md:col-span-2">
<label className="block font-body text-sm font-medium text-text-primary">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"
/>
{photoPreview && (
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
)} )}
</div> </div>
</div> </div>

View File

@ -1,74 +1,98 @@
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'
const DETAIL_FIELDS = [ // Display order + formatting for the Details card. Empty values are skipped
{ key: 'name', label: 'Name' }, // (except Name, which is required), so the review shows what was entered
{ key: 'farm', label: 'Farm / Prefix' }, // rather than a wall of dashes.
{ key: 'sex', label: 'Sex' }, function detailRows(details, weightUnit) {
{ key: 'dob', label: 'Date of Birth' }, return [
{ key: 'weight_lbs', label: 'Weight (lbs)' }, ['Name', details.name],
{ key: 'ear_number', label: 'Ear Number' }, ['Farm / Prefix', details.farm],
{ key: 'location', label: 'Location' }, ['Ear Number', details.ear_number],
{ key: 'status', label: 'Status' }, ['Sex', SEX_LABELS[details.sex] ?? details.sex],
{ key: 'breed', label: 'Breed' }, ['Date of Birth', formatDate(details.dob)],
{ key: 'color_name', label: 'Color Name' }, ['Breed', details.breed],
] ['Color Name', details.color_name],
['Weight', formatWeight(details.weight_lbs, weightUnit)],
function dash(value) { ['Status', STATUS_LABELS[details.status] ?? details.status],
return value === null || value === undefined || value === '' ? '—' : String(value) ['Location', details.location],
['Acquired', formatDate(details.acquired_date)],
['Death Date', formatDate(details.death_date)],
].filter(([label, value]) => label === 'Name' || (value !== null && value !== undefined && value !== ''))
} }
function ReviewStep({ details, pedigreeSummary, genetics, onBack, onSave, saving = false }) { function locusLabel(locus, value) {
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"
</div> >
))} Edit
</dl> </button>
</Card> )}
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Details</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{DETAIL_FIELDS.map((field) => (
<div key={field.key} className="flex justify-between gap-2 border-b border-border py-1">
<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>
<Card className="p-4">
<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">
{LOCI.map((locus) => (
<div key={locus.key} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{locus.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(genetics[locus.key])}</dd>
</div>
))}
</dl>
<p className="mt-3 font-body text-sm text-text-secondary">
Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</span>
</p>
</Card>
<div className="flex justify-end gap-3">
<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>
{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>
))}
</dl>
)
}
function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) {
// Sire and Dam always show (so "Unknown" parents are visible at a glance);
// deeper ancestors only when something was actually entered.
const pedigreeRows = pedigreeSummary
.filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown'))
.map((node) => [node.label, node.display])
const setLoci = LOCI.filter((locus) => genetics[locus.key])
const colorString = generateColorString(genetics)
return (
<div className="space-y-4">
<Section title="Pedigree" onEdit={() => onEdit(1)}>
<Rows rows={pedigreeRows} />
</Section>
<Section title="Details" onEdit={() => onEdit(2)}>
<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">
Color: <span className="text-text-primary">{colorString}</span>
</p>
)}
</>
)}
</Section>
</div> </div>
) )
} }

View File

@ -0,0 +1,13 @@
// 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

@ -0,0 +1,14 @@
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,6 +4,15 @@ 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 { useState } from 'react' import { useEffect, 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,11 +6,13 @@ 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: '',
@ -35,14 +37,54 @@ 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] = useSearchParams() const [searchParams, setSearchParams] = 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,
@ -54,6 +96,35 @@ 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({
@ -102,85 +173,145 @@ 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 {
const created = await createRabbit({ ...details, ...pedigreeFields, ...genetics }) 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 <PageHeader title="Add Rabbit" />
title="Add Rabbit"
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
action={headerAction}
/>
<div> <Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
{step === 1 && (
<PedigreeTree {/* All three steps stay mounted and are hidden with CSS, so going back
initialState={pedigreeInitialState} (or jumping from Review via "Edit") keeps the pedigree tree's
onChange={(fields, summary) => { internal state, the photo preview, and any open sections intact. */}
setPedigreeFields(fields) <div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
setPedigreeSummary(summary) <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">
{step === 2 && ( Start with the parents. Tap any ancestor to search your herd, enter them manually, or mark them unknown.
<div className="space-y-6"> Picking a rabbit from your herd fills in its ancestors automatically.
<RabbitDetailsForm </p>
value={details} <PedigreeTree
onChange={updateDetails} subjectName={details.name.trim() || 'New Rabbit'}
onPhotoChange={setPhotoFile} initialState={pedigreeInitialState}
weightUnit={settings?.weight_unit} onChange={(fields, summary) => {
showValidation={!step2Valid} setPedigreeFields(fields)
/> setPedigreeSummary(summary)
<GeneticsForm value={genetics} onChange={updateGenetics} defaultExpanded={false} /> }}
</div> />
)}
{step === 3 && (
<div>
{error && <p className="mb-4 font-body text-sm text-danger">{error}</p>}
<ReviewStep
details={details}
pedigreeSummary={pedigreeSummary}
genetics={genetics}
onBack={() => setStep(2)}
onSave={handleSave}
saving={saving}
/>
</div>
)}
</div> </div>
<div className={step === 2 ? 'space-y-6' : 'hidden'}>
<RabbitDetailsForm
value={details}
onChange={updateDetails}
onPhotoChange={setPhotoFile}
weightUnit={settings?.weight_unit}
showValidation={triedNext}
collapseSecondary
nameInputId={NAME_INPUT_ID}
/>
<GeneticsForm
value={genetics}
onChange={updateGenetics}
defaultExpanded={false}
onUseColor={(color) => updateDetails({ color_name: color })}
/>
</div>
{step === 3 && (
<div>
{error && (
<p role="alert" className="mb-4 font-body text-sm text-danger">
{error}
</p>
)}
<ReviewStep
details={details}
pedigreeSummary={pedigreeSummary}
genetics={genetics}
weightUnit={settings?.weight_unit}
onEdit={goToStep}
/>
</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,6 +104,7 @@ 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, useParams } from 'react-router-dom' import { Link, useLocation, 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,6 +140,9 @@ 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)
@ -231,6 +234,11 @@ 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">