Adds the public viewing surface in the UI — shareable, no-login pages backed by
the redaction-safe /api/v1/public API:
- /p/[treeId]: tree name + searchable people directory (living people show as
"Living person"; counts; links to person pages).
- /p/[treeId]/persons/[personId]: person detail — events, alternate names, and
parents/partners/children as links to other public person pages.
- app/p/layout.tsx: slim public header (logo + Sign in), no app sidebar.
- robots.ts: allow /p/, disallow the authenticated app sections.
- Trees list: a "Public page ↗" link on every non-private tree so the owner can
grab the shareable URL.
Client-rendered (same-origin fetch via Caddy). Follow-up (needs a frontend
SSR→backend base URL + a compose/env deploy step, so not auto-applied by
Watchtower): true server-rendering for SEO, a dynamic sitemap of public trees,
and per-page noindex for unlisted/site_members.
tsc clean; next build passes (both routes dynamic).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
A logged-in NON-member of a public/unlisted tree could read living people's
dates, real alternate names, and media (incl. downloading photos) through the
family-view endpoints — only the person LIST was redacted; list_events,
list_relationships, list_names, list_media gated on can_view_tree alone.
For non-members, these now delegate to the same visibility-filtered reads the
public surface uses (person_visibility-driven): living-person events/names
dropped, relationships touching a hidden person dropped, media limited to
full-visibility persons, and media download (get_media → media_content) 404s
for a redacted/unlinked person's media. Members are unchanged.
Adds list_public_relationships_for_person / list_public_media / can_view_media
to public_view_service. Test: an authed non-member sees no living-person PII
across events/names/relationships/media and can't download a living person's
file, while the owner still sees everything. Full suite: 72 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Output of a multi-agent gap analysis comparing Provenance against commercial
(Ancestry/MyHeritage/FamilySearch) and open-source (GRAMPS/Gramps Web/webtrees)
genealogy software: 15 research lenses, 580 raw features deduped into a 17-
category taxonomy, 302 features assessed against the codebase (have/partial/
planned/missing) with statuses verified against the actual code.
Includes an executive summary, per-category backlog with status/importance/
effort/phase, a quick-wins shortlist, and strategic differentiators. Statuses
reflect the repo at analysis time (before tree-visibility phases 1-3); a couple
of flagged items (e.g. the site_members tier) are already closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Adds the anonymous read surface (/api/v1/public) — the privacy-critical core.
- CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK).
- public_view_service: every projection passes through privacy.person_visibility.
persons redacted (living → "Living person", hidden dropped); relationships
only when both endpoints non-hidden; events only for FULL-visibility persons
(partnership events only when both partners full); names only for FULL
persons. Not-viewable trees raise 404 (not 403) so the surface can't probe
for private trees. Media deferred (higher-sensitivity; own pass later).
- public router: read-only directory + tree + persons/relationships/events +
person detail/names/events. Directory lists `public` to all and adds
`site_members` for authenticated callers; never lists unlisted/private.
- PublicTreeRead omits owner_id.
Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test
asserting a living person's real name, alias, and birth year appear in NO public
response while the deceased person's data does; plus private=404, unlisted
viewable-by-link-but-unlisted, site_members requires login, and directory
visibility. Full suite: 70 passed. Regenerated openapi.json + TS client.
Note: the AUTHED list endpoints still leak per-person for non-members
(pre-existing) — fixed next, separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
can_view_tree() now distinguishes anonymous vs authenticated non-members so the
four-level model is enforceable:
- public / unlisted → anyone, including anonymous (unlisted gated only by the
link, so the API must never *list* it)
- site_members → any authenticated account (denies anonymous)
- private → members only
Members (any role) always view; soft-deleted trees stay hidden from everyone.
person_visibility (living-person redaction) is unchanged.
Tests: a full can_view_tree matrix across {anonymous, logged-in non-member,
member} × {public, unlisted, site_members, private}, plus deleted-tree-hidden
and the site_members anon-vs-logged-in case. Adds `engine`/`db_session` fixtures
(refactored out of `client`) so the engine can be unit-tested directly,
including the anonymous path that has no HTTP endpoint yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
First step of the public-viewing feature (design: docs/design/tree-visibility.md).
No non-member behavior change yet — this only widens the vocabulary and UI.
- TreeVisibility gains `site_members` (any authenticated user of the instance),
giving the four-level model: public / site_members / unlisted / private.
- Alembic migration adds the enum value via an autocommit block (ALTER TYPE
ADD VALUE can't run in a transaction on older Postgres); downgrade is a no-op
since PG can't drop an enum value.
- Regenerated openapi.json + frontend TS client.
- Trees-list dropdown now offers Private / Public – Members / Unlisted / Public
with an explanatory tooltip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Tree visibility was set to private with no UI to change it — the trees list
only displayed the value as text. Add a private/unlisted/public dropdown on
each tree card that PATCHes visibility immediately (optimistic), pulled out of
the card's navigation Link so it doesn't trigger a page change. Honors the
"everything configurable / full CRUD in the UI" invariants. Living people stay
protected by the privacy engine regardless of tree visibility.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Depth was hardcoded (3 ancestors, 2 descendants). Add a controls row to set
each direction independently — a slider plus a number stepper, with an "All"
toggle per direction — applied around whoever is currently focused.
- ancestor/descendant depth held in state; effective value is a large cap
when "All" is on (the chart only renders people that exist, so the cap is
free).
- changes apply to the live chart via setAncestryDepth/setProgenyDepth +
updateTree without a full rebuild.
- fan mode (ancestors only) takes the ancestor depth via its `generations`
prop, capped at 8 to avoid the radial layout's 2^n blow-up; its descendants
control is disabled with a note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Setting a person's sex meant clicking Edit, opening a dropdown, and saving.
Replace the read-only ♂/♀ symbol next to the name with an always-visible
two-button segmented control that PATCHes immediately on click (gender-only;
backend PATCH is exclude_unset so the name/other fields are untouched).
Clicking the active sex clears it. The full edit form still offers gender for
completeness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Adding a marriage/partnership event used a plain <select> for the spouse,
which is unusable on a large tree — you can't search, only scroll. Swap it
for the existing PersonCombobox (already used by the relationship form), which
filters by name as you type. No onCreate, so it still resolves to an existing
person id, which is what the partnership-event handler requires.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
family-chart 0.9.0 stacks all of a person's spouses on one gender-determined
side, so someone with two spouses (e.g. a woman with two husbands) renders
with both spouses piled above/below her and ambiguous child lines.
Patch the library (via patch-package) so the person stays centered and their
spouses split to alternating sides — spouse 1 above, spouse 2 below, further
spouses farther out — and order each couple's children to match, so children
descend from between the correct pair without crossed lines:
- setupSpouses: keep the person centered; place spouses at alternating
offsets and recenter the cluster on the person's slot.
- sortChildrenWithSpouses: order children by spouse order (gender-independent)
to match the new spouse positions.
Adds patch-package + a postinstall hook, and COPY patches into the Dockerfile
deps stage so the patch applies during `npm ci` in CI. Verified the patch
re-applies on a clean install and the production build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Once you recenter the tree on someone, there was no quick way back to the
tree's home/default person. Add a header link (shown only when a home person
is set and you're not already on them) that recenters the chart on
home_person_id via the existing goTo() — works in landscape, portrait, and
fan modes. Labels with the home person's name for clarity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
The Tree view, People (Family) view, and person detail page each tracked
the "current person" independently, so moving between them reset you to the
home person. The detail page's "← Back to tree" link also pointed at the
People view (not the Tree) and carried no person, so it always landed on the
default person.
Make the focused person a URL-encoded concept that travels across views:
- Tree and People views read ?focus=<id> on load and mirror the focused
person back into the URL via router.replace (no history spam), so leaving
and returning keeps you centered where you were. Bookmarks/shared links
also resolve to the right person.
- "Open person" links carry ?from=tree | ?from=people.
- The detail page's back link is now origin-aware: "← Back to Tree" →
/tree?focus=<id> or "← Back to People" → /?focus=<id>, returning you in
place instead of to the home person.
- Add a "View in tree →" link on the detail page — the previously missing
direct jump from a person to the tree re-rooted on them.
- person→person relationship links (and create-relative redirect) pass
`from` through so click-chains keep their anchor.
Also gitignore *.tsbuildinfo (Next build artifact).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Root cause of the blank Jung tree: a child double-linked to the same parent
(and, generally, any cycle) made family-chart recurse forever.
Backend (the real fix):
- create_relationship now rejects an equivalent existing edge → 409.
parent_child is directional (parent→child); partnership/sibling match the
pair in either order. So you can't link the same two people the same way
twice. (GEDCOM import already deduped; manual creates didn't.)
Frontend (defense in depth so data can never blank the view):
- Tree view sanitizes the graph before rendering: dedupes parents/spouses,
drops self-links, and greedily breaks ancestor cycles (a person can't be
their own ancestor); children are derived from the kept edges. The render is
wrapped in try/catch and shows a note instead of a blank canvas, telling you
which conflicting links were skipped.
- Person page surfaces the 409 ("They're already linked that way.").
59 backend tests pass (incl. dup-rejection + reverse-parent-child allowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "People with no sex set" section to the Cleanup page — lists everyone
whose gender is still null with inline ♂ Male / ♀ Female buttons (and a link to
their page). Refreshes after the source-match and first-name guess passes, so
it's the manual mop-up for whatever those leave behind.
Frontend only (reuses person list + PATCH) — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "Guess from first name" option in the Cleanup gender section: a bundled,
curated given-name -> sex dictionary (weighted English + German for the first
real tree) proposes sex for people who don't have it set. Deterministic, offline,
no model. Genuinely ambiguous names (Marion, Frances, Jordan, …) are excluded
from both sets so they're left for a human. Reuses the existing preview/apply
gender flow, so every guess is reviewed before saving.
No migration. 56 backend tests pass; frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A new per-tree Cleanup page (and cleanup_service + endpoints), each fix
preview-first per the propose-then-approve rule:
- Mark deceased by birth year: lists people born ≤ a cutoff (default 1930) not
already deceased; apply sets is_living=false for the ones you keep checked.
- Set sex from a source GEDCOM: upload the source .ged (it carries SEX); matches
by name and proposes sex only where it's missing — far more accurate than
guessing from first names. Review, then apply.
- Names that look broken: flags date-in-surname / date-in-given / no-surname /
packed given names, with inline editable given+surname; fix the checked ones.
No migration (uses existing columns). 55 backend tests pass (preview+apply for
all three); frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A blue ♂ (male) or pink ♀ (female) symbol now follows the person's name in the
detail header, using the same gender tints as the tree cards. Nothing shows when
sex is unknown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Theme is now class-based (.dark on <html>) with a System/Light/Dark toggle in
the sidebar, persisted to localStorage and applied pre-paint by an inline
script (no flash). Replaces the prefers-color-scheme-only behavior, so a phone
on a light OS theme can still choose dark and vice versa.
- New brand-derived --line token (Ink at 55%): a dark line on the light paper,
light on dark. The family-chart tree connectors had the library's default
white stroke and were invisible in light mode — now they use --line, as do
the pedigree brackets and the fan-chart sectors.
- Light/dark tokens use the exact brand palette (Ink/Muted flip; Bronze/Paper
constant).
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Family view gets a prominent "+ Add person" button that creates a person and
opens their page to fill in details (previously you could only add a person
via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
"+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
links them in the chosen role (parent/child/partner/sibling), and jumps to
their new page to edit — no more create-then-go-back-and-link.
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New account_service + endpoints under /users/me:
- GET /me/export — zip of every owned tree (account.json + media blobs).
- POST /me/import — restore a backup into NEW trees (ids remapped, media
re-uploaded); non-destructive, never touches existing data.
- DELETE /me — soft-delete the user, their owned trees, and revoke sessions;
guarded by retyping the account email.
Settings page wires all three (export download, restore upload, delete with
typed-email confirmation). No migration — uses existing tables + soft-delete.
52 backend tests pass (export→restore round-trip + delete guards); frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Media model already carried person_id/event_id/source_id and the upload
route already accepted person_id — this surfaces it in the UI:
- Person page: a Media card lists media linked to that person, uploads new
files already linked ("Upload & link"), links existing unlinked media, and
unlinks.
- Media page: each item gets a person picker to link/unlink.
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Partnership life events (marriage/divorce/engagement) now attach to the
couple's relationship, not each person. The add-event form asks for the
spouse, finds-or-creates the partnership, and writes ONE event on it — shown
on both partners' pages ("· with <spouse>"), entered once. Event values
(RELI/OCCU detail) now render too.
- Family-view pedigree orders parents deterministically (father on top, mother
below, stable fallback when gender is unknown) instead of by which link was
created first.
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with
Settings and Sign out. New /settings page: account info + change password
(POST /auth/change-password, re-verifies current password). Export/restore/
delete are stubbed there for the next pass.
- Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/
Read; the tree and family views open focused on it; the person page gets a
"Set as default" control and "Default person" badge. Cleared if that person
is deleted. Complements the account-level "this is me" link.
- Tree visualization now fills the content area (AppShell drops the max-width
column on the /tree route); other pages stay centered.
- Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields
like home_person_id audits cleanly.
50 backend tests pass; migration up/down verified; frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So a deploy never needs a manual `alembic upgrade head`:
- Backend image gains an entrypoint that runs `alembic upgrade head` before
uvicorn when RUN_MIGRATIONS=1 (set on the backend service). This self-migrates
even on a Watchtower in-place image swap, which doesn't re-run one-shot jobs.
- A one-shot `migrate` service covers the `docker compose up` path; backend and
worker depend on it completing, which also serializes it with the backend
entrypoint so alembic never runs concurrently. `upgrade head` is idempotent.
Activating this needs the updated compose on the host once (Watchtower only
swaps images, not the compose file / env). After that, migrations are automatic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>