Cleanup tool: "mark deceased by a child's birth year" rule

Adds a preview/apply rule to the Cleanup tool for parents who have NO birth date
of their own (so the existing born-on-or-before rule can't reach them) but who
have a child born long ago — they're necessarily deceased. This is the gap that
left ~56 parents in the Paul tree as "unknown".

- cleanup_service.preview_deceased_by_child(year): parents of any child born
  on/before the cutoff, excluding already-deceased; returns child_birth_year.
- GET /trees/{id}/cleanup/deceased-by-child?born_on_or_before=1900. Apply reuses
  the existing POST .../cleanup/deceased (same audited mark-deceased path).
- Frontend: a new card in the Cleanup tool (year input → preview → select →
  apply), preview-first like the rest of the tool.

Test covers preview (finds the no-birthdate parent of a pre-cutoff child,
excludes modern-child parents), child_birth_year, apply, and re-preview drop.
Suite 106 passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-11 11:08:50 -04:00
parent e24a7cfcc9
commit 1340d1957f
7 changed files with 342 additions and 0 deletions
+77
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type DeceasedByChild = components["schemas"]["DeceasedByChildCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"];
@@ -31,6 +32,12 @@ export default function CleanupPage() {
const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null);
// 1b) Deceased by a child's birth year (for parents with no birth date)
const [childYear, setChildYear] = useState(1900);
const [decByChild, setDecByChild] = useState<DeceasedByChild[] | null>(null);
const [dbcSel, setDbcSel] = useState<Set<string>>(new Set());
const [dbcMsg, setDbcMsg] = useState<string | null>(null);
// 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set());
@@ -63,6 +70,23 @@ export default function CleanupPage() {
setDeceased(null);
}
async function previewDeceasedByChild() {
setDbcMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased-by-child", {
params: { path: { tree_id: treeId }, query: { born_on_or_before: childYear } },
});
setDecByChild(data ?? []);
setDbcSel(new Set((data ?? []).map((d) => d.person_id)));
}
async function applyDeceasedByChild() {
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId } },
body: { person_ids: [...dbcSel] },
});
setDbcMsg(`Marked ${data?.updated ?? 0} people deceased.`);
setDecByChild(null);
}
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = "";
@@ -231,6 +255,59 @@ export default function CleanupPage() {
</CardContent>
</Card>
{/* 1b) Deceased by a child's birth year */}
<Card>
<CardHeader>
<CardTitle className="text-base">Mark deceased by a childs birth year</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Catches parents who have <strong>no birth date of their own</strong> (so the rule
above cant reach them) but who have a child born long ago theyre necessarily
deceased.
</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-[var(--muted)]">Has a child born on or before</span>
<Input
type="number"
className="w-28"
value={childYear}
onChange={(e) => setChildYear(Number(e.target.value))}
/>
</label>
<Button variant="outline" onClick={previewDeceasedByChild}>
Preview
</Button>
</div>
{dbcMsg && <p className="text-sm text-bronze">{dbcMsg}</p>}
{decByChild && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{decByChild.length} people with a child born {childYear} (not already marked
deceased).
</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{decByChild.map((d) => (
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={dbcSel.has(d.person_id)}
onChange={() => toggle(dbcSel, d.person_id, setDbcSel)}
/>
<span className="flex-1">{d.name}</span>
<span className="text-xs text-[var(--muted)]">child b. {d.child_birth_year}</span>
</li>
))}
</ul>
{decByChild.length > 0 && (
<Button onClick={applyDeceasedByChild}>Mark {dbcSel.size} deceased</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 2) Gender from source */}
<Card>
<CardHeader>