7 Commits

Author SHA1 Message Date
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 76b7f453c1 Add update (CRUD) for events and people; record the full-CRUD invariant
Events and people are now editable, not write-once: PATCH /events/{id} (type, structured date, place, notes) and PATCH /persons/{id} (vitals, privacy, and the primary name's given/surname). CLAUDE.md gains rule #8: every stored object must support full CRUD in API and UI — historical research is constant correction. Tests cover both updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 438d2db2e7 Merge pull request 'Tree layout toggles + fan + card->profile + server search' (#16) from phase2-tree-toggles into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 08:01:32 -04:00
justin 99913ada94 Tree layout toggles (landscape/portrait/fan), card->profile, server search
Tree page gets Landscape/Portrait/Fan toggles: landscape & portrait via family-chart's orientation; a hand-rolled radial Fan chart of ancestors (rings per generation, click to recenter). Clicking a card recenters and updates an 'Open <name> →' link to that person's profile. The People directory search now hits the server-side pg_trgm fuzzy endpoint (debounced) so it spans the whole tree, not just the loaded page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 08:01:31 -04:00
justin 584b323121 Merge pull request 'Fuzzy search (pg_trgm) + living-person protection' (#15) from phase2-search-privacy into main
build-backend / build (push) Successful in 30s
2026-06-07 07:55:14 -04:00
justin 4788ae7723 Add fuzzy name search (pg_trgm) and living-person protection
Fuzzy search: pg_trgm extension + trigram GIN indexes on name parts and a GET /trees/{id}/persons?q= search ranked by trigram similarity (finds Mueller for 'muller'), privacy-filtered. Living-person protection: the privacy engine now derives possibly-living status (explicit flag, else no death fact + birth within ~100y or unknown) and returns 'redacted' for non-members of public/unlisted trees; the service minimises those records ('Living person', no vitals). Members are unaffected. 31 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 07:55:13 -04:00
justin 51f0066e61 Merge pull request 'Interactive Tree view (pan/zoom genealogy chart)' (#14) from interactive-tree into main
build-frontend / build (push) Successful in 1m21s
2026-06-06 23:07:04 -04:00
21 changed files with 1485 additions and 161 deletions
+1
View File
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact. 5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe). 6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys. 7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
## Tech stack ## Tech stack
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead from app.schemas.event import EventCreate, EventRead, EventUpdate
from app.services import event_service, tree_service from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"]) router = APIRouter(prefix="/trees", tags=["events"])
@@ -40,6 +40,25 @@ async def list_person_events(
return [EventRead.model_validate(e) for e in events] return [EventRead.model_validate(e) for e in events]
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
async def update_event(
tree_id: uuid.UUID,
event_id: uuid.UUID,
data: EventUpdate,
session: SessionDep,
current: CurrentUser,
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.update_event(
session,
actor=current,
tree=tree,
event_id=event_id,
changes=data.model_dump(exclude_unset=True),
)
return EventRead.model_validate(event)
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event( async def delete_event(
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+30 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
from app.services import person_service, tree_service from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary). # Persons are nested under their tree (the tenant boundary).
@@ -36,10 +36,18 @@ async def create_person(
@router.get("/{tree_id}/persons", response_model=list[PersonRead]) @router.get("/{tree_id}/persons", response_model=list[PersonRead])
async def list_persons( async def list_persons(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, deleted: bool = False tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
deleted: bool = False,
q: 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 deleted: if q:
persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q
)
elif deleted:
persons = await person_service.list_deleted_persons( persons = await person_service.list_deleted_persons(
session, viewer_id=current.id, tree=tree session, viewer_id=current.id, tree=tree
) )
@@ -48,6 +56,25 @@ async def list_persons(
return [PersonRead.model_validate(p) for p in persons] return [PersonRead.model_validate(p) for p in persons]
@router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def update_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: PersonUpdate,
session: SessionDep,
current: CurrentUser,
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.update_person(
session,
actor=current,
tree=tree,
person_id=person_id,
changes=data.model_dump(exclude_unset=True),
)
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_person( async def delete_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+17 -1
View File
@@ -7,7 +7,7 @@ aliases) so name changes over time are first-class.
import uuid import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -33,6 +33,22 @@ class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "names" __tablename__ = "names"
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
# pg_trgm extension (enabled in the accompanying migration).
__table_args__ = (
Index(
"ix_names_given_trgm",
"given",
postgresql_using="gin",
postgresql_ops={"given": "gin_trgm_ops"},
),
Index(
"ix_names_surname_trgm",
"surname",
postgresql_using="gin",
postgresql_ops={"surname": "gin_trgm_ops"},
),
)
person_id: Mapped[uuid.UUID] = mapped_column( person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True ForeignKey("persons.id", ondelete="CASCADE"), index=True
+13
View File
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
notes: str | None = None notes: str | None = None
class EventUpdate(BaseModel):
# All optional; only fields explicitly sent are changed (PATCH semantics).
event_type: str | None = None
place_id: uuid.UUID | None = None
date_value: str | None = None
date_start: date | None = None
date_end: date | None = None
date_precision: str | None = None
calendar: str | None = None
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel): class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+10
View File
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
notes: str | None = None notes: str | None = None
class PersonUpdate(BaseModel):
# Person fields + the primary name's parts; only sent fields are changed.
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy | None = None
notes: str | None = None
class PersonRead(BaseModel): class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+38
View File
@@ -122,6 +122,44 @@ async def list_events_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_id: uuid.UUID,
changes: dict,
) -> Event:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
event = (
await session.execute(
select(Event).where(
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if event is None:
raise NotFound("event not found")
if "place_id" in changes and changes["place_id"] is not None:
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
raise NotFound("place not found in this tree")
for key, value in changes.items():
setattr(event, key, value)
record_audit(
session,
action="update",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(event)
return event
async def delete_event( async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
) -> None: ) -> None:
+130 -13
View File
@@ -6,7 +6,7 @@ person through the privacy engine. Each returned Person gets a transient
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy from app.models.enums import PersonPrivacy
@@ -25,6 +25,14 @@ def _format_name(name: Name) -> str | None:
return joined or name.display_name return joined or name.display_name
def _redact(person: Person) -> None:
"""Minimise a possibly-living person for a non-member view (transient only —
never committed)."""
person.primary_name = "Living person"
person.gender = None
person.is_living = True
async def _attach_primary_name(session: AsyncSession, person: Person) -> None: async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
stmt = ( stmt = (
select(Name) select(Name)
@@ -87,6 +95,59 @@ async def create_person(
return person return person
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
async def update_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
for key in _PERSON_FIELDS & changes.keys():
setattr(person, key, changes[key])
if "given" in changes or "surname" in changes:
name = (
await session.execute(
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().first()
if name is None:
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
session.add(name)
if "given" in changes:
name.given = changes["given"]
if "surname" in changes:
name.surname = changes["surname"]
name.display_name = None # rebuild display from parts
record_audit(
session,
action="update",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def get_person( async def get_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> Person: ) -> Person:
@@ -104,12 +165,15 @@ async def get_person(
if person is None: if person is None:
raise NotFound("person not found") raise NotFound("person not found")
# Run the single person through the privacy engine (redaction lands Phase 2). # Run the single person through the privacy engine (redaction lands Phase 2).
if ( vis = await privacy.person_visibility(
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) session, user_id=viewer_id, tree=tree, person=person
== Visibility.hidden )
): if vis == Visibility.hidden:
raise NotFound("person not found") raise NotFound("person not found")
await _attach_primary_name(session, person) if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
return person return person
@@ -199,13 +263,66 @@ async def list_persons(
visible: list[Person] = [] visible: list[Person] = []
for person in persons: for person in persons:
if ( vis = await privacy.person_visibility(
await privacy.person_visibility( session, user_id=viewer_id, tree=tree, person=person
session, user_id=viewer_id, tree=tree, person=person )
) if vis == Visibility.hidden:
== Visibility.hidden
):
continue continue
await _attach_primary_name(session, person) if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
visible.append(person) visible.append(person)
return visible return visible
async def search_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
q = query.strip()
if not q:
return []
like = f"%{q}%"
score = func.greatest(
func.similarity(func.coalesce(Name.given, ""), q),
func.similarity(func.coalesce(Name.surname, ""), q),
)
sub = (
select(Name.person_id.label("pid"), func.max(score).label("score"))
.where(
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
or_(
Name.given.op("%")(q),
Name.surname.op("%")(q),
Name.given.ilike(like),
Name.surname.ilike(like),
),
)
.group_by(Name.person_id)
.order_by(func.max(score).desc())
.limit(limit)
.subquery()
)
stmt = (
select(Person)
.join(sub, sub.c.pid == Person.id)
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
.order_by(sub.c.score.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
out: 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:
await _attach_primary_name(session, person)
out.append(person)
return out
+49 -2
View File
@@ -8,14 +8,20 @@ tree's visibility, the per-person override, and (Phase 2) living-person status.
import enum import enum
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
from app.models.event import Event
from app.models.person import Person from app.models.person import Person
from app.models.tree import Tree, TreeMembership from app.models.tree import Tree, TreeMembership
# A person with no death fact whose birth is within this window (or unknown) is
# treated as possibly living and redacted from non-members (ARCHITECTURE §6).
LIVING_RECENCY_YEARS = 100
class Visibility(enum.StrEnum): class Visibility(enum.StrEnum):
full = "full" full = "full"
@@ -48,15 +54,56 @@ async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
return role in (MembershipRole.owner, MembershipRole.editor) return role in (MembershipRole.owner, MembershipRole.editor)
async def is_possibly_living(session: AsyncSession, person: Person) -> bool:
"""True if the person should be treated as living: explicit flag, or (absent
a death fact) a birth within the recency window or an unknown birth."""
if person.is_living is True:
return True
if person.is_living is False:
return False
death = (
await session.execute(
select(Event.id)
.where(
Event.person_id == person.id,
Event.event_type == "death",
Event.deleted_at.is_(None),
)
.limit(1)
)
).scalar_one_or_none()
if death is not None:
return False
birth = (
await session.execute(
select(Event.date_start)
.where(
Event.person_id == person.id,
Event.event_type == "birth",
Event.date_start.is_not(None),
Event.deleted_at.is_(None),
)
.order_by(Event.date_start)
.limit(1)
)
).scalar_one_or_none()
if birth is None:
return True # unknown birth → treat as possibly living
return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS
async def person_visibility( async def person_visibility(
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
) -> Visibility: ) -> Visibility:
if not await can_view_tree(session, user_id=user_id, tree=tree): if not await can_view_tree(session, user_id=user_id, tree=tree):
return Visibility.hidden return Visibility.hidden
if await get_membership_role(session, user_id, tree.id) is not None: if await get_membership_role(session, user_id, tree.id) is not None:
return Visibility.full return Visibility.full # members see everyone in their tree
# Non-member viewing a public/unlisted tree: # Non-member viewing a public/unlisted tree:
if person.privacy == PersonPrivacy.private: if person.privacy == PersonPrivacy.private:
return Visibility.hidden return Visibility.hidden
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6). if person.privacy == PersonPrivacy.public:
return Visibility.full # explicit per-person opt-in
if await is_possibly_living(session, person):
return Visibility.redacted # living people are protected by default
return Visibility.full return Visibility.full
@@ -0,0 +1,33 @@
"""pg_trgm extension + trigram name indexes for fuzzy search
Revision ID: 9a2b1c7d4e10
Revises: 7fc7024ef432
Create Date: 2026-06-07
"""
from collections.abc import Sequence
from alembic import op
revision: str = "9a2b1c7d4e10"
down_revision: str | None = "7fc7024ef432"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_given_trgm "
"ON names USING gin (given gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_surname_trgm "
"ON names USING gin (surname gin_trgm_ops)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_names_surname_trgm")
op.execute("DROP INDEX IF EXISTS ix_names_given_trgm")
# Leave the pg_trgm extension in place; other features may rely on it.
+2
View File
@@ -11,6 +11,7 @@ import os
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata import app.models # noqa: F401 — register all models on Base.metadata
@@ -72,6 +73,7 @@ async def client():
engine = create_async_engine(TEST_DATABASE_URL) engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
+19
View File
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
assert resp.status_code == 403 assert resp.status_code == 403
async def test_person_update(client):
token = await register(client, "edit@example.com")
h = auth(token)
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Jon", "surname": "Smith"}, headers=h
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tid}/persons/{pid}",
json={"given": "John", "gender": "male"},
headers=auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["primary_name"] == "John Smith"
assert resp.json()["gender"] == "male"
async def test_auth_required_without_token(client): async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees") resp = await client.get("/api/v1/trees")
assert resp.status_code == 401 assert resp.status_code == 401
+19
View File
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
assert len(listed.json()) == 0 assert len(listed.json()) == 0
async def test_event_update(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
eid = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
headers=h,
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tree_id}/events/{eid}",
json={"date_value": "ABT 1851", "event_type": "baptism"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert resp.json()["date_value"] == "ABT 1851"
assert resp.json()["event_type"] == "baptism"
async def test_event_requires_exactly_one_subject(client): async def test_event_requires_exactly_one_subject(client):
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
resp = await client.post( resp = await client.post(
+36
View File
@@ -0,0 +1,36 @@
"""Living-person protection: living people are redacted from non-members."""
from tests.conftest import auth, register
async def test_living_person_redacted_for_non_members(client):
owner = auth(await register(client, "pub-owner@example.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=owner
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Old", "surname": "Ancestor", "is_living": False},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Young", "surname": "Living", "is_living": True},
headers=owner,
)
other = auth(await register(client, "pub-viewer@example.com"))
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=other)).json()
names = {p["primary_name"] for p in people}
assert "Old Ancestor" in names # deceased is visible
assert "Living person" in names # living is redacted
assert "Young Living" not in names # the real living name is hidden
# The redacted person leaks no gender.
living = next(p for p in people if p["primary_name"] == "Living person")
assert living["gender"] is None
# The owner (a member) sees real names.
owner_people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)).json()
assert "Young Living" in {p["primary_name"] for p in owner_people}
+24
View File
@@ -0,0 +1,24 @@
"""Fuzzy name search (pg_trgm)."""
from tests.conftest import auth, register
async def test_fuzzy_name_search(client):
h = auth(await register(client, "search@example.com"))
tid = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
for given, surname in [("Hans", "Mueller"), ("John", "Smith"), ("Anna", "Vogel")]:
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": given, "surname": surname},
headers=h,
)
# Trigram fuzziness: "muller" should find "Mueller" (not a substring match).
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "muller"}, headers=h)
assert r.status_code == 200
names = [p["primary_name"] or "" for p in r.json()]
assert any("Mueller" in n for n in names)
# Substring search still works.
r2 = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "smi"}, headers=h)
assert any("Smith" in (p["primary_name"] or "") for p in r2.json())
+87 -33
View File
@@ -34,6 +34,7 @@ export default function FamilyViewPage() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null); const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [results, setResults] = useState<Person[] | null>(null); // server fuzzy search
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name. // Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2 // `key` keeps each empty slot's inline form independent (a person has 2
@@ -65,6 +66,22 @@ export default function FamilyViewPage() {
load(); load();
}, [load]); }, [load]);
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
useEffect(() => {
const q = search.trim();
if (!q) {
setResults(null);
return;
}
const t = setTimeout(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { q } },
});
setResults(data ?? []);
}, 250);
return () => clearTimeout(t);
}, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const parentsOf = (id: string) => const parentsOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id); rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
@@ -105,23 +122,42 @@ export default function FamilyViewPage() {
load(); load();
} }
async function postRel(body: components["schemas"]["RelationshipCreate"]) {
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
// Create the relationship(s) connecting an (existing or new) person to anchor.
async function createLink(kind: AddKind, anchor: string, personId: string) {
if (kind === "parent") {
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
} else if (kind === "partner") {
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
} else {
// child: link to anchor, and to anchor's spouse too (so both parents show)
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
const partners = partnersOf(anchor);
if (partners.length === 1) {
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
}
}
}
async function linkExisting(personId: string) {
if (!adding) return;
await createLink(adding.kind, adding.anchor, personId);
setAdding(null);
setAddName("");
load();
}
async function submitAdd(e: React.FormEvent) { async function submitAdd(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!adding || !addName.trim()) return; if (!adding || !addName.trim()) return;
const newId = await addPerson(addName); const newId = await addPerson(addName);
if (newId) { if (newId) await createLink(adding.kind, adding.anchor, newId);
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
setAdding(null); setAdding(null);
setAddName(""); setAddName("");
load(); load();
@@ -193,26 +229,45 @@ export default function FamilyViewPage() {
label: string; label: string;
}) => }) =>
adding?.key === formKey ? ( adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1"> <form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
<Input <Input
autoFocus autoFocus
className="h-9" className="h-9"
placeholder="Full name" placeholder="Search existing or type a new name"
value={addName} value={addName}
onChange={(e) => setAddName(e.target.value)} onChange={(e) => setAddName(e.target.value)}
/> />
<div className="flex gap-1"> {addName.trim() && (
<Button type="submit" size="sm"> <div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
Add {people
</Button> .filter(
<button (p) =>
type="button" p.id !== anchor &&
onClick={() => setAdding(null)} (p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
className="text-xs text-[var(--muted)]" )
> .slice(0, 6)
cancel .map((p) => (
</button> <button
</div> key={p.id}
type="button"
onClick={() => linkExisting(p.id)}
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
>
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</button>
))}
<button
type="submit"
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
>
+ Create new {addName.trim()}
</button>
</div>
)}
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
cancel
</button>
</form> </form>
) : ( ) : (
<button <button
@@ -265,10 +320,9 @@ export default function FamilyViewPage() {
const sorted = [...people].sort((a, b) => const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""), (a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
); );
const matches = search // Server fuzzy results when searching; otherwise the loaded set.
? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase())) const directory = results ?? sorted;
: sorted; const shown = directory.slice(0, 200); // cap DOM nodes; refine search to narrow
const shown = matches.slice(0, 200); // cap DOM nodes; refine search to narrow
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -358,9 +412,9 @@ export default function FamilyViewPage() {
)) ))
)} )}
</div> </div>
{matches.length > shown.length && ( {directory.length > shown.length && (
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]"> <div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
Showing {shown.length} of {matches.length} refine your search to narrow. Showing {shown.length} of {directory.length} refine your search to narrow.
</div> </div>
)} )}
</Card> </Card>
@@ -33,6 +33,53 @@ const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SE
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" }; const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0"); const pad = (n: number, len: number) => String(n).padStart(len, "0");
function composeDate(qual: string, day: string, month: string, year: string) {
const y = year.trim();
if (!y || Number.isNaN(Number(y))) {
return { date_value: null as string | null, date_start: null as string | null, date_precision: null as string | null };
}
const m = month ? Number(month) : null;
const d = day.trim() ? Number(day) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(y);
const prefix = DATE_QUALS[qual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(y), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: qual,
};
}
// Parse a stored date_value (e.g. "ABT 12 MAR 1900") back into form fields.
function parseDateValue(v: string | null | undefined) {
let qual = "exact";
let day = "";
let month = "";
let year = "";
if (v) {
let s = v.trim();
const up = s.toUpperCase();
for (const [q, pre] of Object.entries(DATE_QUALS)) {
if (pre && up.startsWith(`${pre} `)) {
qual = q;
s = s.slice(pre.length + 1).trim();
break;
}
}
for (const t of s.toUpperCase().split(/\s+/).filter(Boolean)) {
if (/^\d{3,4}$/.test(t) && !year) year = t;
else if (/^\d{1,2}$/.test(t)) day = String(Number(t));
else {
const mi = GED_MON.indexOf(t);
if (mi > 0) month = String(mi);
}
}
}
return { qual, day, month, year };
}
export default function PersonDetailPage() { export default function PersonDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string; personId: string }>(); const params = useParams<{ id: string; personId: string }>();
@@ -54,6 +101,23 @@ export default function PersonDetailPage() {
const [dateMonth, setDateMonth] = useState(""); const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState(""); const [dateYear, setDateYear] = useState("");
// Inline edit-event form.
const [editId, setEditId] = useState<string | null>(null);
const [edType, setEdType] = useState("birth");
const [edTypeOther, setEdTypeOther] = useState("");
const [edQual, setEdQual] = useState("exact");
const [edDay, setEdDay] = useState("");
const [edMonth, setEdMonth] = useState("");
const [edYear, setEdYear] = useState("");
// Inline edit-person form (name + vitals).
const [editingPerson, setEditingPerson] = useState(false);
const [pGiven, setPGiven] = useState("");
const [pSurname, setPSurname] = useState("");
const [pGender, setPGender] = useState("");
const [pLiving, setPLiving] = useState("unknown");
const [pPrivacy, setPPrivacy] = useState<"inherit" | "private" | "public">("inherit");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent"); const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological"); const [relQual, setRelQual] = useState<Qualifier>("biological");
@@ -112,30 +176,16 @@ 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);
function buildDate() {
const year = dateYear.trim();
if (!year || Number.isNaN(Number(year))) {
return { date_value: null, date_start: null, date_precision: null };
}
const m = dateMonth ? Number(dateMonth) : null;
const d = dateDay.trim() ? Number(dateDay) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(year);
const prefix = DATE_QUALS[dateQual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: dateQual,
};
}
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType; const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return; if (!event_type) return;
const { date_value, date_start, date_precision } = buildDate(); const { date_value, date_start, date_precision } = composeDate(
dateQual,
dateDay,
dateMonth,
dateYear,
);
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", { const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision }, body: { event_type, person_id: personId, date_value, date_start, date_precision },
@@ -156,6 +206,33 @@ export default function PersonDetailPage() {
load(); load();
} }
function startEdit(ev: Event) {
setEditId(ev.id);
const known = EVENT_TYPES.includes(ev.event_type);
setEdType(known ? ev.event_type : "other");
setEdTypeOther(known ? "" : ev.event_type);
const parsed = parseDateValue(ev.date_value);
setEdQual(parsed.qual);
setEdDay(parsed.day);
setEdMonth(parsed.month);
setEdYear(parsed.year);
}
async function saveEdit() {
if (!editId) return;
const event_type = edType === "other" ? edTypeOther.trim() : edType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(edQual, edDay, edMonth, edYear);
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: editId } },
body: { event_type, date_value, date_start, date_precision },
});
if (!error) {
setEditId(null);
load();
}
}
async function addRel(e: React.FormEvent) { async function addRel(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!relOther) return; if (!relOther) return;
@@ -213,6 +290,33 @@ export default function PersonDetailPage() {
router.push(`/trees/${treeId}`); router.push(`/trees/${treeId}`);
} }
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
setPSurname(t.length > 1 ? t[t.length - 1] : "");
setPGender(current.gender ?? "");
setPLiving(current.is_living === true ? "living" : current.is_living === false ? "deceased" : "unknown");
setPPrivacy((current.privacy as "inherit" | "private" | "public") ?? "inherit");
setEditingPerson(true);
}
async function savePerson() {
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: {
given: pGiven || null,
surname: pSurname || null,
gender: pGender || null,
is_living: pLiving === "living" ? true : pLiving === "deceased" ? false : null,
privacy: pPrivacy,
},
});
if (!error) {
setEditingPerson(false);
load();
}
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>; if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
@@ -316,15 +420,56 @@ export default function PersonDetailPage() {
Back to tree Back to tree
</Link> </Link>
<div className="flex flex-wrap items-center justify-between gap-2"> {editingPerson ? (
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1> <form
<div className="flex items-center gap-3"> onSubmit={(e) => {
{citeControl("p", { person_id: personId }, personCites)} e.preventDefault();
<Button variant="ghost" size="sm" onClick={removePerson}> savePerson();
Delete }}
</Button> className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap gap-2">
<Input className="w-40" placeholder="Given name" value={pGiven} onChange={(e) => setPGiven(e.target.value)} />
<Input className="w-40" placeholder="Surname" value={pSurname} onChange={(e) => setPSurname(e.target.value)} />
<Input className="w-32" placeholder="Gender" value={pGender} onChange={(e) => setPGender(e.target.value)} />
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
<option value="unknown">Status: unknown</option>
<option value="living">Living</option>
<option value="deceased">Deceased</option>
</select>
<select
className={fieldCls}
value={pPrivacy}
onChange={(e) => setPPrivacy(e.target.value as "inherit" | "private" | "public")}
>
<option value="inherit">Privacy: default</option>
<option value="private">Private</option>
<option value="public">Public</option>
</select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button type="button" onClick={() => setEditingPerson(false)} className="text-xs text-[var(--muted)]">
cancel
</button>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div> </div>
</div> )}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -335,26 +480,98 @@ export default function PersonDetailPage() {
<p className="text-sm text-[var(--muted)]">No events yet.</p> <p className="text-sm text-[var(--muted)]">No events yet.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{events.map((ev) => ( {events.map((ev) =>
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm"> editId === ev.id ? (
<span> <li key={ev.id}>
<span className="font-medium capitalize">{ev.event_type}</span> <form
{ev.date_value ? ( onSubmit={(e) => {
<span className="text-[var(--muted)]"> {ev.date_value}</span> e.preventDefault();
) : null} saveEdit();
</span> }}
<span className="flex items-center gap-3"> className="flex flex-wrap items-end gap-2"
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
> >
× <select
</button> className={`${fieldCls} capitalize`}
</span> value={edType}
</li> onChange={(e) => setEdType(e.target.value)}
))} >
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
{edType === "other" && (
<Input
className="h-9 w-32"
placeholder="Custom"
value={edTypeOther}
onChange={(e) => setEdTypeOther(e.target.value)}
/>
)}
<select className={fieldCls} value={edQual} onChange={(e) => setEdQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="Day"
value={edDay}
onChange={(e) => setEdDay(e.target.value)}
/>
<select className={fieldCls} value={edMonth} onChange={(e) => setEdMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="Year"
value={edYear}
onChange={(e) => setEdYear(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => startEdit(ev)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2"> <form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
+136 -59
View File
@@ -3,14 +3,19 @@
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export). // Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
import "./chart.css"; import "./chart.css";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { FanChart } from "@/components/fan-chart";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"]; type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"]; type Event = components["schemas"]["EventRead"];
type Mode = "landscape" | "portrait" | "fan";
function splitName(name: string | null | undefined): [string, string] { function splitName(name: string | null | undefined): [string, string] {
const t = (name ?? "").trim().split(/\s+/).filter(Boolean); const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
@@ -23,11 +28,16 @@ export default function TreePage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading"); const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
const [focusId, setFocusId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape");
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
@@ -40,31 +50,56 @@ export default function TreePage() {
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/relationships", { 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}/events", { params: { path: { tree_id: treeId } } }),
]); ]);
const people = p.data ?? []; if (cancelled) return;
const rels: Relationship[] = r.data ?? []; const ppl = p.data ?? [];
const events: Event[] = e.data ?? []; setPeople(ppl);
if (people.length === 0) { setRels(r.data ?? []);
if (!cancelled) setStatus("empty"); setEvents(e.data ?? []);
return; setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
} setStatus(ppl.length ? "ready" : "empty");
})().catch(() => !cancelled && setStatus("error"));
const parentsOf = (id: string) => return () => {
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id); cancelled = true;
const childrenOf = (id: string) => };
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id); }, [router, treeId]);
const partnersOf = (id: string) =>
rels const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id)) const parentsOf = useCallback(
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)); (id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
const birthYear = new Map<string, string>(); [rels],
for (const ev of events) { );
if (ev.person_id && ev.event_type === "birth" && !birthYear.has(ev.person_id)) { const childrenOf = useCallback(
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? ""; (id: string) =>
if (y) birthYear.set(ev.person_id, y); rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id),
} [rels],
);
const partnersOf = useCallback(
(id: string) =>
rels
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
[rels],
);
const years = useMemo(() => {
const m = new Map<string, string>();
for (const ev of events) {
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
if (y) m.set(ev.person_id, y);
} }
}
return m;
}, [events]);
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "Unknown", [byId]);
const yearOf = useCallback((id: string) => years.get(id) ?? "", [years]);
// family-chart for landscape/portrait. Intentionally not keyed on focusId —
// card clicks recenter via updateMainId without rebuilding the chart.
useEffect(() => {
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false;
(async () => {
const data = people.map((pp) => { const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name); const [fn, ln] = splitName(pp.primary_name);
return { return {
@@ -72,56 +107,98 @@ export default function TreePage() {
data: { data: {
"first name": fn || "Unnamed", "first name": fn || "Unnamed",
"last name": ln, "last name": ln,
birthday: birthYear.get(pp.id) ?? "", birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M", gender: pp.gender === "female" ? "F" : "M",
}, },
rels: { rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) },
spouses: partnersOf(pp.id),
parents: parentsOf(pp.id),
children: childrenOf(pp.id),
},
}; };
}); });
const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
try { containerRef.current.innerHTML = "";
const f3 = await import("family-chart"); const chart = f3.createChart(containerRef.current, data);
containerRef.current.innerHTML = ""; chart
const chart = f3.createChart(containerRef.current, data); .setCardHtml()
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]); .setCardDisplay([["first name", "last name"], ["birthday"]])
chart.updateTree({ initial: true }); .setOnCardClick((_e: unknown, d: { data?: { id?: string } }) => {
if (!cancelled) setStatus("ready"); const id = d?.data?.id;
} catch { if (id) {
if (!cancelled) setStatus("error"); setFocusId(id);
} chart.updateMainId(id);
})().catch(() => { chart.updateTree();
if (!cancelled) setStatus("error"); }
}); });
if (mode === "portrait") chart.setOrientationVertical();
else chart.setOrientationHorizontal();
if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true });
})();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router, treeId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, mode, people, rels, events]);
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
<button
onClick={() => setMode(m)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === m ? "bg-bronze text-paper" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{label}
</button>
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Tree</h1> <h1 className="text-2xl font-semibold">Tree</h1>
<span className="text-sm text-[var(--muted)]"> <div className="flex items-center gap-3">
Drag to pan · scroll to zoom · click a person to recenter <div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
</span> <ModeButton m="landscape" label="Landscape" />
<ModeButton m="portrait" label="Portrait" />
<ModeButton m="fan" label="Fan" />
</div>
{focusId && (
<Link
href={`/trees/${treeId}/persons/${focusId}`}
className="text-sm text-bronze hover:underline"
>
Open {nameOf(focusId)}
</Link>
)}
</div>
</div> </div>
{status === "empty" && ( {status === "empty" && (
<p className="text-[var(--muted)]"> <p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p>
No people yet add some under People, or import a GEDCOM.
</p>
)} )}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>} {status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
<div
ref={containerRef} {status === "ready" && mode === "fan" && focusId ? (
className="f3 rounded-xl border border-[var(--border)]" <div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
style={{ width: "100%", height: "74vh", background: "var(--surface)" }} <FanChart
/> focusId={focusId}
parentsOf={parentsOf}
nameOf={nameOf}
yearOf={yearOf}
onSelect={setFocusId}
/>
</div>
) : (
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
)}
<p className="text-sm text-[var(--muted)]">
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</p>
</div> </div>
); );
} }
+128
View File
@@ -0,0 +1,128 @@
"use client";
// Radial fan chart of a focus person's ancestors (family-chart has no fan).
// Each generation is a ring; slot p in generation g descends from slot floor(p/2)
// in g-1. Click a wedge to refocus.
type Props = {
focusId: string;
parentsOf: (id: string) => string[];
nameOf: (id: string) => string;
yearOf: (id: string) => string;
onSelect: (id: string) => void;
generations?: number;
};
const SIZE = 720;
const CENTER = SIZE / 2;
const FOCUS_R = 46;
const SPAN = Math.PI * 1.6; // 288° fan
function polar(r: number, a: number): [number, number] {
// a = 0 points up, increasing clockwise.
return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)];
}
function sector(r0: number, r1: number, a0: number, a1: number): string {
const [x0, y0] = polar(r1, a0);
const [x1, y1] = polar(r1, a1);
const [x2, y2] = polar(r0, a1);
const [x3, y3] = polar(r0, a0);
const large = a1 - a0 > Math.PI ? 1 : 0;
return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`;
}
function clip(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
export function FanChart({
focusId,
parentsOf,
nameOf,
yearOf,
onSelect,
generations = 4,
}: Props) {
const gens: (string | null)[][] = [[focusId]];
for (let g = 1; g <= generations; g++) {
const row: (string | null)[] = [];
for (const slot of gens[g - 1]) {
const ps = slot ? parentsOf(slot) : [];
row.push(ps[0] ?? null, ps[1] ?? null);
}
gens.push(row);
}
const ringT = (CENTER - 60 - FOCUS_R) / generations;
const start = -SPAN / 2;
const wedges: React.ReactNode[] = [];
for (let g = 1; g <= generations; g++) {
const row = gens[g];
const w = SPAN / row.length;
const r0 = FOCUS_R + (g - 1) * ringT;
const r1 = FOCUS_R + g * ringT;
row.forEach((id, i) => {
const a0 = start + i * w;
const a1 = start + (i + 1) * w;
const mid = (a0 + a1) / 2;
const [tx, ty] = polar((r0 + r1) / 2, mid);
let deg = (mid * 180) / Math.PI;
if (deg > 90 || deg < -90) deg += 180; // keep text upright
wedges.push(
<g
key={`${g}-${i}`}
onClick={() => id && onSelect(id)}
style={{ cursor: id ? "pointer" : "default" }}
>
<path
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
fill={id ? "var(--surface)" : "transparent"}
stroke="var(--border)"
/>
{id && (
<text
x={tx}
y={ty}
transform={`rotate(${deg} ${tx} ${ty})`}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: g >= 3 ? 9 : 11, fill: "var(--foreground)" }}
>
{clip(nameOf(id), g >= 3 ? 12 : 18)}
</text>
)}
</g>,
);
});
}
const [fx, fy] = [CENTER, CENTER];
return (
<div className="overflow-auto">
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} className="mx-auto block w-full max-w-3xl">
{wedges}
<circle cx={fx} cy={fy} r={FOCUS_R} fill="var(--color-bronze)" />
<text
x={fx}
y={fy - 4}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: 12, fill: "var(--color-paper)", fontWeight: 600 }}
>
{clip(nameOf(focusId), 12)}
</text>
<text
x={fx}
y={fy + 12}
textAnchor="middle"
dominantBaseline="middle"
style={{ fontSize: 10, fill: "var(--color-paper)" }}
>
{yearOf(focusId)}
</text>
</svg>
</div>
);
}
+112 -2
View File
@@ -243,7 +243,8 @@ export interface paths {
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"]; delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Person */
patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": { "/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
@@ -312,7 +313,8 @@ export interface paths {
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"]; delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Event */
patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/relationships": { "/api/v1/trees/{tree_id}/relationships": {
@@ -676,6 +678,27 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** EventUpdate */
EventUpdate: {
/** Event Type */
event_type?: string | null;
/** Place Id */
place_id?: string | null;
/** Date Value */
date_value?: string | null;
/** Date Start */
date_start?: string | null;
/** Date End */
date_end?: string | null;
/** Date Precision */
date_precision?: string | null;
/** Calendar */
calendar?: string | null;
/** Detail */
detail?: string | null;
/** Notes */
notes?: string | null;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -798,6 +821,20 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** PersonUpdate */
PersonUpdate: {
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Gender */
gender?: string | null;
/** Is Living */
is_living?: boolean | null;
privacy?: components["schemas"]["PersonPrivacy"] | null;
/** Notes */
notes?: string | null;
};
/** RegisterRequest */ /** RegisterRequest */
RegisterRequest: { RegisterRequest: {
/** Email */ /** Email */
@@ -1412,6 +1449,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
deleted?: boolean; deleted?: boolean;
q?: string | null;
}; };
header?: never; header?: never;
path: { path: {
@@ -1538,6 +1576,42 @@ export interface operations {
}; };
}; };
}; };
update_person_api_v1_trees__tree_id__persons__person_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PersonUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PersonRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: { restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1698,6 +1772,42 @@ export interface operations {
}; };
}; };
}; };
update_event_api_v1_trees__tree_id__events__event_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
event_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EventUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EventRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_relationships_api_v1_trees__tree_id__relationships_get: { list_relationships_api_v1_trees__tree_id__relationships_get: {
parameters: { parameters: {
query?: never; query?: never;
+317
View File
@@ -564,6 +564,22 @@
"default": false, "default": false,
"title": "Deleted" "title": "Deleted"
} }
},
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Q"
}
} }
], ],
"responses": { "responses": {
@@ -595,6 +611,67 @@
} }
}, },
"/api/v1/trees/{tree_id}/persons/{person_id}": { "/api/v1/trees/{tree_id}/persons/{person_id}": {
"patch": {
"tags": [
"persons"
],
"summary": "Update Person",
"operationId": "update_person_api_v1_trees__tree_id__persons__person_id__patch",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "person_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Person Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": { "delete": {
"tags": [ "tags": [
"persons" "persons"
@@ -900,6 +977,67 @@
} }
}, },
"/api/v1/trees/{tree_id}/events/{event_id}": { "/api/v1/trees/{tree_id}/events/{event_id}": {
"patch": {
"tags": [
"events"
],
"summary": "Update Event",
"operationId": "update_event_api_v1_trees__tree_id__events__event_id__patch",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "event_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Event Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": { "delete": {
"tags": [ "tags": [
"events" "events"
@@ -2345,6 +2483,114 @@
], ],
"title": "EventRead" "title": "EventRead"
}, },
"EventUpdate": {
"properties": {
"event_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Event Type"
},
"place_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Place Id"
},
"date_value": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Date Value"
},
"date_start": {
"anyOf": [
{
"type": "string",
"format": "date"
},
{
"type": "null"
}
],
"title": "Date Start"
},
"date_end": {
"anyOf": [
{
"type": "string",
"format": "date"
},
{
"type": "null"
}
],
"title": "Date End"
},
"date_precision": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Date Precision"
},
"calendar": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Calendar"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"title": "EventUpdate"
},
"HTTPValidationError": { "HTTPValidationError": {
"properties": { "properties": {
"detail": { "detail": {
@@ -2693,6 +2939,77 @@
], ],
"title": "PersonRead" "title": "PersonRead"
}, },
"PersonUpdate": {
"properties": {
"given": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Given"
},
"surname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Surname"
},
"gender": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Gender"
},
"is_living": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Living"
},
"privacy": {
"anyOf": [
{
"$ref": "#/components/schemas/PersonPrivacy"
},
{
"type": "null"
}
]
},
"notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"title": "PersonUpdate"
},
"RegisterRequest": { "RegisterRequest": {
"properties": { "properties": {
"email": { "email": {