Shared marriage events; deterministic parent ordering #25
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user