Tree Cleanup tool: bulk fixes with preview → approve

A new per-tree Cleanup page (and cleanup_service + endpoints), each fix
preview-first per the propose-then-approve rule:

- Mark deceased by birth year: lists people born ≤ a cutoff (default 1930) not
  already deceased; apply sets is_living=false for the ones you keep checked.
- Set sex from a source GEDCOM: upload the source .ged (it carries SEX); matches
  by name and proposes sex only where it's missing — far more accurate than
  guessing from first names. Review, then apply.
- Names that look broken: flags date-in-surname / date-in-given / no-surname /
  packed given names, with inline editable given+surname; fix the checked ones.

No migration (uses existing columns). 55 backend tests pass (preview+apply for
all three); frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:17:01 -04:00
parent 97f7a9e0ff
commit aa62ca490e
9 changed files with 1737 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
const ISSUE_LABEL: Record<string, string> = {
date_in_surname: "date in surname",
date_in_given: "date in given name",
no_surname: "no surname",
packed_given: "long given name",
};
export default function CleanupPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
// 1) Deceased by birth year
const [year, setYear] = useState(1930);
const [deceased, setDeceased] = useState<Deceased[] | null>(null);
const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null);
// 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set());
const [genMsg, setGenMsg] = useState<string | null>(null);
const genFile = useRef<HTMLInputElement>(null);
// 3) Name issues
const [issues, setIssues] = useState<NameIssue[] | null>(null);
const [edits, setEdits] = useState<Record<string, { given: string; surname: string; on: boolean }>>({});
const [nameMsg, setNameMsg] = useState<string | null>(null);
async function previewDeceased() {
setDecMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId }, query: { born_on_or_before: year } },
});
setDeceased(data ?? []);
setDecSel(new Set((data ?? []).map((d) => d.person_id)));
}
async function applyDeceased() {
const ids = [...decSel];
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId } },
body: { person_ids: ids },
});
setDecMsg(`Marked ${data?.updated ?? 0} people deceased.`);
setDeceased(null);
}
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = "";
if (!file) return;
setGenMsg(null);
const fd = new FormData();
fd.append("file", file);
const resp = await fetch(`/api/v1/trees/${treeId}/cleanup/gender/preview`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
const data: GenderProp[] = await resp.json();
setGender(data);
setGenSel(new Set(data.map((g) => g.person_id)));
}
}
async function applyGender() {
const updates = (gender ?? [])
.filter((g) => genSel.has(g.person_id))
.map((g) => ({ person_id: g.person_id, gender: g.proposed_gender }));
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/gender", {
params: { path: { tree_id: treeId } },
body: { updates },
});
setGenMsg(`Set gender on ${data?.updated ?? 0} people.`);
setGender(null);
}
const loadNames = useCallback(async () => {
setNameMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/names", {
params: { path: { tree_id: treeId } },
});
setIssues(data ?? []);
const init: Record<string, { given: string; surname: string; on: boolean }> = {};
for (const i of data ?? []) {
init[i.name_id] = { given: i.given ?? "", surname: i.surname ?? "", on: false };
}
setEdits(init);
}, [treeId]);
useEffect(() => {
loadNames();
}, [loadNames]);
async function applyNames() {
const chosen = (issues ?? []).filter((i) => edits[i.name_id]?.on);
const body = {
edits: chosen.map((i) => ({
name_id: i.name_id,
given: edits[i.name_id].given,
surname: edits[i.name_id].surname,
})),
};
if (!body.edits.length) return;
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/names", {
params: { path: { tree_id: treeId } },
body,
});
setNameMsg(`Fixed ${data?.updated ?? 0} names.`);
loadNames();
}
const toggle = (set: Set<string>, id: string, setter: (s: Set<string>) => void) => {
const n = new Set(set);
if (n.has(id)) n.delete(id);
else n.add(id);
setter(n);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Cleanup</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Fix common import messes in bulk. Each tool previews its changes nothing is saved
until you apply.
</p>
</div>
{/* 1) Deceased by year */}
<Card>
<CardHeader>
<CardTitle className="text-base">Mark deceased by birth year</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<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)]">Born on or before</span>
<Input
type="number"
className="w-28"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
/>
</label>
<Button variant="outline" onClick={previewDeceased}>
Preview
</Button>
</div>
{decMsg && <p className="text-sm text-bronze">{decMsg}</p>}
{deceased && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{deceased.length} people born {year} (not already marked deceased).
</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{deceased.map((d) => (
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={decSel.has(d.person_id)}
onChange={() => toggle(decSel, d.person_id, setDecSel)}
/>
<span className="flex-1">{d.name}</span>
<span className="text-xs text-[var(--muted)]">b. {d.birth_year}</span>
</li>
))}
</ul>
{deceased.length > 0 && (
<Button onClick={applyDeceased}>Mark {decSel.size} deceased</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 2) Gender from source */}
<Card>
<CardHeader>
<CardTitle className="text-base">Set sex from a source GEDCOM</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Upload your source <code>.ged</code> (it carries each persons sex). We match by
name and propose sex only for people who dont have it set.
</p>
<input
ref={genFile}
type="file"
accept=".ged,.gedcom,text/plain"
onChange={previewGender}
className="hidden"
/>
<Button variant="outline" onClick={() => genFile.current?.click()}>
Choose source GEDCOM
</Button>
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
{gender && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">{gender.length} matches with a sex to set.</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{gender.map((g) => (
<li key={g.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={genSel.has(g.person_id)}
onChange={() => toggle(genSel, g.person_id, setGenSel)}
/>
<span className="flex-1">{g.name}</span>
<span
className="text-xs"
style={{
color:
g.proposed_gender === "male"
? "rgb(120,159,172)"
: "rgb(196,138,146)",
}}
>
{g.proposed_gender}
</span>
</li>
))}
</ul>
{gender.length > 0 && (
<Button onClick={applyGender}>Set sex on {genSel.size} people</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 3) Name issues */}
<Card>
<CardHeader>
<CardTitle className="text-base">Names that look broken</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{nameMsg && <p className="text-sm text-bronze">{nameMsg}</p>}
{issues === null ? (
<p className="text-sm text-[var(--muted)]">Scanning</p>
) : issues.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No obvious name problems found.</p>
) : (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{issues.length} flagged. Edit given/surname, tick the ones to fix, then apply.
</p>
<ul className="space-y-2">
{issues.map((i) => {
const e = edits[i.name_id] ?? { given: "", surname: "", on: false };
return (
<li key={i.name_id} className="flex flex-wrap items-center gap-2 text-sm">
<input
type="checkbox"
checked={e.on}
onChange={() =>
setEdits((p) => ({ ...p, [i.name_id]: { ...e, on: !e.on } }))
}
/>
<Input
className="h-9 w-40"
placeholder="Given"
value={e.given}
onChange={(ev) =>
setEdits((p) => ({ ...p, [i.name_id]: { ...e, given: ev.target.value } }))
}
/>
<Input
className="h-9 w-40"
placeholder="Surname"
value={e.surname}
onChange={(ev) =>
setEdits((p) => ({
...p,
[i.name_id]: { ...e, surname: ev.target.value },
}))
}
/>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{ISSUE_LABEL[i.issue] ?? i.issue}
</span>
</li>
);
})}
</ul>
<Button onClick={applyNames}>Fix selected</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}