5 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
15 changed files with 1237 additions and 143 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
+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.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).
@@ -56,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
+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:
+53
View File
@@ -95,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:
+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(
+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": {