Files
justin 58400ffdf7 Person page: server-side search; stop loading the whole tree
The person page fetched the entire tree on every open — all persons (to build a
name map + power the relative pickers) and all events (to find partnership
events). On a 2k-person tree that's a ~230KB person list + ~600KB event list per
view. Now it loads only what the page shows:

Frontend:
- The relationship & spouse pickers use the backend's fuzzy pg_trgm search
  (debounced, typo-tolerant) instead of substring-filtering a preloaded array —
  better search, and no need to preload every person. PersonCombobox gained an
  `onSearch` server mode (client `people` mode still works).
- The page drops the all-persons and all-events fetches; it resolves just this
  person's relatives' names via GET /persons?ids=..., and reads partnership
  events from the per-person events endpoint.

Backend:
- GET /trees/{id}/persons?ids=a,b,c — batch by id (privacy-filtered, names
  batched), for relative-name display.
- list_events_for_person (member path) now also returns the person's partnership
  events, so the page needn't scan every event in the tree.

Adversarial review (frontend logic + backend/privacy) found no issues. Suite 105
passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:29:13 -04:00

166 lines
5.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema";
type Person = components["schemas"]["PersonRead"];
/**
* A type-to-pick person picker. Two modes:
* - client (`people`): filter a preloaded list in the browser.
* - server (`onSearch`): query the backend (debounced) as you type — the
* preferred mode for large trees, so the page doesn't
* have to preload every person just to search.
* Selecting one sets `value` (a person id) and fills the input with their name.
*/
export function PersonCombobox({
people,
onSearch,
value,
onChange,
onCreate,
placeholder = "Search for a person…",
className,
}: {
people?: Person[];
onSearch?: (q: string) => Promise<Person[]>;
value: string;
onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */
onCreate?: (name: string) => void;
placeholder?: string;
className?: string;
}) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [results, setResults] = useState<Person[]>([]);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
// Names we've seen (from the list or search results), so a selected value
// keeps displaying its name even in server mode.
const known = useRef<Map<string, string>>(new Map());
const remember = useCallback((ps: Person[] | undefined) => {
for (const p of ps ?? []) known.current.set(p.id, p.primary_name ?? "Unnamed");
}, []);
useEffect(() => {
remember(people);
}, [people, remember]);
const nameOf = useCallback((id: string) => known.current.get(id) ?? "", []);
// Keep the input text in sync when the selection changes externally
// (e.g. cleared to "" after a successful add).
useEffect(() => {
if (!value) setQuery("");
else if (!open) setQuery(nameOf(value));
}, [value, open, nameOf]);
// Close on outside click.
useEffect(() => {
function onDoc(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
// Server search, debounced. Stale responses are dropped via `cancelled`.
useEffect(() => {
if (!onSearch) return;
const q = query.trim();
if (!q) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
let cancelled = false;
const t = setTimeout(async () => {
try {
const r = await onSearch(q);
if (cancelled) return;
remember(r);
setResults(r);
} finally {
if (!cancelled) setLoading(false);
}
}, 160);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [query, onSearch, remember]);
const matches = useMemo(() => {
if (onSearch) return results.slice(0, 10);
const q = query.trim().toLowerCase();
const pool = q
? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: (people ?? []);
return pool.slice(0, 10);
}, [query, results, people, onSearch]);
const base =
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
const showDropdown =
open && (matches.length > 0 || loading || (onCreate && query.trim()));
return (
<div ref={wrapRef} className="relative">
<input
className={`${base} ${className ?? ""}`}
value={query}
placeholder={placeholder}
onFocus={() => setOpen(true)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
if (value) onChange(""); // typing invalidates the prior pick
}}
/>
{showDropdown && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{loading && matches.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--muted)]">Searching</li>
)}
{matches.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => {
onChange(p.id);
setQuery(p.primary_name ?? "Unnamed");
setOpen(false);
}}
className={`block w-full px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))] ${
p.id === value ? "text-bronze" : ""
}`}
>
{p.primary_name ?? "Unnamed"}
</button>
</li>
))}
{onCreate && query.trim() && (
<li className={matches.length > 0 ? "border-t border-[var(--border)]" : ""}>
<button
type="button"
onClick={() => {
const name = query.trim();
setOpen(false);
onCreate(name);
}}
className="block w-full px-3 py-2 text-left text-sm text-bronze hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
+ Create {query.trim()}
</button>
</li>
)}
</ul>
)}
</div>
);
}