Merge pull request 'Shared marriage events; deterministic parent ordering' (#25) from marriage-event-parent-order into main
build-frontend / build (push) Successful in 1m23s
build-frontend / build (push) Successful in 1m23s
This commit was merged in pull request #25.
This commit is contained in:
@@ -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)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user