Update PedigreeTree and RabbitDetail components for improved ear number handling and display

This commit is contained in:
fireshaper 2026-06-25 23:34:48 -04:00
parent 4544aa8060
commit 99a1c65c9f
5 changed files with 48 additions and 17 deletions

View File

@ -1,6 +1,3 @@
# MyKindlebox — Implementation Plan # MyKindlebox — Implementation Plan
**Version:** 1.1 **Version:** 1.1

View File

@ -14,7 +14,12 @@ import {
formatManualAncestorDisplay, formatManualAncestorDisplay,
} from '../lib/pedigree' } from '../lib/pedigree'
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: true } 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
// `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
@ -40,6 +45,20 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
const containerRef = useRef(null) const containerRef = useRef(null)
const inputRef = useRef(null) const inputRef = useRef(null)
const triggerRef = 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 - rect.bottom
const spaceAbove = rect.top
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
}
function close() { function close() {
setOpen(false) setOpen(false)
@ -125,6 +144,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
ref={triggerRef} ref={triggerRef}
type="button" type="button"
onClick={() => { onClick={() => {
if (!open) updateDropUp()
setOpen((v) => !v) setOpen((v) => !v)
if (node.mode === 'manual') { if (node.mode === 'manual') {
setMode('manual') setMode('manual')
@ -145,7 +165,11 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
</button> </button>
{open && ( {open && (
<div className="absolute left-0 top-full z-10 mt-1 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm"> <div
className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
<div className="mb-2 flex gap-1"> <div className="mb-2 flex gap-1">
<button <button
type="button" type="button"
@ -223,9 +247,9 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<input <input
type="text" type="text"
value={manualForm.ear_number} value={manualForm.ear_number}
onChange={(e) => setManualField('ear_number', e.target.value)} 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 focus:outline-none focus:ring-2 focus:ring-accent" className="w-full rounded border border-border px-2 py-1 text-sm uppercase 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>

View File

@ -24,6 +24,9 @@ function AncestorNodeView({ path, label, ancestors }) {
{node.name} {node.name}
{node.farm ? ` (${node.farm})` : ''} {node.farm ? ` (${node.farm})` : ''}
</span> </span>
{node.ear_number ? (
<span className="w-full truncate font-body text-xs text-text-secondary">Ear #{node.ear_number}</span>
) : null}
</Link> </Link>
) )
} }
@ -48,19 +51,26 @@ function Generation({ entries, ancestors }) {
) )
} }
function PedigreeTreeReadOnly({ subjectName, ancestors }) { // `generations` controls how many ancestor columns to render. The detail page
// uses 2 (parents + grandparents) to keep the inline tree compact; the full
// 3-generation tree lives on the dedicated print page.
function PedigreeTreeReadOnly({ subjectName, ancestors, generations = 3 }) {
// Each generation column is ~176px (incl. gap); narrower min-width for fewer
// generations lets the tree fit its column without horizontal scrolling.
const minWidth = generations >= 3 ? 720 : 544
return ( return (
// The scroll container fills (and shrinks with) its column; the 720px-wide // The scroll container fills (and shrinks with) its column; the tree scrolls
// tree scrolls horizontally inside it rather than forcing the page wider and // horizontally inside it rather than forcing the page wider and pushing the
// pushing the timeline column off-screen. // timeline column off-screen.
<div className="w-full overflow-x-auto p-4"> <div className="w-full overflow-x-auto p-4">
<div className="flex min-w-[720px] items-center gap-4"> <div className="flex items-center gap-4" style={{ minWidth }}>
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center"> <div className="flex h-16 w-28 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>
<Generation entries={GEN1} ancestors={ancestors} /> <Generation entries={GEN1} ancestors={ancestors} />
<Generation entries={GEN2} ancestors={ancestors} /> {generations >= 2 && <Generation entries={GEN2} ancestors={ancestors} />}
<Generation entries={GEN3} ancestors={ancestors} /> {generations >= 3 && <Generation entries={GEN3} ancestors={ancestors} />}
</div> </div>
</div> </div>
) )

View File

@ -166,8 +166,8 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
<input <input
type="text" type="text"
value={value.ear_number ?? ''} value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value)} onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
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="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"
/> />
</div> </div>

View File

@ -247,7 +247,7 @@ function RabbitDetail() {
<div className="min-w-0 flex-1 space-y-6"> <div className="min-w-0 flex-1 space-y-6">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
{ancestors ? ( {ancestors ? (
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} /> <PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} generations={2} />
) : ( ) : (
<p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p> <p className="p-6 font-body text-sm text-text-secondary">Loading pedigree…</p>
)} )}