6ec852a23a
A "Guess from first name" option in the Cleanup gender section: a bundled, curated given-name -> sex dictionary (weighted English + German for the first real tree) proposes sex for people who don't have it set. Deterministic, offline, no model. Genuinely ambiguous names (Marion, Frances, Jordan, …) are excluded from both sets so they're left for a human. Reuses the existing preview/apply gender flow, so every guess is reviewed before saving. No migration. 56 backend tests pass; frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
"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 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);
|
||
}
|
||
|
||
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 person’s sex). We match by
|
||
name and propose sex only for people who don’t 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>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|