diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index 6f568a7..41e87e3 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -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) => diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index 52064a8..242cccf 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -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 = { 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([]); // 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() { Life events - {events.length === 0 ? ( + {shownEvents.length === 0 ? (

No events yet.

) : (
    - {events.map((ev) => + {shownEvents.map((ev) => editId === ev.id ? (
  • {ev.event_type} + {ev.relationship_id ? ( + + {" "} + · with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")} + + ) : null} {ev.date_value ? ( — {ev.date_value} ) : null} + {ev.detail ? ( + — {ev.detail} + ) : null} {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} @@ -901,6 +973,23 @@ export default function PersonDetailPage() { /> )} + {isPartnershipType(evType) && ( + + )}