Commit Graph

48 Commits

Author SHA1 Message Date
justin a6179037c2 Close citation/source living-person leak; add on-demand tree purge
Two changes.

1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only
   on can_view_tree, so a non-member on a public/unlisted/site_members tree could
   enumerate citations and sources tied to a redacted living person, leaking that
   the person exists and has sourced facts (and possibly their name via a source
   title). #46 closed this for events/media/names/relationships but not
   citations/sources. Now citation_service.list_citations and
   source_service.{list_sources,get_source} delegate non-member reads to
   public_view_service, mirroring the #46 pattern:
   - citations: shown only when the cited fact resolves to FULL-visibility
     person(s) — covers the person_id, name_id, event_id (person or both-partner),
     and relationship_id (both-partner) target paths.
   - sources: shown only when they back at least one visible citation; a withheld
     source 404s (don't reveal it exists).
   Tests cover all four citation target types + source withholding + member-sees-all.

2. On-demand tree purge — owners can permanently delete a soft-deleted tree now
   instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge
   (owner-only): the tree must already be in the trash, and the caller retypes its
   name to confirm. Media objects are deleted from storage, then a single
   DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE;
   the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever"
   button to the Recently-deleted list. No migration.

Suite: 102 passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-10 22:38:59 -04:00
justin c5631d3eab Add an instance owner/operator role (env-declared via OWNER_EMAIL)
Provenance had no system-level owner: ownership was only per-tree
(TreeMembership), so a self-hosted instance had no operator account and no
instance-admin surface. This adds one, declared by environment per the project's
twelve-factor rule.

- OWNER_EMAIL (comma-separated): the account(s) named here are instance owners.
  Derived at request time — no DB column, no migration, can't drift from the env,
  survives DB resets. is_instance_owner()/InstanceOwner dependency in api/deps.py.
- Ownership requires a VERIFIED email (independent of REQUIRE_EMAIL_VERIFICATION).
  Registration is open, so without this an attacker could seize the role by
  registering the owner address first; verification ties it to inbox control.
- GET /api/v1/admin/instance (owner-only): operational status — version, env,
  user/tree counts, configured AI providers. Deliberately exposes no tree data
  or PII: instance ownership is an operator role, NOT a privacy-engine bypass.
- /users/me reports is_instance_owner; frontend gains an owner-only /admin page
  and a conditional sidebar link (server-enforced, not just client-hidden).

Found-and-fixed by an adversarial security review before merge: the
verified-email land-grab (above) and a frontend null-deref where the admin page
crashed on 401/5xx instead of failing closed.

Docs: .env.example + ARCHITECTURE (notes the not-a-privacy-bypass boundary and
the verified-email requirement). Tests: owner matching, the land-grab guard,
/users/me, and owner-only /admin. Suite 96 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 23:16:45 -04:00
justin c6b1e72130 Per-tree AI model policy (owner-only admin view)
The operator decides which model providers exist (env / registry — Anthropic,
OpenAI, x.AI, Ollama, several at once). The *tree owner* decides who uses which:

- Members' assistant -> one configured provider (or none)
- Recommender (association/connection finder) -> one configured provider (or none)
- Owner -> may use any configured provider

Backend: two nullable columns on `trees` (ai_member_provider,
ai_recommender_provider) + migration; `configured_llm_providers()` exposes the
registry as {name, model} with no secrets; owner-gated GET/PATCH
/trees/{id}/ai validate names against the configured set. Frontend: owner-only
"AI models" page with a dropdown per role, graceful 403 for non-owners, and a
sidebar link.

Per-model-within-a-provider selection is a follow-up; today each provider maps
to its single configured model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 20:52:30 -04:00
justin abaa8efdd5 Fix #214: ChangeProposal (propose-then-confirm)
Implements non-negotiable #1: the AI assistant never writes autonomously. Every
assistant/contributor "write" emits a ChangeProposal — a structured diff a human
approves, edits, or rejects. Design: docs/design/change-proposal.md.

Structural guarantee: a proposal's operations reach the DB ONLY via
change_proposal_service.apply(), which requires the actor be an editor and
dispatches each op through the normal editing services (person/name/event/
relationship/source/citation create/update/delete) — so every change passes the
privacy engine and is audited as the approving human. propose() only inserts a
pending row; it performs no domain mutation. Model providers stay read-only, so
no model response can mutate tree data.

- ChangeProposal model + migration (status pending|applied|rejected, origin
  assistant|contributor, JSONB operations, reviewer + apply_error).
- Service: propose / list / get / apply (with optional edited ops) / reject /
  delete; a dispatcher mapping ops → editing services. v1 applies ops in order,
  not cross-op transactional (single-op is atomic; documented).
- API /trees/{id}/proposals + a frontend review page (approve/reject; editor-
  gated) and sidebar entry.

Tests: proposal doesn't apply until approved; reject doesn't apply; non-editor
member can see but not apply; multi-op; approve-with-edits; apply-error keeps it
pending. Full suite 87 passed; single alembic head.

Closes #214

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 15:44:40 -04:00
justin eb0350733b Fix #145: tree membership management (list / add / role / remove)
TreeMembership was enforced on every read/write but had no API or UI to manage
members — trees were effectively single-user, breaking full-CRUD (NN#8).

Backend (/trees/{id}/members): list (members only — the list exposes emails, so
non-members never see it, even on public trees); add an existing user by email
(owner only, 404 if no such account, 409 if already a member); PATCH role;
DELETE. A tree must always keep ≥1 owner (demote/remove of the sole owner → 409).
All changes audited.

Frontend: a Members page (owner gets add-by-email + per-member role select +
remove; others see a read-only list) and a sidebar entry.

Test covers the full lifecycle + every guard. Suite 77 passed.

Closes #145

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 12:43:30 -04:00
justin 05d2773e25 Cleanup: infer a missing sex from a known-sex spouse (preview → approve)
Unset sex renders blue (male-colored), which is misleading next to a confirmed
male partner. Add a Cleanup action that proposes the opposite sex for an unset
partner of someone whose sex is set (couples are opposite-sex in practice — a
confirmed-male husband ⇒ a female wife). People whose known partners disagree
are skipped as ambiguous.

It's a preview the user reviews and approves in the Cleanup tool (reusing the
existing gender apply path + audit) — not an autonomous write. Backend:
guess_gender_by_spouse + GET /cleanup/gender/from-spouse. Frontend: an "Infer
from spouse" button feeding the existing proposal list. Test covers
propose-opposite, skip-no-partner, skip-already-set, apply, and re-preview.

Full suite 73 passed; frontend build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:59:08 -04:00
justin 7d6fbce87e Public tree view: add generation depth controls (shared with member view)
The public tree chart was fixed at 3 ancestors / 2 descendants. Add the same
Generations controls the member view has (slider + number stepper + "All" per
direction), applied live around the focused person.

Extracts the member page's inline DepthControl into a shared
components/depth-control.tsx and uses it in both, so they stay in sync. The
public chart gains anc/prog depth state + an apply effect (setAncestryDepth/
setProgenyDepth + updateTree) mirroring the member behavior.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:35:43 -04:00
justin 150d69e5ac Public tree view: full-width canvas like the member view
The public layout forced max-w-5xl on every /p page, so the tree chart was
cramped. Mirror the member shell: the public layout now drops the max-width for
the tree page (/p/<id>) only, giving the chart the full canvas (74vh to match
the member view), while the page keeps its heading and people list in a
centered max-w-5xl column. Person detail (/p/<id>/persons/<pid>) and /explore
stay narrow.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:29:18 -04:00
justin 269cae556f Public view: add tree chart + homepage Explore links
Two gaps from review of the public surface:
- The public tree page showed only a list of names. Add the family-chart
  hourglass (PublicTreeChart) above the directory — the same renderer the
  member tree view uses, including the cycle-sanitisation that guards against a
  bad graph, fed by redacted public data. Click a card to recenter; "Open"
  links to the person's public page. Centers on the tree's home person.
- The homepage had no path to /explore. Add an "Explore" nav link and an
  "Explore public trees" hero button.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:44:23 -04:00
justin 7a5c5f2882 Visibility phase 5: public /explore directory + search
A no-login directory of shared trees, backed by GET /api/v1/public/trees:
- /explore: searchable grid of public trees; debounced name search. Because the
  backend adds `site_members` trees when a valid session is present, signed-in
  users see more with no client-side branching.
- PublicHeader extracted and shared by /p and /explore (logo, Explore, Sign in).
- "Explore" entry added to the authed sidebar.

tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:34:20 -04:00
justin b8405ced07 Visibility phase 4: no-login public viewer pages + robots
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>
2026-06-09 09:31:56 -04:00
justin 9820a77d25 Visibility phase 3: redaction-safe public read API + leak test
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>
2026-06-09 09:17:41 -04:00
justin 4a3fe983fa Visibility phase 1: add site_members value + 4-option dropdown
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>
2026-06-09 08:54:45 -04:00
justin dc1b6aac01 Trees list: inline visibility selector (private/unlisted/public)
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>
2026-06-09 08:36:20 -04:00
justin c86771034c Tree view: configurable generation depth (ancestors/descendants + All)
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>
2026-06-08 22:20:07 -04:00
justin 93c22b4bcf Person page: one-click sex setter (no edit mode)
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>
2026-06-08 21:42:59 -04:00
justin 62513ee22e Person page: make marriage-event spouse picker searchable
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>
2026-06-08 21:29:27 -04:00
justin 182a5dab16 Tree view: center a person between multiple spouses
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>
2026-06-08 19:56:17 -04:00
justin fe1e0171ff Tree view: add "Back to default person" recenter link
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>
2026-06-08 15:15:39 -04:00
justin c5a2a7f0d4 Preserve focused person across tree/people/detail navigation
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>
2026-06-08 14:48:32 -04:00
justin fae1162ff8 Prevent duplicate relationships; harden tree render against bad graphs
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>
2026-06-08 11:35:11 -04:00
justin a53858f920 Cleanup: list people with no sex set + inline set
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>
2026-06-08 10:43:08 -04:00
justin 6ec852a23a Cleanup: best-guess sex from first name (offline dictionary)
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>
2026-06-08 10:30:35 -04:00
justin aa62ca490e Tree Cleanup tool: bulk fixes with preview → approve
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>
2026-06-08 10:17:01 -04:00
justin cd4ccb4ac8 Show a sex symbol after the name on the person page
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>
2026-06-08 09:16:38 -04:00
justin e8839b15a0 Full light/dark theme toggle; brand-aware connector lines
- 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>
2026-06-07 11:48:59 -04:00
justin 37ac49767e Make creating a person obvious; inline "create new" when linking relatives
- 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>
2026-06-07 11:30:14 -04:00
justin e9b2436ce0 Account export / restore-into-new-tree / delete
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>
2026-06-07 11:26:04 -04:00
justin d27cc5dddc Link media to people (person page + media page)
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>
2026-06-07 11:19:25 -04:00
justin 5106538934 Shared marriage events; deterministic parent ordering
- 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>
2026-06-07 11:15:54 -04:00
justin 0262ed3d97 Account menu + Settings (change password); per-tree home person; full-width tree
- 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>
2026-06-07 11:05:04 -04:00
justin 1164841950 Global Import in the menu; mobile drawer nav
- Add a top-level "Import" entry to the sidebar and a global /import page, so
  you can start a tree from a GEDCOM without first creating an empty one. The
  import flow now picks its destination (new tree, or an existing one) — the
  tree-scoped page reuses the same <GedcomImport> with a fixed destination and
  keeps Export.
- Extract the sidebar chrome into <AppShell> and give small screens a working
  menu: a hamburger opens the full sidebar as a slide-in drawer (it was just a
  logo + "Trees" link before). Used by both /trees and /import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:40:01 -04:00
justin 5824e70895 GEDCOM: duplicate-aware import + typed name/attribute mapping
Duplicate detection (the "merge / skip / overwrite" the user asked for):
- New POST /gedcom/preview dry-runs the file and flags incoming people that
  resemble existing ones (name similarity via difflib + birth-year guard;
  high/medium score). No writes.
- /gedcom/import takes default_action (new|skip|merge|overwrite) + per-xref
  resolutions {xref: {action, target_id}}:
    new       create as a new person (current behavior)
    skip      link families to the existing person, copy nothing
    merge     attach the incoming names (as alternates), events, citations,
              and notes onto the existing person
    overwrite soft-delete the existing person, import the incoming one fresh
  Relationship creation is deduped so a merge can't double an edge.

Richer record mapping (covers the user's repo's GEDCOM):
- Multiple NAME records honor their TYPE; _MARNM (and NICK) import as typed
  alternate names — maiden stays primary, married becomes a "married" Name.
- RELI -> a "religion" event with the value in detail; OCCU/EDUC values too.
- NOTE -> person notes (and event notes); NOTE/RELI are no longer "unmapped".
- Export round-trips name TYPE.

Verified against the user's 2185-person export: 0 unmapped tags. 48 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:35:55 -04:00
justin 04ccdbf96a Alternate names (maiden/married), self-person link, deletion integrity
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
  NameCreate/Read/Update schemas, name_service (one-primary invariant,
  promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
  curated name_type dropdown (birth/maiden, married, alias, nickname, …).

Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
  + migration; PATCH /users/me/self-person; "This is me" / "This is you"
  on the person page. Soft-deleting the linked person clears it.

Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
  dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
  the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.

Family view surfaces "Not connected to anyone" so dangling people aren't lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:21:12 -04:00
justin f165ccb941 Tree search + click-rebuild; searchable relationship picker; gender dropdown
- Tree page: add a "Find a person" search box that jumps the chart to a
  match and rebuilds the hourglass (parents/grandparents/partner/children)
  around them. Clicking any card recenters via family-chart's default
  behavior (setAncestryDepth 3 / setProgenyDepth 2), syncing focus through
  setAfterUpdate for the "Open profile" link.
- Person detail: replace the relationship "add" <select> with a
  type-to-filter PersonCombobox so long people lists are searchable.
- Person detail: gender is now a Male/Female dropdown, not free text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:58:45 -04:00
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 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 bfa6c0782a Add an interactive Tree view (pan/zoom genealogy chart)
Researched how FamilySearch/Geni/MyHeritage lay out trees (switchable pedigree/portrait/fan, an interactive canvas with pan/zoom + click-to-recenter, gender colors, birth-death years) and built a real Tree page on the MIT d3 library family-chart instead of a flat list. Ancestors + descendants around a focus person, click any card to recenter, drag to pan, scroll to zoom — scales to large imported trees. Tree is now the first per-tree sidebar item and the default when opening a tree; People keeps the searchable directory + add/edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 23:07:02 -04:00
justin f6bcf198ee Make the people index a scalable scrollable directory
A flat wrap of every person didn't scale to imported trees. Replace it with a bounded (max-height, scrollable) searchable directory: clean name + birth–death-year rows, focus highlight, a result count, and a 200-row cap with a 'refine your search' notice so a thousand-person tree stays fast and usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:54:08 -04:00
justin 631d050540 Add GEDCOM Import/Export UI (defaults to importing into a new tree)
An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin fe9a95c60d Rebuild the UI as an app shell: left sidebar, media gallery, structured events
Replaces the centered single-column of full-width cards with a proper application layout: a persistent left sidebar (Trees, and per-tree People/Sources/Media, with the tree name and sign-out) and a constrained content column. Marketing landing and auth pages are split out (own header/footer; centered auth with the logo).

Adds a Media gallery (upload + image thumbnails / file tiles, served via the backend content endpoint). Events are no longer free-text: a curated event-type list (+ custom) and a structured date (qualifier + day/month/year) that composes a proper genealogical date. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:05 -04:00
justin 3a14fcc4ca Redesign the frontend: real type, hero landing, depth
Lifts the UI from wireframe to a finished heritage look: Fraunces (display serif) + Inter (sans) via next/font; a proper hero landing with a feature triad and the Origin mark; a warm bronze-tinted background gradient for depth; a sticky branded header and refined footer. Polished button (sizes + bronze focus ring + shadow), card (rounded-xl, soft layered shadow), and input (bronze focus) primitives that carry across every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:34:47 -04:00
justin 83f83ab641 Add source manager and inline citing with 'sourced' badges
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin 1f25eb2f21 Add person-detail page with events timeline and relationships
New /trees/[id]/persons/[personId] view: life-events timeline with add/remove, and relationships grouped into parents/children/partners/siblings with an add form (kind + person picker + qualifier). People in the tree list now link here. Regenerated the OpenAPI client for the new endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 12:10:56 -04:00
justin b8f5c35045 Apply brand identity to the frontend (ink + bronze + paper)
Replaces the default black/gray with the docs/brand palette: warm ink text on paper surfaces, bronze accent, serif headings and the Origin-mark wordmark in the header, favicon, and the 'where it came from matters' tagline. Light/dark adapt via CSS vars (ink/paper flip); bronze and paper are constant. Tailwind v4 @theme exposes bronze/paper/ink tokens and the serif stack. Buttons/inputs/cards restyled to match; brand SVGs vendored into public/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:49:58 -04:00
justin a5a79f01a7 Scaffold Next.js frontend with generated OpenAPI client and core views
Next.js (App Router) + React 19 + TypeScript + Tailwind v4, with shadcn-style UI primitives (Button, Input, Card, Label via cva/tailwind-merge). A typed API client is generated from the backend OpenAPI spec with openapi-typescript + openapi-fetch (npm run gen:api); the committed openapi.json/schema.d.ts are the snapshot.

Views: landing, login, register, tree list + create, and tree detail with person list + create. Auth rides the same-origin HttpOnly session cookie the backend sets (Caddy proxies /api/*), so no token handling in JS. Built as a standalone container. Mobile-first; next build is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 11:03:07 -04:00