Cleanup: list people with no sex set + inline set

Adds a "People with no sex set" section to the Cleanup page — lists everyone
whose gender is still null with inline ♂ Male / ♀ Female buttons (and a link to
their page). Refreshes after the source-match and first-name guess passes, so
it's the manual mop-up for whatever those leave behind.

Frontend only (reuses person list + PATCH) — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:43:08 -04:00
parent 6ec852a23a
commit a53858f920
+69 -1
View File
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"];
const ISSUE_LABEL: Record<string, string> = {
date_in_surname: "date in surname",
@@ -36,6 +37,9 @@ export default function CleanupPage() {
const [genMsg, setGenMsg] = useState<string | null>(null);
const genFile = useRef<HTMLInputElement>(null);
// People still missing a sex (manual mop-up)
const [unset, setUnset] = useState<Person[] | null>(null);
// 3) Name issues
const [issues, setIssues] = useState<NameIssue[] | null>(null);
const [edits, setEdits] = useState<Record<string, { given: string; surname: string; on: boolean }>>({});
@@ -77,6 +81,25 @@ export default function CleanupPage() {
setGenSel(new Set(data.map((g) => g.person_id)));
}
}
const loadUnset = useCallback(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
setUnset(
(data ?? [])
.filter((p) => !p.gender)
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")),
);
}, [treeId]);
async function setSex(personId: string, gender: "male" | "female") {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { gender },
});
setUnset((prev) => (prev ? prev.filter((p) => p.id !== personId) : prev));
}
async function guessGender() {
setGenMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/guess", {
@@ -96,6 +119,7 @@ export default function CleanupPage() {
});
setGenMsg(`Set gender on ${data?.updated ?? 0} people.`);
setGender(null);
loadUnset();
}
const loadNames = useCallback(async () => {
@@ -113,7 +137,8 @@ export default function CleanupPage() {
useEffect(() => {
loadNames();
}, [loadNames]);
loadUnset();
}, [loadNames, loadUnset]);
async function applyNames() {
const chosen = (issues ?? []).filter((i) => edits[i.name_id]?.on);
@@ -261,6 +286,49 @@ export default function CleanupPage() {
</CardContent>
</Card>
{/* People still missing a sex */}
<Card>
<CardHeader>
<CardTitle className="text-base">
People with no sex set{unset ? ` (${unset.length})` : ""}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{unset === null ? (
<p className="text-sm text-[var(--muted)]">Loading</p>
) : unset.length === 0 ? (
<p className="text-sm text-[var(--muted)]">Everyone has a sex set. 🎉</p>
) : (
<ul className="max-h-80 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{unset.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<a
href={`/trees/${treeId}/persons/${p.id}`}
className="flex-1 truncate hover:underline"
>
{p.primary_name ?? "Unnamed"}
</a>
<button
onClick={() => setSex(p.id, "male")}
className="rounded px-2 py-0.5 text-xs"
style={{ color: "rgb(120,159,172)", border: "1px solid rgb(120,159,172)" }}
>
Male
</button>
<button
onClick={() => setSex(p.id, "female")}
className="rounded px-2 py-0.5 text-xs"
style={{ color: "rgb(196,138,146)", border: "1px solid rgb(196,138,146)" }}
>
Female
</button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* 3) Name issues */}
<Card>
<CardHeader>