import { useEffect, useRef, useState } from 'react'
import { searchRabbits, getRabbitAncestors } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
import { useMediaQuery } from '../hooks/useMediaQuery'
import {
ALL_SLOTS,
GEN1,
GEN2,
GEN3,
createInitialPedigreeState,
resolveEffectiveNode,
buildPedigreePayload,
buildPedigreeSummary,
serializeManualAncestor,
parseManualAncestor,
formatManualAncestorDisplay,
} from '../lib/pedigree'
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: false }
// 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]))
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 ?? '0'),
dob: isKnownDate ? parsed.dob : '',
dobUnknown: !isKnownDate,
}
}
function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade }) {
const node = resolveEffectiveNode(path, state, cascadeCache)
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 (
)
}
// 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({
subjectName = 'New Rabbit',
initialState,
initialCascade,
onChange,
}) {
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)
function setSlotState(slot, value) {
setState((prev) => ({ ...prev, [slot]: value }))
}
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)))
}
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
}, [])
if (!wide) {
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
return (