Files
provenance/frontend/app/trees/[id]/cleanup/page.tsx
T
justin a53858f920 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>
2026-06-08 10:43:08 -04:00

394 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"];
type Person = components["schemas"]["PersonRead"];
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);
// 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 }>>({});
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)));
}
}
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", {
params: { path: { tree_id: treeId } },
});
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);
loadUnset();
}
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();
loadUnset();
}, [loadNames, loadUnset]);
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"
/>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => genFile.current?.click()}>
Choose source GEDCOM
</Button>
<Button variant="outline" onClick={guessGender}>
Guess from first name
</Button>
</div>
<p className="text-xs text-[var(--muted)]">
Guess from first name uses a built-in name dictionary for people with no sex set;
ambiguous names (Marion, Frances, ) are left for you to decide.
</p>
{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>
{/* 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>
<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>
);
}