12 Commits

Author SHA1 Message Date
justin 7043532c3b Merge pull request 'Cleanup tool: mark deceased by a child's birth year' (#254) from cleanup-deceased-by-child into main
build-backend / build (push) Successful in 29s
build-frontend / build (push) Successful in 1m29s
2026-06-11 11:08:52 -04:00
justin 1340d1957f 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>
2026-06-11 11:08:50 -04:00
justin e24a7cfcc9 Merge pull request 'Tree cards: living/unset-sex people render gray, not blue' (#253) from living-and-unset-cards-gray into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 10:37:27 -04:00
justin 07944e329e Tree cards: render unset-sex / redacted "Living person" in gray, not blue
The chart mapped gender as `=== "female" ? "F" : "M"`, so anything non-female —
including null — became "M" (blue). On the public site, redacted living people
(whose gender the privacy engine nulls) all showed blue regardless of real sex,
and anywhere a person's sex was simply unset they also showed blue (misleading).

Map male→"M", female→"F", and everything else→null, which family-chart renders
as `card-genderless`. So living/redacted people render gray (and never imply a
sex), and unset-sex people render gray instead of defaulting to male/blue.
Applied to both the member tree (tree/page.tsx) and the public chart
(public-tree-chart.tsx), which share chart.css. Also bumped the genderless color
from the library's washed-out `lightgray` to a warm mid-gray that matches the
muted male/female tones and the brand palette.

Privacy note: `_redact` already nulls gender, so this is purely the client color
mapping — no sex leak, just a correct neutral rendering.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 10:37:25 -04:00
justin a33a88e558 Merge pull request 'docs: note the spouse-layout fix is upstreamed' (#252) from docs-upstream-spouse-fix into main 2026-06-11 09:33:21 -04:00
justin fe8349819f docs: note the spouse-layout fix is upstreamed (donatso/family-chart#105)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 09:33:19 -04:00
justin e745fb5d4d Merge pull request 'Move cardToMiddle fix into the family-chart patch (+ document patches)' (#251) from family-chart-patch-cardtomiddle into main
build-frontend / build (push) Successful in 1m27s
2026-06-11 09:21:32 -04:00
justin e0573e6be2 Move cardToMiddle vertical-centering fix into the family-chart patch
Fold the fly-to vertical-centering fix into our patch-package patch (alongside
the existing spouse-layout fix) instead of compensating in app code, and revert
the in-app workaround so the two don't double-correct.

- patches/family-chart+0.9.0.patch: cardToMiddle now scales datum.y by the zoom
  k in both dist builds (.js + .esm.js), matching datum.x. Verified the patch
  applies cleanly (patch-package --error-on-fail).
- tree/page.tsx: the cardToMiddle caller passes raw y again (the patched library
  does the scaling now); pre-scaling here too would double-correct. Behavior is
  identical to the previous in-app fix — both center the node exactly.
- CLAUDE.md: documents the two family-chart patches, how to regenerate them, and
  that both should be upstreamed. The cardToMiddle fix is submitted upstream
  (donatso/family-chart#103, issue #102); the spouse-layout fix is a TODO.

The frontend Dockerfile already COPYs patches/ before npm ci, so the fix is in
the production build.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 09:21:30 -04:00
justin 3731d77d4b Merge pull request 'Fix fly-to vertical centering at non-1 zoom levels' (#250) from fix-fly-to-vertical-centering into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 08:58:38 -04:00
justin bf1576252b Fix fly-to vertical centering at non-1 zoom levels
Clicking ×N sometimes flew to a blank area far below the tree. Cause:
family-chart's cardToMiddle scales datum.x by the zoom factor k but not datum.y
(`y = height/2 - datum.y`, missing the ·k), so vertical centering is only
correct at k=1 and drifts by datum.y·(k−1) at any other zoom — worse the deeper
the person sits. That's why it worked only when the view happened to be near 1:1.

Compensate by pre-multiplying the y we pass to cardToMiddle by the current
scale, cancelling the library's missing ·k. x was already correct.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:58:36 -04:00
justin 0ed6ba4505 Merge pull request 'Tree: clicking ×N flies to the person's other copy' (#249) from tree-fly-to-duplicate into main
build-frontend / build (push) Successful in 1m31s
2026-06-11 08:47:59 -04:00
justin ed263cf9a7 Tree: clicking ×N flies to the person's other copy (not just flashes)
On a large tree the duplicate's other copy is usually off-screen, so flashing
in place wasn't enough. Clicking the ×N badge now pans/zooms the view to center
the other copy and flashes it on arrival; clicking again cycles through the
remaining copies (for a person drawn 3+ times).

Uses family-chart's exported handlers: cardToMiddle centers a datum (read from
the target card_cont's bound x/y, falling back to its transform attr), keeping
the current zoom level via getCurrentZoom. Verified against the lib: the svg's
parent (f3Canvas) holds the zoom object, and cards are positioned by datum x/y —
same coordinate space cardToMiddle expects. Falls back to an in-place flash if
the zoom object isn't ready. Frontend only; supersedes the flash-only behavior.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:47:44 -04:00
12 changed files with 463 additions and 22 deletions
+17
View File
@@ -77,6 +77,23 @@ Don't get ahead of the phases. GEDCOM and the assistant's propose-diff foundatio
- **Privacy/assistant/hint code gets extra care** — these are the areas where bugs do real harm. Prefer a design note before a large change. - **Privacy/assistant/hint code gets extra care** — these are the areas where bugs do real harm. Prefer a design note before a large change.
- **No secrets in the repo.** Config via env; provide `.env.example` with placeholders. - **No secrets in the repo.** Config via env; provide `.env.example` with placeholders.
## Patched dependencies (family-chart)
The tree view uses **family-chart** (d3-based). Two adjustments live in the repo:
- **CSS is vendored** at `frontend/app/trees/[id]/tree/chart.css` — the package blocks its CSS subpath export, so we copy it in.
- **The library is patched** via `patch-package` (`frontend/patches/family-chart+0.9.0.patch`, applied by the `postinstall` hook; the backend/frontend Dockerfiles `COPY patches` before install). Both hunks touch `dist/family-chart.js` **and** `dist/family-chart.esm.js` (the app loads the `esm` build). Current fixes:
1. **Spouse-centering layout** (`setupSpouses` / `sortChildrenWithSpouses`) — center a person between two spouses with children under the correct pair.
2. **`cardToMiddle` vertical centering** — the lib scaled `datum.x` by the zoom factor `k` but not `datum.y`, so "fly to a node" drifted vertically at any zoom ≠ 1; we add the missing `* k`.
To change a patch: edit the file(s) under `node_modules/family-chart/dist/`, then `cd frontend && npx patch-package family-chart` to regenerate, and verify with `npx patch-package --error-on-fail`.
**Upstreamed.** Both are general library bugfixes, not app-specific, and are submitted upstream:
- `cardToMiddle` vertical centering — **donatso/family-chart#103** (issue **#102**).
- Multi-spouse centered layout — **donatso/family-chart#105** (issue **#104**).
If either is merged + released, bump `family-chart`, drop the corresponding patch hunk, **and** remove any in-app compensation (e.g. the `cardToMiddle` caller in `tree/page.tsx` passes raw `y` precisely because the patch fixes it — pre-scaling there too would double-correct). Until then, keep the patch.
## License & contribution terms ## License & contribution terms
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution. Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
+19
View File
@@ -6,6 +6,7 @@ from app.api.deps import CurrentUser, SessionDep
from app.schemas.cleanup import ( from app.schemas.cleanup import (
CleanupResult, CleanupResult,
DeceasedApply, DeceasedApply,
DeceasedByChildCandidate,
DeceasedCandidate, DeceasedCandidate,
GenderApply, GenderApply,
GenderProposal, GenderProposal,
@@ -31,6 +32,24 @@ async def preview_deceased(
return [DeceasedCandidate(**r) for r in rows] return [DeceasedCandidate(**r) for r in rows]
@router.get(
"/{tree_id}/cleanup/deceased-by-child", response_model=list[DeceasedByChildCandidate]
)
async def preview_deceased_by_child(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
born_on_or_before: int = 1900,
) -> list[DeceasedByChildCandidate]:
"""People with a child born on/before the cutoff — necessarily deceased even
when their own birth date is missing. Apply via POST .../cleanup/deceased."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_deceased_by_child(
session, actor=current, tree=tree, year=born_on_or_before
)
return [DeceasedByChildCandidate(**r) for r in rows]
@router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult) @router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult)
async def apply_deceased( async def apply_deceased(
tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser
+6
View File
@@ -9,6 +9,12 @@ class DeceasedCandidate(BaseModel):
birth_year: int birth_year: int
class DeceasedByChildCandidate(BaseModel):
person_id: uuid.UUID
name: str
child_birth_year: int
class DeceasedApply(BaseModel): class DeceasedApply(BaseModel):
person_ids: list[uuid.UUID] person_ids: list[uuid.UUID]
+45
View File
@@ -133,6 +133,51 @@ async def apply_deceased(
return len(persons) return len(persons)
# ---- 1b. Mark deceased by a CHILD's birth year -------------------------------------
# For parents whose own birth date is missing (so the birth-year rule can't reach
# them) but who have a child born long ago — they're necessarily deceased. Applies
# through the same apply_deceased() path.
async def preview_deceased_by_child(
session: AsyncSession, *, actor: User, tree: Tree, year: int
) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
names = await _primary_name_by_person(session, tree.id)
years = await _birth_year_by_person(session, tree.id)
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
)
)
).scalars().all()
# parent id -> earliest child birth year, among children born on/before `year`.
earliest_child: dict[uuid.UUID, int] = {}
for r in rels:
cy = years.get(r.person_to_id) # the child's birth year
if cy is None or cy > year:
continue
if r.person_from_id not in earliest_child or cy < earliest_child[r.person_from_id]:
earliest_child[r.person_from_id] = cy
persons = {p.id: p for p in await _persons(session, tree.id)}
out: list[dict] = []
for parent_id, cy in earliest_child.items():
p = persons.get(parent_id)
if p is None or p.is_living is False: # gone or already deceased
continue
out.append(
{
"person_id": str(parent_id),
"name": _display(names.get(parent_id)),
"child_birth_year": cy,
}
)
out.sort(key=lambda r: r["child_birth_year"])
return out
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ---------------------- # ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
async def preview_gender( async def preview_gender(
+47
View File
@@ -51,6 +51,53 @@ async def test_deceased_preview_and_apply(client):
assert old not in [r["person_id"] for r in prev2] assert old not in [r["person_id"] for r in prev2]
async def test_deceased_by_child_preview_and_apply(client):
h, tid = await _tree(client, "cl-decchild@example.com")
# Parent with NO birth date (the gap the birth-year rule can't reach).
parent = await _person(client, h, tid, "Gesche", "Frerking")
child = await _person(client, h, tid, "Kindt", "Frerking")
await _birth(client, h, tid, child, 1880) # child born before the cutoff
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
# A parent of a modern child must NOT be flagged.
p_modern = await _person(client, h, tid, "Modern", "Parent")
c_modern = await _person(client, h, tid, "Kid", "Parent")
await _birth(client, h, tid, c_modern, 1990)
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": p_modern, "person_to_id": c_modern},
headers=h,
)
prev = (
await client.get(
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
)
).json()
ids = [r["person_id"] for r in prev]
assert parent in ids and p_modern not in ids
assert next(r for r in prev if r["person_id"] == parent)["child_birth_year"] == 1880
# Apply through the shared deceased endpoint.
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [parent]}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{parent}", headers=h)
).json()["is_living"] is False
# Re-preview drops the now-deceased parent.
prev2 = (
await client.get(
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
)
).json()
assert parent not in [r["person_id"] for r in prev2]
async def test_gender_from_spouse_preview_and_apply(client): async def test_gender_from_spouse_preview_and_apply(client):
h, tid = await _tree(client, "cl-spouse@example.com") h, tid = await _tree(client, "cl-spouse@example.com")
husband = ( husband = (
+77
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"]; type Deceased = components["schemas"]["DeceasedCandidate"];
type DeceasedByChild = components["schemas"]["DeceasedByChildCandidate"];
type GenderProp = components["schemas"]["GenderProposal"]; type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"]; type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
@@ -31,6 +32,12 @@ export default function CleanupPage() {
const [decSel, setDecSel] = useState<Set<string>>(new Set()); const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null); 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 // 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null); const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set()); const [genSel, setGenSel] = useState<Set<string>>(new Set());
@@ -63,6 +70,23 @@ export default function CleanupPage() {
setDeceased(null); 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>) { async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = ""; if (genFile.current) genFile.current.value = "";
@@ -231,6 +255,59 @@ export default function CleanupPage() {
</CardContent> </CardContent>
</Card> </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 */} {/* 2) Gender from source */}
<Card> <Card>
<CardHeader> <CardHeader>
+4 -1
View File
@@ -1,7 +1,10 @@
.f3 { .f3 {
--female-color: rgb(196, 138, 146); --female-color: rgb(196, 138, 146);
--male-color: rgb(120, 159, 172); --male-color: rgb(120, 159, 172);
--genderless-color: lightgray; /* Warm mid-gray for unset-sex / redacted "Living person" cards — matches the
muted male/female tone weight and the brand palette, instead of the library's
washed-out lightgray. */
--genderless-color: rgb(156, 150, 143);
--background-color: rgb(33, 33, 33); --background-color: rgb(33, 33, 33);
--text-color: #fff; --text-color: #fff;
+76 -18
View File
@@ -36,6 +36,12 @@ export default function TreePage() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null); const chartRef = useRef<any>(null);
// family-chart's pan/zoom helpers (cardToMiddle, getCurrentZoom), captured at
// render — used to fly to a duplicate's other copy.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlersRef = useRef<any>(null);
// Per-person cursor so repeated clicks on a ×N badge cycle through the copies.
const dupCycle = useRef<Map<string, number>>(new Map());
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
@@ -179,7 +185,9 @@ export default function TreePage() {
"first name": fn || "Unnamed", "first name": fn || "Unnamed",
"last name": ln, "last name": ln,
birthday: years.get(pp.id) ?? "", birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M", // male → blue, female → pink, unset → genderless (gray). Unset sex no
// longer defaults to male/blue (which was misleading).
gender: pp.gender === "male" ? "M" : pp.gender === "female" ? "F" : null,
}, },
rels: { rels: {
spouses: ok(partnersOf(pp.id), pp.id), spouses: ok(partnersOf(pp.id), pp.id),
@@ -189,6 +197,7 @@ export default function TreePage() {
}; };
}); });
const f3 = await import("family-chart"); const f3 = await import("family-chart");
handlersRef.current = f3.handlers;
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
try { try {
containerRef.current.innerHTML = ""; containerRef.current.innerHTML = "";
@@ -252,31 +261,80 @@ export default function TreePage() {
[mode], [mode],
); );
// Click the "×N" duplicate badge on a card to flash every copy of that person // Click the "×N" duplicate badge to FLY to the person's other copy in the
// in the current view. They're the same record drawn in two places (a shared // view (cycling through them on repeat clicks) and flash it on arrival. The
// ancestor, or an intermarriage). Delegated on the container so it survives // same record is drawn in two places (a shared ancestor, or an intermarriage),
// chart rebuilds; capture-phase + stopPropagation so it doesn't also recenter. // and on a big tree the other copy is usually off-screen. Delegated on the
// container so it survives chart rebuilds; capture-phase + stopPropagation so a
// badge click flies instead of recentering.
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
type Bound = Element & { __data__?: { data?: { id?: string } } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any
const idOf = (node: Element | null) => (node as Bound | null)?.__data__?.data?.id; const data = (n: Element | null) => (n as any)?.__data__;
const idOf = (n: Element | null) => data(n)?.data?.id as string | undefined;
const xyOf = (cont: Element): { x: number; y: number } | null => {
const d = data(cont);
if (d && typeof d.x === "number" && typeof d.y === "number") return { x: d.x, y: d.y };
const m = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)/.exec(cont.getAttribute("transform") ?? "");
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
};
const flash = (cont: Element | null) => {
const card = cont?.querySelector(".card") as HTMLElement | null;
if (!card) return;
card.classList.remove("f3-card-flash");
void card.offsetWidth; // restart the animation on repeat clicks
card.classList.add("f3-card-flash");
window.setTimeout(() => card.classList.remove("f3-card-flash"), 1900);
};
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag"); const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag");
if (!tag) return; if (!tag) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const id = idOf(tag.closest(".card_cont")); const clicked = tag.closest(".card_cont");
const id = idOf(clicked);
if (!id) return; if (!id) return;
el!.querySelectorAll(".card_cont").forEach((cont) => { const copies = Array.from(el!.querySelectorAll(".card_cont")).filter((c) => idOf(c) === id);
if (idOf(cont) !== id) return; if (copies.length < 2) {
const card = cont.querySelector(".card") as HTMLElement | null; flash(clicked);
if (!card) return; return;
card.classList.remove("f3-card-flash"); }
void card.offsetWidth; // restart the animation on repeat clicks // Advance from wherever we last landed (or the clicked card), skipping the
card.classList.add("f3-card-flash"); // clicked copy, so each click moves to the next other location.
window.setTimeout(() => card.classList.remove("f3-card-flash"), 1900); const start = dupCycle.current.get(id) ?? copies.indexOf(clicked as Element);
}); let next = (start + 1) % copies.length;
if (copies[next] === clicked) next = (next + 1) % copies.length;
dupCycle.current.set(id, next);
const target = copies[next];
const handlers = handlersRef.current;
const svg = el!.querySelector("svg.main_svg") as SVGSVGElement | null;
const xy = xyOf(target);
let flew = false;
if (handlers?.cardToMiddle && svg && xy) {
try {
const rect = svg.getBoundingClientRect();
const scale = handlers.getCurrentZoom ? handlers.getCurrentZoom(svg).k : 1;
// cardToMiddle centers the datum at the current zoom. (Its vertical
// centering at non-1 zoom is fixed in our family-chart patch — see
// CLAUDE.md / upstream PR donatso/family-chart#103 — so we pass the
// raw y; do NOT pre-scale it here or it double-corrects.)
handlers.cardToMiddle({
datum: xy,
svg,
svg_dim: { width: rect.width, height: rect.height },
scale,
transition_time: 750,
});
flew = true;
} catch {
/* zoom not ready — fall back to flashing in place */
}
}
// Flash on arrival (after the fly), or immediately if we couldn't fly.
window.setTimeout(() => flash(target), flew ? 900 : 0);
} }
el.addEventListener("click", onClick, true); el.addEventListener("click", onClick, true);
return () => el.removeEventListener("click", onClick, true); return () => el.removeEventListener("click", onClick, true);
@@ -453,7 +511,7 @@ export default function TreePage() {
person appears N times in the current view. The same record is drawn in person appears N times in the current view. The same record is drawn in
two places because they connect through more than one line (a shared two places because they connect through more than one line (a shared
ancestor, or an intermarriage).{" "} ancestor, or an intermarriage).{" "}
<span className="text-[var(--muted)]">Click the ×N to flash the other copies.</span> <span className="text-[var(--muted)]">Click the ×N to fly to the other copies (click again to cycle).</span>
</li> </li>
<li className="flex flex-wrap items-center gap-x-2 gap-y-1"> <li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
+4 -1
View File
@@ -120,7 +120,10 @@ export function PublicTreeChart({
"first name": fn || "Unnamed", "first name": fn || "Unnamed",
"last name": ln, "last name": ln,
birthday: years.get(pp.id) ?? "", birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M", // male → blue, female → pink, unset/redacted → genderless (gray).
// Redacted living people have null gender, so they render gray rather
// than defaulting to male/blue (and never imply a real person's sex).
gender: pp.gender === "male" ? "M" : pp.gender === "female" ? "F" : null,
}, },
rels: { rels: {
spouses: ok(partnersOf(pp.id), pp.id), spouses: ok(partnersOf(pp.id), pp.id),
+66
View File
@@ -718,6 +718,27 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/cleanup/deceased-by-child": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Preview Deceased By Child
* @description People with a child born on/before the cutoff — necessarily deceased even
* when their own birth date is missing. Apply via POST .../cleanup/deceased.
*/
get: operations["preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/cleanup/gender/preview": { "/api/v1/trees/{tree_id}/cleanup/gender/preview": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1286,6 +1307,18 @@ export interface components {
/** Person Ids */ /** Person Ids */
person_ids: string[]; person_ids: string[];
}; };
/** DeceasedByChildCandidate */
DeceasedByChildCandidate: {
/**
* Person Id
* Format: uuid
*/
person_id: string;
/** Name */
name: string;
/** Child Birth Year */
child_birth_year: number;
};
/** DeceasedCandidate */ /** DeceasedCandidate */
DeceasedCandidate: { DeceasedCandidate: {
/** /**
@@ -4013,6 +4046,39 @@ export interface operations {
}; };
}; };
}; };
preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get: {
parameters: {
query?: {
born_on_or_before?: number;
};
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeceasedByChildCandidate"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: { preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: {
parameters: { parameters: {
query?: never; query?: never;
+82
View File
@@ -2873,6 +2873,64 @@
} }
} }
}, },
"/api/v1/trees/{tree_id}/cleanup/deceased-by-child": {
"get": {
"tags": [
"cleanup"
],
"summary": "Preview Deceased By Child",
"description": "People with a child born on/before the cutoff \u2014 necessarily deceased even\nwhen their own birth date is missing. Apply via POST .../cleanup/deceased.",
"operationId": "preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "born_on_or_before",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1900,
"title": "Born On Or Before"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeceasedByChildCandidate"
},
"title": "Response Preview Deceased By Child Api V1 Trees Tree Id Cleanup Deceased By Child Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/cleanup/gender/preview": { "/api/v1/trees/{tree_id}/cleanup/gender/preview": {
"post": { "post": {
"tags": [ "tags": [
@@ -4897,6 +4955,30 @@
], ],
"title": "DeceasedApply" "title": "DeceasedApply"
}, },
"DeceasedByChildCandidate": {
"properties": {
"person_id": {
"type": "string",
"format": "uuid",
"title": "Person Id"
},
"name": {
"type": "string",
"title": "Name"
},
"child_birth_year": {
"type": "integer",
"title": "Child Birth Year"
}
},
"type": "object",
"required": [
"person_id",
"name",
"child_birth_year"
],
"title": "DeceasedByChildCandidate"
},
"DeceasedCandidate": { "DeceasedCandidate": {
"properties": { "properties": {
"person_id": { "person_id": {
+20 -2
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/family-chart/dist/family-chart.esm.js b/node_modules/family-chart/dist/family-chart.esm.js diff --git a/node_modules/family-chart/dist/family-chart.esm.js b/node_modules/family-chart/dist/family-chart.esm.js
index 3867be0..560c99e 100644 index 3867be0..656fafa 100644
--- a/node_modules/family-chart/dist/family-chart.esm.js --- a/node_modules/family-chart/dist/family-chart.esm.js
+++ b/node_modules/family-chart/dist/family-chart.esm.js +++ b/node_modules/family-chart/dist/family-chart.esm.js
@@ -10,10 +10,10 @@ function sortChildrenWithSpouses(children, datum, data) { @@ -10,10 +10,10 @@ function sortChildrenWithSpouses(children, datum, data) {
@@ -61,8 +61,17 @@ index 3867be0..560c99e 100644
if (!d.spouses) if (!d.spouses)
d.spouses = []; d.spouses = [];
d.spouses.push(spouse); d.spouses.push(spouse);
@@ -1073,7 +1091,7 @@ function calculateTreeFit(svg_dim, tree_dim) {
return { k, x, y };
}
function cardToMiddle({ datum, svg, svg_dim, scale, transition_time }) {
- const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y, t = { k, x: x / k, y: y / k };
+ const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y * k, t = { k, x: x / k, y: y / k };
positionTree({ t, svg, transition_time });
}
function manualZoom({ amount, svg, transition_time = 500 }) {
diff --git a/node_modules/family-chart/dist/family-chart.js b/node_modules/family-chart/dist/family-chart.js diff --git a/node_modules/family-chart/dist/family-chart.js b/node_modules/family-chart/dist/family-chart.js
index 1c750d4..47efcc2 100644 index 1c750d4..edeb804 100644
--- a/node_modules/family-chart/dist/family-chart.js --- a/node_modules/family-chart/dist/family-chart.js
+++ b/node_modules/family-chart/dist/family-chart.js +++ b/node_modules/family-chart/dist/family-chart.js
@@ -33,10 +33,9 @@ @@ -33,10 +33,9 @@
@@ -116,3 +125,12 @@ index 1c750d4..47efcc2 100644
if (!d.spouses) if (!d.spouses)
d.spouses = []; d.spouses = [];
d.spouses.push(spouse); d.spouses.push(spouse);
@@ -1096,7 +1106,7 @@
return { k, x, y };
}
function cardToMiddle({ datum, svg, svg_dim, scale, transition_time }) {
- const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y, t = { k, x: x / k, y: y / k };
+ const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y * k, t = { k, x: x / k, y: y / k };
positionTree({ t, svg, transition_time });
}
function manualZoom({ amount, svg, transition_time = 500 }) {