18 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
justin f7666ad30b Merge pull request 'Tree: Legend by the pan/zoom hint + clickable ×N duplicate badges' (#248) from tree-legend-and-duplicate-flash into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 08:32:53 -04:00
justin 690a6da659 Tree: a Legend by the pan/zoom hint, and clickable ×N duplicate badges
Two small tree-view aids prompted by "why do some people show ×2".

- Legend: a hover/focus "Legend" link next to the "drag to pan…" hint, explaining
  the ×N badge (a person drawn N times in the view because they connect through
  more than one line — a shared ancestor or an intermarriage), the gender card
  colors, and the pan/zoom/recenter controls.
- The ×N badge is now clearly clickable (cursor + hover state); clicking it
  flashes every copy of that person in the current view (a bronze outline pulse),
  so you can spot where else they appear. Implemented by delegating on the chart
  container and matching the d3-bound person id across cards; capture-phase +
  stopPropagation so a badge click flashes instead of recentering.

Frontend only. Honest follow-up: flashing finds copies that are on-screen; a true
"fly to an off-screen copy" needs d3-zoom transform work (the chart pans by
transform, not scroll) — a later enhancement.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:32:35 -04:00
justin e7115023e1 Merge pull request 'Person page: server-side search; stop loading the whole tree' (#247) from person-page-server-search into main
build-backend / build (push) Successful in 38s
build-frontend / build (push) Successful in 1m30s
2026-06-11 08:29:32 -04:00
justin 58400ffdf7 Person page: server-side search; stop loading the whole tree
The person page fetched the entire tree on every open — all persons (to build a
name map + power the relative pickers) and all events (to find partnership
events). On a 2k-person tree that's a ~230KB person list + ~600KB event list per
view. Now it loads only what the page shows:

Frontend:
- The relationship & spouse pickers use the backend's fuzzy pg_trgm search
  (debounced, typo-tolerant) instead of substring-filtering a preloaded array —
  better search, and no need to preload every person. PersonCombobox gained an
  `onSearch` server mode (client `people` mode still works).
- The page drops the all-persons and all-events fetches; it resolves just this
  person's relatives' names via GET /persons?ids=..., and reads partnership
  events from the per-person events endpoint.

Backend:
- GET /trees/{id}/persons?ids=a,b,c — batch by id (privacy-filtered, names
  batched), for relative-name display.
- list_events_for_person (member path) now also returns the person's partnership
  events, so the page needn't scan every event in the tree.

Adversarial review (frontend logic + backend/privacy) found no issues. Suite 105
passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:29:13 -04:00
justin 629bfa1367 Merge pull request 'Fix list_persons N+1 (the ~4s person-page load)' (#246) from fix-person-list-n-plus-one into main
build-backend / build (push) Successful in 37s
2026-06-11 08:00:47 -04:00
justin 1562febdcf Fix list_persons N+1 (the ~4s person-page load)
Opening any person page on a large tree took 4-5s on an idle server. Root cause:
list_persons looped over every person calling privacy.person_visibility (which
issues TWO get_membership_role queries per call) AND _attach_primary_name (one
name query per person). On the reporter's 2,324-person tree that's ~7,000
serialized DB round-trips per page load — the person page fetches the full
person list to build its name-lookup map.

Fix:
- Resolve the viewer's membership role ONCE. Members see the whole tree (full),
  so skip the per-person privacy engine entirely.
- Add _attach_primary_names: one batched names query (person_id IN (...),
  ordered the same as the single-person query so it picks the same name) instead
  of one per person.
- Apply the same batching to the non-member path, search_persons, the deleted-
  persons list, and public_view_service.list_public_persons.

Member-path list_persons goes from ~3·N queries to ~3 total. Other tree-wide
list endpoints (events/relationships/media/citations) were already flat selects.

Adds a regression test that asserts list_persons issues a constant number of
queries (not proportional to person count). Suite: 103 passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 08:00:30 -04:00
20 changed files with 900 additions and 85 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
+11 -2
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
@@ -41,9 +41,18 @@ async def list_persons(
current: CurrentUser, current: CurrentUser,
deleted: bool = False, deleted: bool = False,
q: str | None = None, q: str | None = None,
ids: str | None = None,
) -> list[PersonRead]: ) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
if q: if ids is not None:
try:
id_list = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
except ValueError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid ids") from exc
persons = await person_service.list_persons_by_ids(
session, viewer_id=current.id, tree=tree, ids=id_list
)
elif q:
persons = await person_service.search_persons( persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q session, viewer_id=current.id, tree=tree, query=q
) )
+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(
+21 -2
View File
@@ -4,9 +4,10 @@ engine. Every event has exactly one subject — a Person or a partnership."""
import uuid import uuid
from datetime import date from datetime import date
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import RelationshipType
from app.models.event import Event from app.models.event import Event
from app.models.person import Person from app.models.person import Person
from app.models.place import Place from app.models.place import Place
@@ -124,12 +125,30 @@ async def list_events_for_person(
return await public_view_service.list_public_person_events( return await public_view_service.list_public_person_events(
session, viewer_id=viewer_id, tree=tree, person_id=person_id session, viewer_id=viewer_id, tree=tree, person_id=person_id
) )
# Member view: this person's own events PLUS their partnership events (which
# live on the relationship and show on both partners). Returning both here
# means the person page doesn't have to load every event in the tree.
partner_rel_ids = (
select(Relationship.id)
.where(
Relationship.tree_id == tree.id,
Relationship.type == RelationshipType.partnership,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person_id,
Relationship.person_to_id == person_id,
),
)
)
stmt = ( stmt = (
select(Event) select(Event)
.where( .where(
Event.tree_id == tree.id, Event.tree_id == tree.id,
Event.person_id == person_id,
Event.deleted_at.is_(None), Event.deleted_at.is_(None),
or_(
Event.person_id == person_id,
Event.relationship_id.in_(partner_rel_ids),
),
) )
.order_by(Event.date_start.nulls_last(), Event.created_at) .order_by(Event.date_start.nulls_last(), Event.created_at)
) )
+86 -5
View File
@@ -45,6 +45,29 @@ async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
person.primary_name = _format_name(name) if name is not None else None person.primary_name = _format_name(name) if name is not None else None
async def _attach_primary_names(session: AsyncSession, persons: list[Person]) -> None:
"""Batch version of ``_attach_primary_name`` — ONE query for the whole list
instead of one per person (the difference between 1 and N queries when
rendering a 2k-person tree). The global order (is_primary desc, sort_order)
matches the single-person query, so the first row seen per person is the same
name ``_attach_primary_name`` would pick."""
if not persons:
return
rows = (
await session.execute(
select(Name)
.where(Name.person_id.in_([p.id for p in persons]), Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().all()
best: dict[uuid.UUID, Name] = {}
for n in rows:
best.setdefault(n.person_id, n)
for p in persons:
n = best.get(p.id)
p.primary_name = _format_name(n) if n is not None else None
async def create_person( async def create_person(
session: AsyncSession, session: AsyncSession,
*, *,
@@ -336,15 +359,18 @@ async def list_deleted_persons(
.order_by(Person.deleted_at.desc()) .order_by(Person.deleted_at.desc())
) )
persons = list((await session.execute(stmt)).scalars().all()) persons = list((await session.execute(stmt)).scalars().all())
for person in persons: await _attach_primary_names(session, persons)
await _attach_primary_name(session, person)
return persons return persons
async def list_persons( async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: ) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): # Resolve the viewer's role ONCE. Members see the whole tree (full), so we
# skip the per-person privacy engine entirely and batch the name fetch — the
# difference between ~3 queries and ~3·N queries on a 2k-person tree.
role = await privacy.get_membership_role(session, viewer_id, tree.id)
if role is None and not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
stmt = ( stmt = (
@@ -354,7 +380,15 @@ async def list_persons(
) )
persons = list((await session.execute(stmt)).scalars().all()) persons = list((await session.execute(stmt)).scalars().all())
if role is not None:
await _attach_primary_names(session, persons)
return persons
# Non-member on a viewable (public/unlisted/site_members) tree: redact per
# person. Names are batched for the non-redacted ones; redacted ones already
# have their display name overwritten by _redact.
visible: list[Person] = [] visible: list[Person] = []
full: list[Person] = []
for person in persons: for person in persons:
vis = await privacy.person_visibility( vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person session, user_id=viewer_id, tree=tree, person=person
@@ -364,8 +398,50 @@ async def list_persons(
if vis == Visibility.redacted: if vis == Visibility.redacted:
_redact(person) _redact(person)
else: else:
await _attach_primary_name(session, person) full.append(person)
visible.append(person) visible.append(person)
await _attach_primary_names(session, full)
return visible
async def list_persons_by_ids(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, ids: list[uuid.UUID]
) -> list[Person]:
"""Just the named persons (privacy-filtered, names batched). Lets a page show
the names of someone's relatives without loading the whole tree."""
role = await privacy.get_membership_role(session, viewer_id, tree.id)
if role is None and not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
if not ids:
return []
persons = list(
(
await session.execute(
select(Person).where(
Person.id.in_(ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalars().all()
)
if role is not None:
await _attach_primary_names(session, persons)
return persons
visible: list[Person] = []
full: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
)
if vis == Visibility.hidden:
continue
if vis == Visibility.redacted:
_redact(person)
else:
full.append(person)
visible.append(person)
await _attach_primary_names(session, full)
return visible return visible
@@ -406,7 +482,11 @@ async def search_persons(
.order_by(sub.c.score.desc()) .order_by(sub.c.score.desc())
) )
persons = list((await session.execute(stmt)).scalars().all()) persons = list((await session.execute(stmt)).scalars().all())
if await privacy.get_membership_role(session, viewer_id, tree.id) is not None:
await _attach_primary_names(session, persons)
return persons
out: list[Person] = [] out: list[Person] = []
full: list[Person] = []
for person in persons: for person in persons:
vis = await privacy.person_visibility( vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person session, user_id=viewer_id, tree=tree, person=person
@@ -416,6 +496,7 @@ async def search_persons(
if vis == Visibility.redacted: if vis == Visibility.redacted:
_redact(person) _redact(person)
else: else:
await _attach_primary_name(session, person) full.append(person)
out.append(person) out.append(person)
await _attach_primary_names(session, full)
return out return out
+8 -2
View File
@@ -33,7 +33,11 @@ from app.models.source import Citation, Source
from app.models.tree import Tree from app.models.tree import Tree
from app.services import privacy from app.services import privacy
from app.services.exceptions import NotFound from app.services.exceptions import NotFound
from app.services.person_service import _attach_primary_name, _redact from app.services.person_service import (
_attach_primary_name,
_attach_primary_names,
_redact,
)
from app.services.privacy import Visibility from app.services.privacy import Visibility
@@ -78,6 +82,7 @@ async def list_public_persons(
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
) -> list[Person]: ) -> list[Person]:
out: list[Person] = [] out: list[Person] = []
full: list[Person] = []
for p in await _persons(session, tree): for p in await _persons(session, tree):
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p) vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p)
if vis == Visibility.hidden: if vis == Visibility.hidden:
@@ -85,8 +90,9 @@ async def list_public_persons(
if vis == Visibility.redacted: if vis == Visibility.redacted:
_redact(p) _redact(p)
else: else:
await _attach_primary_name(session, p) full.append(p)
out.append(p) out.append(p)
await _attach_primary_names(session, full) # one query, not one per person
return out return out
+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 = (
@@ -0,0 +1,39 @@
"""Regression guard: list_persons must batch — a constant number of queries,
not one (or three) per person. A 2k-person tree took ~4s before this was fixed."""
import sqlalchemy as sa
from tests.conftest import auth, register
async def test_list_persons_does_not_n_plus_one(client, engine):
owner = auth(await register(client, "perf-owner@ex.com"))
tid = (await client.post("/api/v1/trees", json={"name": "Perf"}, headers=owner)).json()["id"]
n = 25
for i in range(n):
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": f"P{i}", "surname": "X"},
headers=owner,
)
selects = 0
def _count(conn, cursor, statement, params, context, executemany):
nonlocal selects
if statement.lstrip().upper().startswith("SELECT"):
selects += 1
sa.event.listen(engine.sync_engine, "before_cursor_execute", _count)
try:
resp = await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)
finally:
sa.event.remove(engine.sync_engine, "before_cursor_execute", _count)
assert resp.status_code == 200
body = resp.json()
assert len(body) == n
assert all(p["primary_name"] for p in body) # names still resolve correctly
# Batched: a small constant (auth, role, persons, one names query, …) — NOT
# proportional to n. The old per-person path was ~3·n SELECTs.
assert 0 < selects < n, f"expected a constant query count, got {selects} for {n} people"
+60
View File
@@ -0,0 +1,60 @@
"""Backing the trimmed person-page fetch: batch persons by id (for relative-name
display) and partnership events on the per-person events endpoint (so the page
doesn't load every event in the tree)."""
from tests.conftest import auth, register
async def _tree(client, h):
return (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
async def test_list_persons_by_ids(client):
h = auth(await register(client, "ids@ex.com"))
tid = await _tree(client, h)
a = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Aaa"}, headers=h)).json()["id"]
b = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Bbb"}, headers=h)).json()["id"]
c = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Ccc"}, headers=h)).json()["id"]
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": f"{a},{c}"}, headers=h)
assert r.status_code == 200
assert {p["id"] for p in r.json()} == {a, c} # only the requested, not b
assert all(p["primary_name"] for p in r.json()) # names resolved
assert (
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": "nope"}, headers=h)
).status_code == 422
assert (
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": ""}, headers=h)
).json() == []
async def test_person_events_include_partnership(client):
h = auth(await register(client, "pev@ex.com"))
tid = await _tree(client, h)
p1 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P1"}, headers=h)).json()["id"]
p2 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P2"}, headers=h)).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": p1, "date_value": "1900"},
headers=h,
)
rel = (
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": p1, "person_to_id": p2},
headers=h,
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "marriage", "relationship_id": rel, "date_value": "1925"},
headers=h,
)
# P1's events: own birth + the partnership marriage, in one call.
e1 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p1}/events", headers=h)).json()}
assert {"birth", "marriage"} <= e1
# The marriage shows on BOTH partners' pages.
e2 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p2}/events", headers=h)).json()}
assert "marriage" in e2
+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>
@@ -135,7 +135,6 @@ export default function PersonDetailPage() {
const [evType, setEvType] = useState("birth"); const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState(""); const [evTypeOther, setEvTypeOther] = useState("");
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
const [dateQual, setDateQual] = useState("exact"); const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState(""); const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState(""); const [dateMonth, setDateMonth] = useState("");
@@ -189,8 +188,9 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); setPerson(p.data ?? null);
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([ // Person-scoped fetches only — the page no longer pulls the whole tree.
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), // /persons/{id}/events now includes this person's partnership events too.
const [nm, mine, tr, ev, rl, src, cit, med] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
}), }),
@@ -204,22 +204,49 @@ export default function PersonDetailPage() {
}), }),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
]); ]);
setPeople(all.data ?? []);
setNames(nm.data ?? []); setNames(nm.data ?? []);
setMe(mine.data ?? null); setMe(mine.data ?? null);
setTree(tr.data ?? null); setTree(tr.data ?? null);
setEvents(ev.data ?? []); setEvents(ev.data ?? []);
setAllEvents(evAll.data ?? []);
setMedia(med.data ?? []); setMedia(med.data ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []); setSources(src.data ?? []);
setCitations(cit.data ?? []); setCitations(cit.data ?? []);
// Resolve the names of just this person's relatives (for display), by id —
// not the whole tree. The relationship/spouse pickers search on demand.
const relList = rl.data ?? [];
const relatedIds = Array.from(
new Set(
relList
.flatMap((r) => [r.person_from_id, r.person_to_id])
.filter((id): id is string => !!id && id !== personId),
),
);
if (relatedIds.length) {
const rel = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { ids: relatedIds.join(",") } },
});
setPeople(rel.data ?? []);
} else {
setPeople([]);
}
setReady(true); setReady(true);
}, [router, treeId, personId]); }, [router, treeId, personId]);
// Server-side fuzzy search for the relative/spouse pickers — avoids loading
// every person just to search.
const searchPeople = useCallback(
async (query: string) => {
const r = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q: query } },
});
return (r.data ?? []).filter((pp) => pp.id !== personId);
},
[treeId, personId],
);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
@@ -233,7 +260,6 @@ export default function PersonDetailPage() {
return (id: string) => m.get(id) ?? "source"; return (id: string) => m.get(id) ?? "source";
}, [sources]); }, [sources]);
const others = people.filter((p) => p.id !== personId);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId); const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId); const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter((r) => r.type === "partnership"); const partners = rels.filter((r) => r.type === "partnership");
@@ -241,22 +267,18 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id); const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId); const personCites = citations.filter((c) => c.person_id === personId);
// Partnership events live on the relationship and show on both partners. // Partnership events live on the relationship and show on both partners; the
// /persons/{id}/events endpoint now returns them alongside personal events.
const myPartnerRels = rels.filter( const myPartnerRels = rels.filter(
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId), (r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
); );
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
const relEvents = allEvents.filter(
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
);
const spouseOfRelEvent = (relId: string | null | undefined) => { const spouseOfRelEvent = (relId: string | null | undefined) => {
const r = myPartnerRels.find((x) => x.id === relId); const r = myPartnerRels.find((x) => x.id === relId);
if (!r) return null; if (!r) return null;
return r.person_from_id === personId ? r.person_to_id : r.person_from_id; return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
}; };
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t); const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
// Personal events + this person's partnership events, shown together. const shownEvents = events;
const shownEvents = [...events, ...relEvents];
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -1090,7 +1112,7 @@ export default function PersonDetailPage() {
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Spouse / partner</span> <span className="text-xs text-[var(--muted)]">Spouse / partner</span>
<PersonCombobox <PersonCombobox
people={others} onSearch={searchPeople}
value={evSpouse} value={evSpouse}
onChange={setEvSpouse} onChange={setEvSpouse}
placeholder="Search for a spouse…" placeholder="Search for a spouse…"
@@ -1158,36 +1180,32 @@ export default function PersonDetailPage() {
</div> </div>
)} )}
{others.length === 0 ? ( <form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p> <span className="text-sm text-[var(--muted)]">Add</span>
) : ( <select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2"> <option value="parent">parent</option>
<span className="text-sm text-[var(--muted)]">Add</span> <option value="child">child</option>
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}> <option value="partner">partner</option>
<option value="parent">parent</option> <option value="sibling">sibling</option>
<option value="child">child</option> </select>
<option value="partner">partner</option> <PersonCombobox
<option value="sibling">sibling</option> onSearch={searchPeople}
value={relOther}
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select> </select>
<PersonCombobox )}
people={others} <Button type="submit">Link</Button>
value={relOther} </form>
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
)}
<Button type="submit">Link</Button>
</form>
)}
{relErr && <p className="text-sm text-red-600">{relErr}</p>} {relErr && <p className="text-sm text-red-600">{relErr}</p>}
</CardContent> </CardContent>
</Card> </Card>
+21 -2
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;
@@ -381,9 +384,25 @@
color: rgb(255, 251, 220); color: rgb(255, 251, 220);
background-color: rgba(255, 251, 220, 0); background-color: rgba(255, 251, 220, 0);
border-radius: 50%; border-radius: 50%;
padding: 2px; padding: 2px 4px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out; transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
} }
.f3 .f3-card-duplicate-tag:hover {
background-color: rgba(255, 251, 220, 0.9);
color: #000;
}
/* Click the ×N badge → every copy of that person flashes (see tree/page.tsx). */
@keyframes f3-card-flash {
0%, 100% { outline-color: rgba(160, 106, 66, 0); }
30%, 70% { outline-color: rgba(160, 106, 66, 1); }
}
.f3 .f3-card-flash .card-inner {
outline: 4px solid rgba(160, 106, 66, 1);
animation: f3-card-flash 0.55s ease-in-out 3;
}
.f3 .f3-card-duplicate-hover div.card-inner { .f3 .f3-card-duplicate-hover div.card-inner {
transform: translate(0, -2px); transform: translate(0, -2px);
+129 -6
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,6 +261,85 @@ export default function TreePage() {
[mode], [mode],
); );
// Click the "×N" duplicate badge to FLY to the person's other copy in the
// view (cycling through them on repeat clicks) and flash it on arrival. The
// same record is drawn in two places (a shared ancestor, or an intermarriage),
// 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(() => {
const el = containerRef.current;
if (!el) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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) {
const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag");
if (!tag) return;
e.preventDefault();
e.stopPropagation();
const clicked = tag.closest(".card_cont");
const id = idOf(clicked);
if (!id) return;
const copies = Array.from(el!.querySelectorAll(".card_cont")).filter((c) => idOf(c) === id);
if (copies.length < 2) {
flash(clicked);
return;
}
// Advance from wherever we last landed (or the clicked card), skipping the
// clicked copy, so each click moves to the next other location.
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);
return () => el.removeEventListener("click", onClick, true);
}, []);
// Mirror the focused person into the URL (?focus=…) so navigating away and // Mirror the focused person into the URL (?focus=…) so navigating away and
// back — or sharing the link — keeps the tree centered where you left it. // back — or sharing the link — keeps the tree centered where you left it.
// `replace` (not push) so each recenter doesn't pile up in browser history. // `replace` (not push) so each recenter doesn't pile up in browser history.
@@ -402,11 +490,46 @@ export default function TreePage() {
/> />
)} )}
<p className="text-sm text-[var(--muted)]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--muted)]">
{mode === "fan" <span>
? "Click an ancestor to recenter the fan." {mode === "fan"
: "Drag to pan · scroll to zoom · click a person to recenter."} ? "Click an ancestor to recenter the fan."
</p> : "Drag to pan · scroll to zoom · click a person to recenter."}
</span>
{mode !== "fan" && (
<div className="group relative">
<button
type="button"
className="underline decoration-dotted underline-offset-2 hover:text-bronze focus-visible:text-bronze focus-visible:outline-none"
>
Legend
</button>
<div className="invisible absolute bottom-full left-0 z-30 mb-2 w-80 rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3 text-xs text-[var(--foreground)] opacity-0 shadow-lg transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
<ul className="space-y-2">
<li>
<span className="font-semibold text-bronze">×N</span> on a card this
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
ancestor, or an intermarriage).{" "}
<span className="text-[var(--muted)]">Click the ×N to fly to the other copies (click again to cycle).</span>
</li>
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(120,159,172)" }} /> male
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(196,138,146)" }} /> female
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "lightgray" }} /> sex not set
</span>
</li>
<li>Drag to pan, scroll to zoom, and click any card to recenter the tree on that person.</li>
</ul>
</div>
</div>
)}
</div>
</div> </div>
); );
} }
+63 -19
View File
@@ -1,26 +1,30 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
/** /**
* A type-to-filter person picker. Shows a text input; as you type, a dropdown * A type-to-pick person picker. Two modes:
* of matching people appears. Selecting one sets `value` (a person id) and * - client (`people`): filter a preloaded list in the browser.
* fills the input with their name. Replaces a plain <select> when the list is * - server (`onSearch`): query the backend (debounced) as you type — the
* long enough that scanning it by hand is painful. * preferred mode for large trees, so the page doesn't
* have to preload every person just to search.
* Selecting one sets `value` (a person id) and fills the input with their name.
*/ */
export function PersonCombobox({ export function PersonCombobox({
people, people,
onSearch,
value, value,
onChange, onChange,
onCreate, onCreate,
placeholder = "Search for a person…", placeholder = "Search for a person…",
className, className,
}: { }: {
people: Person[]; people?: Person[];
onSearch?: (q: string) => Promise<Person[]>;
value: string; value: string;
onChange: (id: string) => void; onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */ /** When set, the dropdown offers a "Create '<typed name>'" action. */
@@ -30,21 +34,27 @@ export function PersonCombobox({
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [results, setResults] = useState<Person[]>([]);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
// Names we've seen (from the list or search results), so a selected value
// keeps displaying its name even in server mode.
const known = useRef<Map<string, string>>(new Map());
const nameOf = useMemo( const remember = useCallback((ps: Person[] | undefined) => {
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])), for (const p of ps ?? []) known.current.set(p.id, p.primary_name ?? "Unnamed");
[people], }, []);
); useEffect(() => {
remember(people);
}, [people, remember]);
const nameOf = useCallback((id: string) => known.current.get(id) ?? "", []);
// Keep the input text in sync when the selection changes externally // Keep the input text in sync when the selection changes externally
// (e.g. cleared to "" after a successful add). // (e.g. cleared to "" after a successful add).
useEffect(() => { useEffect(() => {
if (!value) { if (!value) setQuery("");
setQuery(""); else if (!open) setQuery(nameOf(value));
} else if (!open) {
setQuery(nameOf.get(value) ?? "");
}
}, [value, open, nameOf]); }, [value, open, nameOf]);
// Close on outside click. // Close on outside click.
@@ -56,17 +66,48 @@ export function PersonCombobox({
return () => document.removeEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc);
}, []); }, []);
// Server search, debounced. Stale responses are dropped via `cancelled`.
useEffect(() => {
if (!onSearch) return;
const q = query.trim();
if (!q) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
let cancelled = false;
const t = setTimeout(async () => {
try {
const r = await onSearch(q);
if (cancelled) return;
remember(r);
setResults(r);
} finally {
if (!cancelled) setLoading(false);
}
}, 160);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [query, onSearch, remember]);
const matches = useMemo(() => { const matches = useMemo(() => {
if (onSearch) return results.slice(0, 10);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const pool = q const pool = q
? people.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) ? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: people; : (people ?? []);
return pool.slice(0, 10); return pool.slice(0, 10);
}, [query, people]); }, [query, results, people, onSearch]);
const base = const base =
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40"; "h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
const showDropdown =
open && (matches.length > 0 || loading || (onCreate && query.trim()));
return ( return (
<div ref={wrapRef} className="relative"> <div ref={wrapRef} className="relative">
<input <input
@@ -80,8 +121,11 @@ export function PersonCombobox({
if (value) onChange(""); // typing invalidates the prior pick if (value) onChange(""); // typing invalidates the prior pick
}} }}
/> />
{open && (matches.length > 0 || (onCreate && query.trim())) && ( {showDropdown && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg"> <ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{loading && matches.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--muted)]">Searching</li>
)}
{matches.map((p) => ( {matches.map((p) => (
<li key={p.id}> <li key={p.id}>
<button <button
+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),
+67
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: {
/** /**
@@ -2719,6 +2752,7 @@ export interface operations {
query?: { query?: {
deleted?: boolean; deleted?: boolean;
q?: string | null; q?: string | null;
ids?: string | null;
}; };
header?: never; header?: never;
path: { path: {
@@ -4012,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;
+98
View File
@@ -851,6 +851,22 @@
], ],
"title": "Q" "title": "Q"
} }
},
{
"name": "ids",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Ids"
}
} }
], ],
"responses": { "responses": {
@@ -2857,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": [
@@ -4881,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 }) {