Shared marriage events; deterministic parent ordering

- Partnership life events (marriage/divorce/engagement) now attach to the
  couple's relationship, not each person. The add-event form asks for the
  spouse, finds-or-creates the partnership, and writes ONE event on it — shown
  on both partners' pages ("· with <spouse>"), entered once. Event values
  (RELI/OCCU detail) now render too.
- Family-view pedigree orders parents deterministically (father on top, mother
  below, stable fallback when gender is unknown) instead of by which link was
  created first.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:15:54 -04:00
parent 2669543e56
commit 5106538934
2 changed files with 104 additions and 5 deletions
+11 -1
View File
@@ -86,8 +86,18 @@ export default function FamilyViewPage() {
}, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
// Order parents deterministically: father (male) on top, mother below, with a
// stable fallback when gender is unknown (so it doesn't depend on which link
// happened to be created first).
const parentRank = (id: string) => {
const g = byId.get(id)?.gender;
return g === "male" ? 0 : g === "female" ? 1 : 2;
};
const parentsOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
rels
.filter((r) => r.type === "parent_child" && r.person_to_id === id)
.map((r) => r.person_from_id)
.sort((a, b) => parentRank(a) - parentRank(b));
const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) =>
@@ -46,6 +46,9 @@ const EVENT_TYPES = [
"residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other",
];
// These belong to a couple, not a person — they attach to the partnership and
// show on both partners' pages, so they're only entered once.
const PARTNERSHIP_EVENTS = ["marriage", "divorce", "engagement"];
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
@@ -117,6 +120,8 @@ export default function PersonDetailPage() {
const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState("");
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 [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
@@ -169,7 +174,7 @@ export default function PersonDetailPage() {
return;
}
setPerson(p.data ?? null);
const [all, nm, mine, tr, ev, rl, src, cit] = await Promise.all([
const [all, nm, mine, tr, ev, rl, src, cit, evAll] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
@@ -184,12 +189,14 @@ export default function PersonDetailPage() {
}),
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}/events", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setTree(tr.data ?? null);
setEvents(ev.data ?? []);
setAllEvents(evAll.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.data ?? []);
@@ -217,6 +224,23 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
// Partnership events live on the relationship and show on both partners.
const myPartnerRels = rels.filter(
(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 r = myPartnerRels.find((x) => x.id === relId);
if (!r) return null;
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
};
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
// Personal events + this person's partnership events, shown together.
const shownEvents = [...events, ...relEvents];
async function addEvent(e: React.FormEvent) {
e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType;
@@ -227,9 +251,47 @@ export default function PersonDetailPage() {
dateMonth,
dateYear,
);
let body: components["schemas"]["EventCreate"] = {
event_type,
person_id: personId,
date_value,
date_start,
date_precision,
};
// A partnership event belongs to the couple: attach it to the partnership
// relationship (creating it if needed) so it's entered once and shows on
// both partners.
if (isPartnershipType(event_type)) {
if (!evSpouse) return;
let relId = myPartnerRels.find(
(r) => r.person_from_id === evSpouse || r.person_to_id === evSpouse,
)?.id;
if (!relId) {
const { data, error: relErr } = await api.POST(
"/api/v1/trees/{tree_id}/relationships",
{
params: { path: { tree_id: treeId } },
body: { type: "partnership", person_from_id: personId, person_to_id: evSpouse },
},
);
if (relErr || !data) return;
relId = data.id;
}
body = {
event_type,
relationship_id: relId,
person_id: null,
date_value,
date_start,
date_precision,
};
}
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision },
body,
});
if (!error) {
setDateDay("");
@@ -237,6 +299,7 @@ export default function PersonDetailPage() {
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
setEvSpouse("");
load();
}
}
@@ -777,11 +840,11 @@ export default function PersonDetailPage() {
<CardTitle className="text-base">Life events</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{events.length === 0 ? (
{shownEvents.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p>
) : (
<ul className="space-y-2">
{events.map((ev) =>
{shownEvents.map((ev) =>
editId === ev.id ? (
<li key={ev.id}>
<form
@@ -850,9 +913,18 @@ export default function PersonDetailPage() {
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.relationship_id ? (
<span className="text-[var(--muted)]">
{" "}
· with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")}
</span>
) : null}
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
{ev.detail ? (
<span className="text-[var(--muted)]"> {ev.detail}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
@@ -901,6 +973,23 @@ export default function PersonDetailPage() {
/>
</label>
)}
{isPartnershipType(evType) && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
<select
className={fieldCls}
value={evSpouse}
onChange={(e) => setEvSpouse(e.target.value)}
>
<option value=""> choose </option>
{others.map((p) => (
<option key={p.id} value={p.id}>
{p.primary_name ?? "Unnamed"}
</option>
))}
</select>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>