import { useEffect, useImperativeHandle, useRef, useState } from 'react' import { searchRabbits, getRabbitAncestors } from '../api' import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav' import { useMediaQuery } from '../hooks/useMediaQuery' import { useWeightUnit } from '../hooks/useWeightUnit' import { formatWeight } from '../lib/format' import { CoatAvatar } from './CoatArt' import Icon from './Icon' import { ALL_SLOTS, GEN1, createInitialPedigreeState, resolveEffectiveNode, buildPedigreePayload, buildPedigreeSummary, serializeManualAncestor, parseManualAncestor, formatManualAncestorDisplay, formatManualAncestorDetails, MANUAL_ANCESTOR_DEFAULTS, } from '../lib/pedigree' const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: String(MANUAL_ANCESTOR_DEFAULTS.weight_lbs), dob: '', dobUnknown: true, } // Upper bound on the popup's height (its tallest state is the manual-entry // form). Used to decide drop direction synchronously on open, before the popup // mounts, so the very first click is positioned correctly. 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])) // Sire-side slots are blue, dam-side pink (the buck/doe hues used app-wide). function sideTone(path) { return path[path.length - 1] === 'sire' ? 'bg-c6-soft text-c6-ink' : 'bg-c2-soft text-c2-ink' } // Node widths per generation in the wide bracket layout (px). const WIDTHS = [0, 210, 196, 184] function LockIcon() { return ( ) } // `dob` on the manual-ancestor form is a real date input value; `dobUnknown` // is the separate "Unknown" checkbox. They collapse into one stored string // ("Unknown" or an actual date) only at save time, via serializeManualAncestor. function manualFormFromParsed(parsed) { const isKnownDate = parsed?.dob && parsed.dob !== 'Unknown' return { ...EMPTY_MANUAL_FORM, ...parsed, weight_lbs: String(parsed?.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs), dob: isKnownDate ? parsed.dob : '', dobUnknown: !isKnownDate, } } function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade, flagged = false }) { const node = resolveEffectiveNode(path, state, cascadeCache) const weightUnit = useWeightUnit() const [open, setOpen] = useState(false) const [mode, setMode] = useState('search') // 'search' | 'manual' const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [manualForm, setManualForm] = useState(EMPTY_MANUAL_FORM) const containerRef = useRef(null) const inputRef = useRef(null) const triggerRef = useRef(null) // Open the popup above the trigger when there isn't room below it (e.g. nodes // near the bottom of the window) so the tall manual form doesn't get cut off. const [dropUp, setDropUp] = useState(false) // Decide drop direction from the trigger's position in the viewport. Called // synchronously on open (the trigger already exists in the DOM) so the first // click lands in the right place rather than flipping on the next open. function updateDropUp() { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom const spaceAbove = rect.top setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow) } function close() { setOpen(false) triggerRef.current?.focus() } useEffect(() => { if (!open) return function onClickOutside(e) { if (containerRef.current && !containerRef.current.contains(e.target)) { setOpen(false) } } document.addEventListener('mousedown', onClickOutside) return () => document.removeEventListener('mousedown', onClickOutside) }, [open]) useEffect(() => { if (!open || mode !== 'search') return const handle = setTimeout(() => { searchRabbits({ q: query }).then(setResults).catch(() => setResults([])) }, 200) return () => clearTimeout(handle) }, [query, open, mode]) if (node.locked) { const text = node.loading ? 'Loading…' : node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name return (

{label}

{text}

) } function selectRabbit(rabbit) { setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name }) if (slot === 'sire' || slot === 'dam') fetchCascade(rabbit.id) setOpen(false) setQuery('') } const canSaveManual = manualForm.name.trim() && manualForm.farm.trim() && manualForm.ear_number.trim() function setManualField(field, fieldValue) { setManualForm((prev) => ({ ...prev, [field]: fieldValue })) } function saveManual() { if (!canSaveManual) return const dob = manualForm.dobUnknown ? 'Unknown' : manualForm.dob setSlotState(slot, { mode: 'manual', id: null, name: serializeManualAncestor({ ...manualForm, dob }) }) setOpen(false) } function markUnknown() { setSlotState(slot, { mode: 'unknown', id: null, name: null }) setOpen(false) } const displayText = node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name const detailText = node.mode === 'manual' ? formatManualAncestorDetails(node.name, (w) => formatWeight(w, weightUnit)) : null return (
{open && (
{[ { value: 'search', label: 'From your herd' }, { value: 'manual', label: 'Type it in' }, ].map((opt) => ( ))}
{mode === 'search' && (
setQuery(e.target.value)} onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })} placeholder="Search by name or ear #…" className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none" />
    {results.map((rabbit, index) => (
  • ))} {query && results.length === 0 && (
  • No matches — try “Type it in”.
  • )}
)} {mode === 'manual' && (
setManualField('farm', e.target.value)} placeholder="Farm Name *" className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none" /> setManualField('name', e.target.value)} placeholder="Rabbit Name *" className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none" /> setManualField('ear_number', e.target.value.toUpperCase())} placeholder="Ear Number *" className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-mono text-sm uppercase focus:border-accent focus:outline-none" />
setManualField('weight_lbs', e.target.value)} className="mt-0.5 w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none" />
setManualField('dob', e.target.value)} className="mt-0.5 w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:!bg-surface-sunken disabled:text-text-secondary" />
)}
)}
) } // One slot plus (in the wide layout) its own sire/dam, joined by the bracket // lines drawn in index.css (.ped-*). function Branch({ 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 (
{childPaths.length > 0 && (
{childPaths.map((childPath) => ( ))}
)}
) } // 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 (
{childPaths.length > 0 && (
{childPaths.map((childPath) => ( ))}
)}
) } function PedigreeTree({ // Exposes setParent(slot, rabbit) so the page can fill the sire/dam from // outside the tree (Add Rabbit's suggested parents). ref, subjectName = 'New Rabbit', initialState, initialCascade, onChange, // Slots a pedigree scan was unsure about. Each is highlighted until the user // edits it (tracked locally in `reviewedSlots`). uncertainSlots, }) { const [state, setState] = useState(() => initialState ?? createInitialPedigreeState()) const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map()) // Ids with a fetch already started, kept outside state so fetchCascade never // 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. const cascadeFetchStarted = useRef(new Set()) const wide = useMediaQuery(WIDE_TREE_QUERY) const [reviewedSlots, setReviewedSlots] = useState(() => new Set()) const flaggedSlots = new Set([...(uncertainSlots ?? [])].filter((slot) => !reviewedSlots.has(slot))) function setSlotState(slot, value) { setState((prev) => ({ ...prev, [slot]: value })) setReviewedSlots((prev) => new Set(prev).add(slot)) } function fetchCascade(id) { if (cascadeCache.has(id) || cascadeFetchStarted.current.has(id)) return cascadeFetchStarted.current.add(id) getRabbitAncestors(id) .then((tree) => setCascadeCache((prev) => new Map(prev).set(id, tree))) .catch(() => setCascadeCache((prev) => new Map(prev).set(id, null))) } useImperativeHandle(ref, () => ({ setParent(slot, rabbit) { setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name }) fetchCascade(rabbit.id) }, })) useEffect(() => { onChange?.(buildPedigreePayload(state, cascadeCache), buildPedigreeSummary(state, cascadeCache)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, cascadeCache]) // If the initial state (e.g. editing an existing rabbit) already has a // linked sire/dam, make sure its cascade subtree gets fetched — initialCascade // only covers what was already available synchronously at mount. useEffect(() => { for (const { slot } of GEN1) { const node = state[slot] if (node.mode === 'linked' && node.id != null) fetchCascade(node.id) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const slotProps = { state, setSlotState, cascadeCache, fetchCascade, flaggedSlots } if (!wide) { return (
{GEN1.map(({ path, slot }) => (

))}
) } // No overflow clipping here: a slot's picker floats out over the card (each // picker flips upward on its own when there isn't room below). return (
{subjectName}
{GEN1.map(({ path }) => ( ))}
) } export default PedigreeTree