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>
This commit is contained in:
fireshaper 2026-10-28 11:05:35 -04:00
parent 3874c33a90
commit 5d29e2b370
12 changed files with 695 additions and 348 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

@ -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>
<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="mt-1 w-full rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary" 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,12 +420,24 @@ 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 (
// The tree needs ~900px; on narrower screens the outer wrapper scrolls <div className="flex flex-col gap-4">
// sideways rather than the min-width stretching the whole page. {GEN1.map(({ path, slot }) => (
<div className="-mx-4 overflow-x-auto md:mx-0"> <section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm">
<p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p> <h3 className="mb-2 font-display text-base font-bold text-text-primary">
<div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]"> {slot === 'sire' ? "Sire's side" : "Dam's side"}
</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>
@ -383,7 +445,6 @@ 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,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,41 +71,60 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
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 className="block font-body text-sm font-medium text-text-primary"> <label htmlFor={nameInputId} className={LABEL_CLASS}>
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 && <p className="mt-1 font-body text-xs text-danger">Name is required</p>} {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> <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
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={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${ 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 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'
@ -96,97 +137,110 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
</div> </div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label> <label className={LABEL_CLASS}>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="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">Acquired Date</label> <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 <input
type="date" type="text"
value={value.acquired_date ?? ''} value={value.color_name ?? ''}
onChange={(e) => setField('acquired_date', e.target.value || null)} onChange={(e) => setField('color_name', 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="e.g. Broken Black Tort"
/> 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="block font-body text-sm font-medium text-text-primary">Weight</label> <label className={LABEL_CLASS}>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="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 className="md:col-span-2">
<label className="block font-body text-sm font-medium text-text-primary">Ear Number</label> <label className={LABEL_CLASS}>Photo</label>
<input <input
type="text" type="file"
value={value.ear_number ?? ''} accept="image/jpeg,image/png,image/webp"
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())} onChange={handlePhotoSelect}
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" 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 className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
<label className="block font-body text-sm font-medium text-text-primary">Location</label> {collapseSecondary && (
<input <button
type="text" type="button"
value={value.location ?? ''} onClick={() => setMoreOpen((v) => !v)}
onChange={(e) => setField('location', 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>
</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="block font-body text-sm font-medium text-text-primary">Status</label> <label className={LABEL_CLASS}>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="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" className={`${INPUT_CLASS} bg-surface-raised`}
> >
{STATUS_OPTIONS.map((opt) => ( {STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
@ -197,31 +251,37 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
</div> </div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Breed</label> <label className={LABEL_CLASS}>Location</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} /> <input
type="text"
value={value.location ?? ''}
onChange={(e) => setField('location', e.target.value)}
placeholder="Cage or hutch"
className={INPUT_CLASS}
/>
</div> </div>
<div> <div>
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label> <label className={LABEL_CLASS}>Acquired Date</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={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"
/> />
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
</div> </div>
<div className="md:col-span-2"> <div>
<label className="block font-body text-sm font-medium text-text-primary">Photo</label> <label className={LABEL_CLASS}>Death Date</label>
<input <input
type="file" type="date"
accept="image/jpeg,image/png,image/webp" value={value.death_date ?? ''}
onChange={handlePhotoSelect} onChange={(e) => setField('death_date', e.target.value || null)}
className="mt-1 block w-full font-body text-sm text-text-secondary" className={INPUT_CLASS}
/> />
{photoPreview && ( </div>
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" /> </div>
)} )}
</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">
<h3 className="mb-3 font-display text-lg text-text-primary">Pedigree</h3> <div className="mb-3 flex items-center justify-between gap-2">
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2"> <h3 className="font-display text-lg font-bold text-text-primary">{title}</h3>
{pedigreeSummary.map((node) => ( {onEdit && (
<div key={node.slot} className="flex justify-between gap-2 border-b border-border py-1"> <button
<dt className="font-body text-sm text-text-secondary">{node.label}</dt> type="button"
<dd className="font-body text-sm text-text-primary">{dash(node.display)}</dd> onClick={onEdit}
className="-my-2 rounded-md px-3 py-2 font-body text-sm font-medium text-accent hover:bg-surface-sunken"
>
Edit
</button>
)}
</div> </div>
))} {children}
</dl>
</Card> </Card>
)
}
<Card className="p-4"> function Rows({ rows }) {
<h3 className="mb-3 font-display text-lg text-text-primary">Details</h3> return (
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2"> <dl className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
{DETAIL_FIELDS.map((field) => ( {rows.map(([label, value]) => (
<div key={field.key} className="flex justify-between gap-2 border-b border-border py-1"> <div key={label} className="flex justify-between gap-4 border-b border-border py-2">
<dt className="font-body text-sm text-text-secondary">{field.label}</dt> <dt className="shrink-0 font-body text-sm text-text-secondary">{label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(details[field.key])}</dd> <dd className="min-w-0 break-words text-right font-body text-sm text-text-primary">{value || '—'}</dd>
</div> </div>
))} ))}
</dl> </dl>
</Card> )
}
<Card className="p-4"> function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) {
<h3 className="mb-3 font-display text-lg text-text-primary">Genetics</h3> // Sire and Dam always show (so "Unknown" parents are visible at a glance);
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2"> // deeper ancestors only when something was actually entered.
{LOCI.map((locus) => ( const pedigreeRows = pedigreeSummary
<div key={locus.key} className="flex justify-between gap-2 border-b border-border py-1"> .filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown'))
<dt className="font-body text-sm text-text-secondary">{locus.label}</dt> .map((node) => [node.label, node.display])
<dd className="font-body text-sm text-text-primary">{dash(genetics[locus.key])}</dd>
</div> const setLoci = LOCI.filter((locus) => genetics[locus.key])
))} const colorString = generateColorString(genetics)
</dl>
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"> <p className="mt-3 font-body text-sm text-text-secondary">
Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</span> Color: <span className="text-text-primary">{colorString}</span>
</p> </p>
</Card> )}
</>
<div className="flex justify-end gap-3"> )}
<Button type="button" variant="secondary" onClick={onBack} disabled={saving}> </Section>
Back
</Button>
<Button type="button" variant="primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
</Button>
</div>
</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}
/>
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
{/* 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> <div>
{step === 1 && ( <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">
Start with the parents. Tap any ancestor to search your herd, enter them manually, or mark them unknown.
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>
{step === 2 && ( <div className={step === 2 ? 'space-y-6' : 'hidden'}>
<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={!step2Valid} showValidation={triedNext}
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 && <p className="mb-4 font-body text-sm text-danger">{error}</p>} {error && (
<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}
onBack={() => setStep(2)} weightUnit={settings?.weight_unit}
onSave={handleSave} onEdit={goToStep}
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,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">