Preserve focused person across tree/people/detail navigation

The Tree view, People (Family) view, and person detail page each tracked
the "current person" independently, so moving between them reset you to the
home person. The detail page's "← Back to tree" link also pointed at the
People view (not the Tree) and carried no person, so it always landed on the
default person.

Make the focused person a URL-encoded concept that travels across views:

- Tree and People views read ?focus=<id> on load and mirror the focused
  person back into the URL via router.replace (no history spam), so leaving
  and returning keeps you centered where you were. Bookmarks/shared links
  also resolve to the right person.
- "Open person" links carry ?from=tree | ?from=people.
- The detail page's back link is now origin-aware: "← Back to Tree" →
  /tree?focus=<id> or "← Back to People" → /?focus=<id>, returning you in
  place instead of to the home person.
- Add a "View in tree →" link on the detail page — the previously missing
  direct jump from a person to the tree re-rooted on them.
- person→person relationship links (and create-relative redirect) pass
  `from` through so click-chains keep their anchor.

Also gitignore *.tsbuildinfo (Next build artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-08 14:48:32 -04:00
parent fae1162ff8
commit c5a2a7f0d4
4 changed files with 70 additions and 15 deletions
+22 -5
View File
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
@@ -26,7 +26,11 @@ type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
const treeId = params.id;
// ?focus=… lets another view (or a person page) hand us who to center on.
// Read once at mount so the focus→URL sync below doesn't trigger a refetch.
const initialFocus = useRef<string | null>(searchParams.get("focus"));
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
@@ -58,10 +62,13 @@ export default function FamilyViewPage() {
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current)
? initialFocus.current
: null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null);
setReady(true);
}, [router, treeId]);
@@ -69,6 +76,16 @@ export default function FamilyViewPage() {
load();
}, [load]);
// Keep the focused person in the URL (?focus=…) so leaving and returning —
// e.g. opening a person then coming back — lands on the same person rather
// than resetting to the home person. `replace` keeps history clean.
useEffect(() => {
if (!focusId || searchParams.get("focus") === focusId) return;
const sp = new URLSearchParams(searchParams.toString());
sp.set("focus", focusId);
router.replace(`/trees/${treeId}?${sp.toString()}`, { scroll: false });
}, [focusId, searchParams, router, treeId]);
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
useEffect(() => {
const q = search.trim();
@@ -363,7 +380,7 @@ export default function FamilyViewPage() {
+ Add person
</Button>
<Link
href={`/trees/${treeId}/persons/${focus.id}`}
href={`/trees/${treeId}/persons/${focus.id}?from=people`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
@@ -432,7 +449,7 @@ export default function FamilyViewPage() {
<div key={p.id} className="flex items-center gap-1">
<PersonBox id={p.id} muted />
<Link
href={`/trees/${treeId}/persons/${p.id}`}
href={`/trees/${treeId}/persons/${p.id}?from=people`}
className="text-xs text-bronze hover:underline"
>
open