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:
2026-06-11 08:29:13 -04:00
parent 629bfa1367
commit 58400ffdf7
8 changed files with 275 additions and 67 deletions
+11 -2
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
@@ -41,9 +41,18 @@ async def list_persons(
current: CurrentUser, current: CurrentUser,
deleted: bool = False, deleted: bool = False,
q: str | None = None, q: str | None = None,
ids: str | None = None,
) -> list[PersonRead]: ) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
if q: if ids is not None:
try:
id_list = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
except ValueError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid ids") from exc
persons = await person_service.list_persons_by_ids(
session, viewer_id=current.id, tree=tree, ids=id_list
)
elif q:
persons = await person_service.search_persons( persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q session, viewer_id=current.id, tree=tree, query=q
) )
+21 -2
View File
@@ -4,9 +4,10 @@ engine. Every event has exactly one subject — a Person or a partnership."""
import uuid import uuid
from datetime import date from datetime import date
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import RelationshipType
from app.models.event import Event from app.models.event import Event
from app.models.person import Person from app.models.person import Person
from app.models.place import Place from app.models.place import Place
@@ -124,12 +125,30 @@ async def list_events_for_person(
return await public_view_service.list_public_person_events( return await public_view_service.list_public_person_events(
session, viewer_id=viewer_id, tree=tree, person_id=person_id session, viewer_id=viewer_id, tree=tree, person_id=person_id
) )
# Member view: this person's own events PLUS their partnership events (which
# live on the relationship and show on both partners). Returning both here
# means the person page doesn't have to load every event in the tree.
partner_rel_ids = (
select(Relationship.id)
.where(
Relationship.tree_id == tree.id,
Relationship.type == RelationshipType.partnership,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person_id,
Relationship.person_to_id == person_id,
),
)
)
stmt = ( stmt = (
select(Event) select(Event)
.where( .where(
Event.tree_id == tree.id, Event.tree_id == tree.id,
Event.person_id == person_id,
Event.deleted_at.is_(None), Event.deleted_at.is_(None),
or_(
Event.person_id == person_id,
Event.relationship_id.in_(partner_rel_ids),
),
) )
.order_by(Event.date_start.nulls_last(), Event.created_at) .order_by(Event.date_start.nulls_last(), Event.created_at)
) )
+41
View File
@@ -404,6 +404,47 @@ async def list_persons(
return visible return visible
async def list_persons_by_ids(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, ids: list[uuid.UUID]
) -> list[Person]:
"""Just the named persons (privacy-filtered, names batched). Lets a page show
the names of someone's relatives without loading the whole tree."""
role = await privacy.get_membership_role(session, viewer_id, tree.id)
if role is None and not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
if not ids:
return []
persons = list(
(
await session.execute(
select(Person).where(
Person.id.in_(ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalars().all()
)
if role is not None:
await _attach_primary_names(session, persons)
return persons
visible: list[Person] = []
full: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
)
if vis == Visibility.hidden:
continue
if vis == Visibility.redacted:
_redact(person)
else:
full.append(person)
visible.append(person)
await _attach_primary_names(session, full)
return visible
async def search_persons( async def search_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50 session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
) -> list[Person]: ) -> list[Person]:
+60
View File
@@ -0,0 +1,60 @@
"""Backing the trimmed person-page fetch: batch persons by id (for relative-name
display) and partnership events on the per-person events endpoint (so the page
doesn't load every event in the tree)."""
from tests.conftest import auth, register
async def _tree(client, h):
return (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
async def test_list_persons_by_ids(client):
h = auth(await register(client, "ids@ex.com"))
tid = await _tree(client, h)
a = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Aaa"}, headers=h)).json()["id"]
b = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Bbb"}, headers=h)).json()["id"]
c = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Ccc"}, headers=h)).json()["id"]
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": f"{a},{c}"}, headers=h)
assert r.status_code == 200
assert {p["id"] for p in r.json()} == {a, c} # only the requested, not b
assert all(p["primary_name"] for p in r.json()) # names resolved
assert (
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": "nope"}, headers=h)
).status_code == 422
assert (
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": ""}, headers=h)
).json() == []
async def test_person_events_include_partnership(client):
h = auth(await register(client, "pev@ex.com"))
tid = await _tree(client, h)
p1 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P1"}, headers=h)).json()["id"]
p2 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P2"}, headers=h)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": p1, "date_value": "1900"},
headers=h,
)
rel = (
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": p1, "person_to_id": p2},
headers=h,
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "marriage", "relationship_id": rel, "date_value": "1925"},
headers=h,
)
# P1's events: own birth + the partnership marriage, in one call.
e1 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p1}/events", headers=h)).json()}
assert {"birth", "marriage"} <= e1
# The marriage shows on BOTH partners' pages.
e2 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p2}/events", headers=h)).json()}
assert "marriage" in e2
@@ -135,7 +135,6 @@ export default function PersonDetailPage() {
const [evType, setEvType] = useState("birth"); const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState(""); const [evTypeOther, setEvTypeOther] = useState("");
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
const [dateQual, setDateQual] = useState("exact"); const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState(""); const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState(""); const [dateMonth, setDateMonth] = useState("");
@@ -189,8 +188,9 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); setPerson(p.data ?? null);
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([ // Person-scoped fetches only — the page no longer pulls the whole tree.
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), // /persons/{id}/events now includes this person's partnership events too.
const [nm, mine, tr, ev, rl, src, cit, med] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
}), }),
@@ -204,22 +204,49 @@ export default function PersonDetailPage() {
}), }),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
]); ]);
setPeople(all.data ?? []);
setNames(nm.data ?? []); setNames(nm.data ?? []);
setMe(mine.data ?? null); setMe(mine.data ?? null);
setTree(tr.data ?? null); setTree(tr.data ?? null);
setEvents(ev.data ?? []); setEvents(ev.data ?? []);
setAllEvents(evAll.data ?? []);
setMedia(med.data ?? []); setMedia(med.data ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []); setSources(src.data ?? []);
setCitations(cit.data ?? []); setCitations(cit.data ?? []);
// Resolve the names of just this person's relatives (for display), by id —
// not the whole tree. The relationship/spouse pickers search on demand.
const relList = rl.data ?? [];
const relatedIds = Array.from(
new Set(
relList
.flatMap((r) => [r.person_from_id, r.person_to_id])
.filter((id): id is string => !!id && id !== personId),
),
);
if (relatedIds.length) {
const rel = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { ids: relatedIds.join(",") } },
});
setPeople(rel.data ?? []);
} else {
setPeople([]);
}
setReady(true); setReady(true);
}, [router, treeId, personId]); }, [router, treeId, personId]);
// Server-side fuzzy search for the relative/spouse pickers — avoids loading
// every person just to search.
const searchPeople = useCallback(
async (query: string) => {
const r = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q: query } },
});
return (r.data ?? []).filter((pp) => pp.id !== personId);
},
[treeId, personId],
);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
@@ -233,7 +260,6 @@ export default function PersonDetailPage() {
return (id: string) => m.get(id) ?? "source"; return (id: string) => m.get(id) ?? "source";
}, [sources]); }, [sources]);
const others = people.filter((p) => p.id !== personId);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId); const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId); const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter((r) => r.type === "partnership"); const partners = rels.filter((r) => r.type === "partnership");
@@ -241,22 +267,18 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id); const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId); const personCites = citations.filter((c) => c.person_id === personId);
// Partnership events live on the relationship and show on both partners. // Partnership events live on the relationship and show on both partners; the
// /persons/{id}/events endpoint now returns them alongside personal events.
const myPartnerRels = rels.filter( const myPartnerRels = rels.filter(
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId), (r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
); );
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
const relEvents = allEvents.filter(
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
);
const spouseOfRelEvent = (relId: string | null | undefined) => { const spouseOfRelEvent = (relId: string | null | undefined) => {
const r = myPartnerRels.find((x) => x.id === relId); const r = myPartnerRels.find((x) => x.id === relId);
if (!r) return null; if (!r) return null;
return r.person_from_id === personId ? r.person_to_id : r.person_from_id; return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
}; };
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t); const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
// Personal events + this person's partnership events, shown together. const shownEvents = events;
const shownEvents = [...events, ...relEvents];
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -1090,7 +1112,7 @@ export default function PersonDetailPage() {
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Spouse / partner</span> <span className="text-xs text-[var(--muted)]">Spouse / partner</span>
<PersonCombobox <PersonCombobox
people={others} onSearch={searchPeople}
value={evSpouse} value={evSpouse}
onChange={setEvSpouse} onChange={setEvSpouse}
placeholder="Search for a spouse…" placeholder="Search for a spouse…"
@@ -1158,36 +1180,32 @@ export default function PersonDetailPage() {
</div> </div>
)} )}
{others.length === 0 ? ( <form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p> <span className="text-sm text-[var(--muted)]">Add</span>
) : ( <select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2"> <option value="parent">parent</option>
<span className="text-sm text-[var(--muted)]">Add</span> <option value="child">child</option>
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}> <option value="partner">partner</option>
<option value="parent">parent</option> <option value="sibling">sibling</option>
<option value="child">child</option> </select>
<option value="partner">partner</option> <PersonCombobox
<option value="sibling">sibling</option> onSearch={searchPeople}
value={relOther}
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select> </select>
<PersonCombobox )}
people={others} <Button type="submit">Link</Button>
value={relOther} </form>
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
)}
<Button type="submit">Link</Button>
</form>
)}
{relErr && <p className="text-sm text-red-600">{relErr}</p>} {relErr && <p className="text-sm text-red-600">{relErr}</p>}
</CardContent> </CardContent>
</Card> </Card>
+63 -19
View File
@@ -1,26 +1,30 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
/** /**
* A type-to-filter person picker. Shows a text input; as you type, a dropdown * A type-to-pick person picker. Two modes:
* of matching people appears. Selecting one sets `value` (a person id) and * - client (`people`): filter a preloaded list in the browser.
* fills the input with their name. Replaces a plain <select> when the list is * - server (`onSearch`): query the backend (debounced) as you type — the
* long enough that scanning it by hand is painful. * 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({ export function PersonCombobox({
people, people,
onSearch,
value, value,
onChange, onChange,
onCreate, onCreate,
placeholder = "Search for a person…", placeholder = "Search for a person…",
className, className,
}: { }: {
people: Person[]; people?: Person[];
onSearch?: (q: string) => Promise<Person[]>;
value: string; value: string;
onChange: (id: string) => void; onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */ /** When set, the dropdown offers a "Create '<typed name>'" action. */
@@ -30,21 +34,27 @@ export function PersonCombobox({
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [results, setResults] = useState<Person[]>([]);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); 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( const remember = useCallback((ps: Person[] | undefined) => {
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])), for (const p of ps ?? []) known.current.set(p.id, p.primary_name ?? "Unnamed");
[people], }, []);
); 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 // Keep the input text in sync when the selection changes externally
// (e.g. cleared to "" after a successful add). // (e.g. cleared to "" after a successful add).
useEffect(() => { useEffect(() => {
if (!value) { if (!value) setQuery("");
setQuery(""); else if (!open) setQuery(nameOf(value));
} else if (!open) {
setQuery(nameOf.get(value) ?? "");
}
}, [value, open, nameOf]); }, [value, open, nameOf]);
// Close on outside click. // Close on outside click.
@@ -56,17 +66,48 @@ export function PersonCombobox({
return () => document.removeEventListener("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(() => { const matches = useMemo(() => {
if (onSearch) return results.slice(0, 10);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const pool = q const pool = q
? people.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) ? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: people; : (people ?? []);
return pool.slice(0, 10); return pool.slice(0, 10);
}, [query, people]); }, [query, results, people, onSearch]);
const base = 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"; "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 ( return (
<div ref={wrapRef} className="relative"> <div ref={wrapRef} className="relative">
<input <input
@@ -80,8 +121,11 @@ export function PersonCombobox({
if (value) onChange(""); // typing invalidates the prior pick 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"> <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) => ( {matches.map((p) => (
<li key={p.id}> <li key={p.id}>
<button <button
+1
View File
@@ -2719,6 +2719,7 @@ export interface operations {
query?: { query?: {
deleted?: boolean; deleted?: boolean;
q?: string | null; q?: string | null;
ids?: string | null;
}; };
header?: never; header?: never;
path: { path: {
+16
View File
@@ -851,6 +851,22 @@
], ],
"title": "Q" "title": "Q"
} }
},
{
"name": "ids",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Ids"
}
} }
], ],
"responses": { "responses": {