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

549 lines
22 KiB
JavaScript

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 (
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 opacity-70" 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 ?? 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 (
<div
data-testid={`pedigree-node-${slot}`}
data-locked="true"
title="Filled in from the linked parent's own pedigree"
className={`flex min-h-[58px] w-full items-center justify-between gap-2 rounded-2xl px-3 py-2 ${
node.mode === 'unknown' ? 'border-2 border-dashed border-border text-text-faint' : `${sideTone(path)} opacity-75`
}`}
>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate font-body text-[11px] font-bold opacity-80">{label}</p>
<p className="truncate font-body text-sm font-semibold">{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
const detailText =
node.mode === 'manual' ? formatManualAncestorDetails(node.name, (w) => formatWeight(w, weightUnit)) : null
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)
}
}}
aria-expanded={open}
className={`flex min-h-[58px] w-full min-w-0 flex-col items-start justify-center gap-px overflow-hidden rounded-2xl px-3 py-2 text-left transition-[transform,box-shadow] hover:-translate-y-0.5 focus:outline-none ${
node.mode === 'unknown'
? 'border-2 border-dashed border-border-strong bg-surface-raised text-text-secondary hover:border-accent hover:text-text-primary'
: sideTone(path)
} ${flagged ? 'ring-2 ring-c5 ring-offset-2 ring-offset-surface-raised' : ''} ${open ? 'shadow-pop' : ''}`}
>
<span className="flex w-full items-center gap-1.5 font-body text-[11px] font-bold opacity-80">
<span className="truncate">{label}</span>
{flagged && (
<span className="shrink-0 rounded-full bg-c5 px-1.5 font-extrabold text-[#1c1530]">Check</span>
)}
</span>
<span className="flex w-full items-center gap-1 truncate font-body text-sm font-extrabold">
{node.mode === 'unknown' && <Icon name="add" className="h-3.5 w-3.5 shrink-0" strokeWidth={2.6} />}
<span className="truncate">{node.mode === 'unknown' ? 'Unknown — choose' : displayText}</span>
</span>
{detailText && <span className="w-full truncate font-body text-xs opacity-85">{detailText}</span>}
</button>
{open && (
<div
className={`absolute left-0 z-30 w-full min-w-[17rem] max-w-sm rounded-3xl border border-border bg-surface-raised p-2.5 text-text-primary shadow-pop ${
dropUp ? 'bottom-full mb-2' : 'top-full mt-2'
}`}
>
<div className="mb-2 flex gap-1 rounded-full bg-surface-sunken p-1">
{[
{ value: 'search', label: 'From your herd' },
{ value: 'manual', label: 'Type it in' },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setMode(opt.value)}
aria-pressed={mode === opt.value}
className={`min-h-[36px] flex-1 rounded-full px-2 font-body text-xs font-bold transition-colors ${
mode === opt.value ? 'bg-surface-raised text-text-primary shadow-card' : 'text-text-secondary hover:text-text-primary'
}`}
>
{opt.label}
</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 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"
/>
<ul className="mt-1.5 max-h-52 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="flex w-full items-center gap-2.5 rounded-2xl px-2 py-1.5 text-left hover:bg-surface-sunken focus:bg-surface-sunken"
>
<CoatAvatar rabbit={rabbit} className="h-8 w-8" />
<span className="min-w-0">
<span className="block truncate font-body text-sm font-bold">{rabbit.name}</span>
{(rabbit.farm || rabbit.ear_number) && (
<span className="block truncate font-body text-xs text-text-secondary">
{[rabbit.farm, rabbit.ear_number && `#${rabbit.ear_number}`].filter(Boolean).join(' · ')}
</span>
)}
</span>
</button>
</li>
))}
{query && results.length === 0 && (
<li className="px-2 py-2 font-body text-sm text-text-secondary">No matches — try “Type it in”.</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-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
/>
<input
type="text"
value={manualForm.name}
onChange={(e) => 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"
/>
<input
type="text"
value={manualForm.ear_number}
onChange={(e) => 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"
/>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">
Weight (lbs) — {MANUAL_ANCESTOR_DEFAULTS.weight_lbs} if not known
</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-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
/>
</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-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"
/>
</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="min-h-[40px] w-full rounded-full bg-accent px-3 font-body text-sm font-bold text-on-accent disabled:opacity-50"
>
Save
</button>
</div>
)}
<button
type="button"
onClick={markUnknown}
className="mt-1.5 min-h-[38px] w-full rounded-full px-3 text-center font-body text-xs font-bold text-text-secondary hover:bg-surface-sunken"
>
Mark as Unknown
</button>
</div>
)}
</div>
)
}
// 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 (
<div className="ped-br">
<div className={`ped-nw ${childPaths.length ? 'has' : ''}`} style={{ width: WIDTHS[path.length] }}>
<AncestorSlot
path={entry.path}
slot={entry.slot}
label={entry.label}
state={slotProps.state}
setSlotState={slotProps.setSlotState}
cascadeCache={slotProps.cascadeCache}
fetchCascade={slotProps.fetchCascade}
flagged={slotProps.flaggedSlots.has(entry.slot)}
/>
</div>
{childPaths.length > 0 && (
<div className="ped-pair">
{childPaths.map((childPath) => (
<Branch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
))}
</div>
)}
</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}
state={slotProps.state}
setSlotState={slotProps.setSlotState}
cascadeCache={slotProps.cascadeCache}
fetchCascade={slotProps.fetchCascade}
flagged={slotProps.flaggedSlots.has(entry.slot)}
/>
{childPaths.length > 0 && (
<div className="ml-2 flex flex-col gap-2 border-l-[2.5px] border-border-strong pl-3">
{childPaths.map((childPath) => (
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
))}
</div>
)}
</div>
)
}
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 (
<div className="flex flex-col gap-4">
{GEN1.map(({ path, slot }) => (
<section key={slot} className="rounded-3xl border border-border bg-surface-raised p-3.5 shadow-card">
<h3 className="mb-2.5 flex items-center gap-2 px-1 font-display text-base font-bold text-text-primary">
<span className={`h-2.5 w-2.5 rounded-full ${slot === 'sire' ? 'bg-c6' : 'bg-c2'}`} aria-hidden="true" />
{slot === 'sire' ? "Sire's side" : "Dam's side"}
</h3>
<StackedBranch path={path} slotProps={slotProps} />
</section>
))}
</div>
)
}
// 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 (
<div className="w-full rounded-3xl border border-border bg-surface-raised p-5 shadow-card">
<div className="ped-br">
<div className="ped-nw has" style={{ width: 150 }}>
<div className="flex min-h-[72px] items-center justify-center rounded-2xl bg-text-primary px-3 text-center">
<span className="truncate font-display text-base font-bold text-surface">{subjectName}</span>
</div>
</div>
<div className="ped-pair">
{GEN1.map(({ path }) => (
<Branch key={path.join('.')} path={path} slotProps={slotProps} />
))}
</div>
</div>
</div>
)
}
export default PedigreeTree