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>
This commit is contained in:
@@ -1,26 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { components } from "@/lib/api/schema";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
|
||||
/**
|
||||
* A type-to-filter person picker. Shows a text input; as you type, a dropdown
|
||||
* of matching people appears. Selecting one sets `value` (a person id) and
|
||||
* fills the input with their name. Replaces a plain <select> when the list is
|
||||
* long enough that scanning it by hand is painful.
|
||||
* 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[];
|
||||
people?: Person[];
|
||||
onSearch?: (q: string) => Promise<Person[]>;
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
/** When set, the dropdown offers a "Create '<typed name>'" action. */
|
||||
@@ -30,21 +34,27 @@ export function PersonCombobox({
|
||||
}) {
|
||||
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 nameOf = useMemo(
|
||||
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])),
|
||||
[people],
|
||||
);
|
||||
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.get(value) ?? "");
|
||||
}
|
||||
if (!value) setQuery("");
|
||||
else if (!open) setQuery(nameOf(value));
|
||||
}, [value, open, nameOf]);
|
||||
|
||||
// Close on outside click.
|
||||
@@ -56,17 +66,48 @@ export function PersonCombobox({
|
||||
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;
|
||||
? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
|
||||
: (people ?? []);
|
||||
return pool.slice(0, 10);
|
||||
}, [query, people]);
|
||||
}, [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
|
||||
@@ -80,8 +121,11 @@ export function PersonCombobox({
|
||||
if (value) onChange(""); // typing invalidates the prior pick
|
||||
}}
|
||||
/>
|
||||
{open && (matches.length > 0 || (onCreate && query.trim())) && (
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user