Compare commits
18 Commits
265f5f4e7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7043532c3b | |||
| 1340d1957f | |||
| e24a7cfcc9 | |||
| 07944e329e | |||
| a33a88e558 | |||
| fe8349819f | |||
| e745fb5d4d | |||
| e0573e6be2 | |||
| 3731d77d4b | |||
| bf1576252b | |||
| 0ed6ba4505 | |||
| ed263cf9a7 | |||
| f7666ad30b | |||
| 690a6da659 | |||
| e7115023e1 | |||
| 58400ffdf7 | |||
| 629bfa1367 | |||
| 1562febdcf |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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 child’s 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 can’t reach them) but who have a child born long ago — they’re 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Vendored
+67
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user