MyKindlebox/mykindlebox/client/src/components/PedigreeTree.jsx

452 lines
17 KiB
JavaScript

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 (
<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`
// 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 (
<div
data-testid={`pedigree-node-${slot}`}
data-locked="true"
className="flex h-16 w-full items-center justify-between gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2"
>
<div className="min-w-0 flex-1 overflow-hidden">
<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>
</div>
<LockIcon />
</div>
)
}
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
return (
<div ref={containerRef} data-testid={`pedigree-node-${slot}`} className="relative w-full min-w-[140px]">
<button
ref={triggerRef}
type="button"
onClick={() => {
if (!open) updateDropUp()
setOpen((v) => !v)
if (node.mode === 'manual') {
setMode('manual')
setManualForm(manualFormFromParsed(parseManualAncestor(node.name)))
} else {
setMode('search')
setManualForm(EMPTY_MANUAL_FORM)
}
}}
className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
>
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
<span
className={`w-full truncate font-body text-sm ${node.mode === 'unknown' ? 'text-text-secondary' : 'text-text-primary'}`}
>
{displayText}
</span>
</button>
{open && (
<div
className={`absolute left-0 z-30 w-full min-w-[16rem] max-w-sm rounded-md border border-border bg-surface-raised p-2 shadow-lg ${
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
<div className="mb-2 flex gap-1">
<button
type="button"
onClick={() => setMode('search')}
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
>
Search existing
</button>
<button
type="button"
onClick={() => setMode('manual')}
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
>
Enter manually
</button>
</div>
{mode === 'search' && (
<div>
<input
ref={inputRef}
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
placeholder="Search by name…"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
/>
<ul className="mt-1 max-h-40 overflow-auto">
{results.map((rabbit, index) => (
<li key={rabbit.id}>
<button
type="button"
onClick={() => selectRabbit(rabbit)}
onKeyDown={(e) =>
handleResultKeyDown(e, {
containerEl: containerRef.current,
index,
inputEl: inputRef.current,
close,
})
}
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
>
{rabbit.name}
{rabbit.farm ? ` (${rabbit.farm})` : ''}
</button>
</li>
))}
{query && results.length === 0 && (
<li className="px-2 py-1 text-sm text-text-secondary">No matches</li>
)}
</ul>
</div>
)}
{mode === 'manual' && (
<div className="space-y-1.5">
<input
autoFocus
type="text"
value={manualForm.farm}
onChange={(e) => setManualField('farm', e.target.value)}
placeholder="Farm Name *"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
/>
<input
type="text"
value={manualForm.name}
onChange={(e) => setManualField('name', e.target.value)}
placeholder="Rabbit Name *"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
/>
<input
type="text"
value={manualForm.ear_number}
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
placeholder="Ear Number *"
className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
<input
type="number"
step="0.01"
value={manualForm.weight_lbs}
onChange={(e) => setManualField('weight_lbs', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Date of Birth</label>
<input
type="date"
value={manualForm.dob}
disabled={manualForm.dobUnknown}
onChange={(e) => setManualField('dob', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
/>
</div>
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
<input
type="checkbox"
checked={manualForm.dobUnknown}
onChange={(e) =>
setManualForm((prev) => ({
...prev,
dobUnknown: e.target.checked,
dob: e.target.checked ? '' : prev.dob,
}))
}
className="accent-accent"
/>
Unknown
</label>
<button
type="button"
onClick={saveManual}
disabled={!canSaveManual}
className="w-full rounded bg-accent px-2 py-2 text-sm font-medium text-surface disabled:opacity-50 md:py-1.5 md:text-xs"
>
Save
</button>
</div>
)}
<button
type="button"
onClick={markUnknown}
className="mt-2 w-full rounded px-2 py-2 text-left text-sm text-text-secondary hover:bg-surface-sunken md:py-1 md:text-xs"
>
Mark as Unknown
</button>
</div>
)}
</div>
)
}
function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }) {
return (
<div className="flex min-w-0 flex-1 flex-col justify-around gap-3">
{entries.map(({ path, slot, label }) => (
<AncestorSlot
key={slot}
path={path}
slot={slot}
label={label}
state={state}
setSlotState={setSlotState}
cascadeCache={cascadeCache}
fetchCascade={fetchCascade}
/>
))}
</div>
)
}
// 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({
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 (
<div className="flex flex-col gap-4">
{GEN1.map(({ path, slot }) => (
<section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm">
<h3 className="mb-2 font-display text-base font-bold text-text-primary">
{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">
<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>
)
}
export default PedigreeTree