Merge pull request 'Shared marriage events; deterministic parent ordering' (#25) from marriage-event-parent-order into main
build-frontend / build (push) Successful in 1m23s

This commit was merged in pull request #25.
This commit is contained in:
2026-06-07 11:15:55 -04:00
2 changed files with 104 additions and 5 deletions
+11 -1
View File
@@ -86,8 +86,18 @@ export default function FamilyViewPage() {
}, [search, treeId]); }, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); 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) => 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) => const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id); rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) => const partnersOf = (id: string) =>
@@ -46,6 +46,9 @@ const EVENT_TYPES = [
"residence", "census", "immigration", "emigration", "occupation", "education", "residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other", "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 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 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" }; 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 [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState(""); 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 [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState(""); const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState(""); const [dateMonth, setDateMonth] = useState("");
@@ -169,7 +174,7 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); 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", { params: { path: { tree_id: treeId } } }),
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 } },
@@ -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}/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 } } }),
]); ]);
setPeople(all.data ?? []); 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 ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []); setSources(src.data ?? []);
setCitations(cit.data ?? []); setCitations(cit.data ?? []);
@@ -217,6 +224,23 @@ 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.
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) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType; const event_type = evType === "other" ? evTypeOther.trim() : evType;
@@ -227,9 +251,47 @@ export default function PersonDetailPage() {
dateMonth, dateMonth,
dateYear, 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", { const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision }, body,
}); });
if (!error) { if (!error) {
setDateDay(""); setDateDay("");
@@ -237,6 +299,7 @@ export default function PersonDetailPage() {
setDateYear(""); setDateYear("");
setDateQual("exact"); setDateQual("exact");
setEvTypeOther(""); setEvTypeOther("");
setEvSpouse("");
load(); load();
} }
} }
@@ -777,11 +840,11 @@ export default function PersonDetailPage() {
<CardTitle className="text-base">Life events</CardTitle> <CardTitle className="text-base">Life events</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{events.length === 0 ? ( {shownEvents.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p> <p className="text-sm text-[var(--muted)]">No events yet.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{events.map((ev) => {shownEvents.map((ev) =>
editId === ev.id ? ( editId === ev.id ? (
<li key={ev.id}> <li key={ev.id}>
<form <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"> <li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span> <span>
<span className="font-medium capitalize">{ev.event_type}</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 ? ( {ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span> <span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null} ) : null}
{ev.detail ? (
<span className="text-[var(--muted)]"> {ev.detail}</span>
) : null}
</span> </span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
@@ -901,6 +973,23 @@ export default function PersonDetailPage() {
/> />
</label> </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"> <label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span> <span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}> <select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>