Files
familienarchiv/docs/superpowers/specs/2026-04-27-stammbaum-design.md
Marcel 9fb2c025cf
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
docs: add Stammbaum feature design spec
Covers: person_relationships table, family_member flag,
RelationshipInferenceService (BFS path-to-label), /stammbaum
SVG page (generational + D3-Force toggle), relationship badge
on document detail, relationship editor on person edit page,
and nav swap Briefwechsel → Stammbaum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:57:15 +02:00

18 KiB
Raw Blame History

Stammbaum — Design Spec

Date: 2026-04-27
Status: Approved for implementation
Replaces: /briefwechsel nav item


1. Problem & Motivation

Three converging user signals:

  1. "How are these two related?" — Users reading a letter see sender and receiver and want to know their family relationship before reading (uncle writing to niece, son writing to mother). The app currently has no way to express this.
  2. Dead Briefwechsel page — The bilateral correspondence filter duplicates the home search. Users don't understand when to use it. The nav slot is wasted.
  3. Differentiation from paperlessNGX — The second unique feature after collaborative Kurrent transcription. A family-aware relationship graph with automatic inference is something a generic document archive cannot offer.

2. Feature Overview

Replace /briefwechsel in the nav with /stammbaum. The feature has four interlocking parts:

Part Where What it does
A — Data model PostgreSQL family_member flag + person_relationships table
B — Stammbaum page /stammbaum SVG family tree (generational layout); toggle to social network view
C — Relationship badge Document detail page Shows computed relationship between sender and receiver
D — Relationship editor Person edit page Toggle family_member, add/remove direct relationships

3. Data Model

3.1 Migration: V54__add_family_network.sql

-- Mark persons as family members (shown in the Stammbaum)
ALTER TABLE persons
    ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;

-- All direct relationships between persons
CREATE TABLE person_relationships (
    id                UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    person_id         UUID        NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
    related_person_id UUID        NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
    relation_type     VARCHAR(30) NOT NULL,
    from_year         INTEGER,
    to_year           INTEGER,
    notes             TEXT,
    created_at        TIMESTAMP   NOT NULL DEFAULT NOW(),
    CONSTRAINT no_self_rel    CHECK (person_id != related_person_id),
    CONSTRAINT unique_rel     UNIQUE (person_id, related_person_id, relation_type)
);

CREATE INDEX idx_person_rel_person  ON person_relationships(person_id);
CREATE INDEX idx_person_rel_related ON person_relationships(related_person_id);

3.2 Relationship types

Only direct, first-degree relationships are stored. All others are derived at query time.

Family (stored):

Type Meaning Symmetric?
PARENT_OF person_id is parent of related_person_id No — inverse is inferred as CHILD_OF
SPOUSE_OF Marriage or partnership Yes — one row per couple (either direction accepted, query both)
SIBLING_OF Explicit sibling link, only when parents are not in the archive Yes

Social (stored):

Type Meaning
FRIEND Personal friendship
COLLEAGUE Work colleague
EMPLOYER person_id employed related_person_id
DOCTOR person_id was doctor of related_person_id
NEIGHBOR Neighbors
OTHER Free-form, use notes for details

Derived (never stored, computed by RelationshipInferenceService): Grandparent, grandchild, uncle/aunt, niece/nephew, cousin, great-grandparent, in-laws, step-relations inferred from marriage chains.

3.3 Directionality rule

PARENT_OF is always stored from the parent's perspective (person_id = parent). The inverse label "CHILD_OF" is computed in the application, never stored as a separate row. For symmetric types (SPOUSE_OF, SIBLING_OF, FRIEND, COLLEAGUE, NEIGHBOR) either direction is acceptable; queries check both.


4. Backend

4.1 New entity: PersonRelationship

@Entity @Table(name = "person_relationships")
@Data @NoArgsConstructor @AllArgsConstructor @Builder
public class PersonRelationship {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    @Schema(requiredMode = REQUIRED)
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "person_id", nullable = false)
    private Person person;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "related_person_id", nullable = false)
    private Person relatedPerson;

    @Column(name = "relation_type", nullable = false)
    @Schema(requiredMode = REQUIRED)
    private String relationType;

    private Integer fromYear;
    private Integer toYear;
    private String notes;

    @CreationTimestamp
    @Schema(requiredMode = REQUIRED)
    private Instant createdAt;
}

4.2 New DTOs

// Request: add a relationship
public record CreateRelationshipRequest(
    UUID relatedPersonId,
    String relationType,      // validated against allowed enum values
    Integer fromYear,
    Integer toYear,
    String notes
) {}

// Response: a relationship edge
public record RelationshipDTO(
    UUID id,
    UUID personId,
    String personDisplayName,
    UUID relatedPersonId,
    String relatedPersonDisplayName,
    String relationType,
    Integer fromYear,
    Integer toYear,
    String notes
) {}

// Response: the full network graph
public record NetworkDTO(
    List<PersonSummaryDTO> nodes,     // reuses existing PersonSummaryDTO
    List<RelationshipDTO> edges
) {}

// Response: computed relationship between two persons
public record InferredRelationshipDTO(
    String labelFromA,    // e.g. "Onkel"
    String labelFromB,    // e.g. "Nichte"
    List<String> path     // e.g. ["SIBLING_OF", "PARENT_OF"] — for tooltip
) {}

4.3 New API endpoints

GET  /api/network
     ?family=true               → only nodes where family_member = true
     → NetworkDTO               (nodes + edges; used by Stammbaum page on load)

GET  /api/persons/{id}/relationships
     → List<RelationshipDTO>    (stored direct relationships only)

GET  /api/persons/{id}/inferred-relationships
     → List<InferredRelationshipWithPersonDTO>
     (computed from all family members; used for the read-only "Abgeleitete Beziehungen"
      section in the editor and the "Beziehungen" card on the person detail page)

POST /api/persons/{id}/relationships
     body: CreateRelationshipRequest
     → RelationshipDTO
     @RequirePermission(WRITE_ALL)

DELETE /api/persons/{id}/relationships/{relId}
     @RequirePermission(WRITE_ALL)

PATCH /api/persons/{id}/family-member
     body: { "familyMember": true/false }
     @RequirePermission(WRITE_ALL)

GET  /api/persons/{aId}/relationship-to/{bId}
     → InferredRelationshipDTO | 404 if no path found
     (called by Document detail page when both sender and receiver are family members)

Add to DTOs:

// Used by /inferred-relationships — pairs the computed label with the related person
public record InferredRelationshipWithPersonDTO(
    UUID relatedPersonId,
    String relatedPersonDisplayName,
    String labelFromA,   // e.g. "Onkel/Tante"
    String labelFromB,   // e.g. "Nichte/Neffe"
    List<String> path    // edge sequence, for tooltip
) {}

4.4 RelationshipInferenceService

Runs BFS on an in-memory graph built from all PARENT_OF, SPOUSE_OF, and SIBLING_OF edges among family members. Graph has at most ~30 nodes — single-millisecond traversal.

@Service
@RequiredArgsConstructor
public class RelationshipInferenceService {

    private final PersonRelationshipRepository relationshipRepository;

    /**
     * Compute the relationship between personA and personB.
     * Returns empty if no path exists within MAX_DEPTH hops.
     */
    public Optional<InferredRelationshipDTO> infer(UUID personAId, UUID personBId) {
        // 1. Load all family edges once (cached per request via @RequestScope or loaded inline)
        // 2. BFS from personAId, tracking the edge sequence
        // 3. When personBId is reached, map path to labels using RELATIONSHIP_LABELS table
        // 4. Return labels from both perspectives
    }

    private static final int MAX_DEPTH = 8; // great-great-grandparent = 4 hops up + variation

    // Path → label mapping (A's label, B's label)
    // Path is a list of edge types as traversed FROM A TO B
    private static final Map<List<String>, String[]> LABEL_MAP = Map.of(
        List.of("PARENT_OF"),                          new String[]{"Elternteil", "Kind"},
        List.of("PARENT_OF", "PARENT_OF"),             new String[]{"Großelternteil", "Enkelkind"},
        List.of("PARENT_OF", "PARENT_OF", "PARENT_OF"),new String[]{"Urgroßelternteil", "Urenkelkind"},
        List.of("SPOUSE_OF"),                          new String[]{"Ehegatte", "Ehegatte"},
        List.of("SIBLING_OF"),                         new String[]{"Geschwister", "Geschwister"},
        List.of("PARENT_OF", "SIBLING_OF"),            new String[]{"Onkel/Tante", "Nichte/Neffe"},
        List.of("SIBLING_OF", "PARENT_OF"),            new String[]{"Nichte/Neffe", "Onkel/Tante"},
        List.of("PARENT_OF", "PARENT_OF", "SIBLING_OF"),new String[]{"Großonkel/-tante", "Großnichte/-neffe"},
        List.of("PARENT_OF", "SPOUSE_OF"),             new String[]{"Schwiegereltern", "Schwiegerkind"},
        List.of("SIBLING_OF", "SPOUSE_OF"),            new String[]{"Schwager/Schwägerin", "Schwager/Schwägerin"}
        // Extended in implementation: cousins, half-siblings via same parent, etc.
    );
}

Sibling derivation: Two persons are siblings if they share at least one PARENT_OF edge pointing to the same parent node. The BFS traverses this without a stored SIBLING_OF edge. Explicit SIBLING_OF edges are only used when parents are not in the archive.


5. Stammbaum Page (/stammbaum)

5.1 Route

frontend/src/routes/stammbaum/+page.svelte and +page.server.ts.

The server load function calls GET /api/network?family=true and returns the full NetworkDTO. The SVG is rendered entirely client-side (no SSR for the graph — it needs browser dimensions).

5.2 Layout: Mode A — Family Tree (primary)

Algorithm: Custom generational layout — no external library needed for ≤30 nodes.

1. Find root nodes (persons with no PARENT_OF edge pointing to them = no parents in the archive)
2. BFS from roots, assigning generation depth to each node
3. For each generation, collect all persons and group them by family unit
   (couple = adjacent, children below the midpoint of their parents' x-positions)
4. Compute x-positions within each generation using a simple spacing algorithm
5. Render as SVG: rect nodes + path connectors (cubic bezier for parent-child, 
   horizontal line for couples)

Visual conventions:

  • Horizontal rows = generations, labeled "Generation I", "Generation II", etc.
  • Navy border nodes = family members
  • Couple pairs connected by a horizontal line with a midpoint dot
  • Parent-to-children: vertical drop from midpoint, then horizontal, then vertical to each child
  • Selected node: filled navy, white text; side panel opens on the right
  • Nodes link to /persons/{id} on click (or open the side panel — user clicks the "Zur Personenseite →" button in the panel to navigate)

Side panel (shown on node click):

  • Person name, birth/death year
  • Document count + transcribed count (from existing PersonStatsDTO)
  • Direct relationships listed
  • Derived relationships (top 5)
  • "Zur Personenseite →" button
  • "Beziehung hinzufügen" button (opens person edit page)

5.3 Layout: Mode B — Social Network (nice-to-have, secondary)

Toggle pill in the top bar switches to D3-Force layout showing all 180 persons.

Implementation: Import only d3-force (not all of D3, ~8 kb gzipped). Svelte handles SVG rendering; D3 only provides the position simulation.

Visual conventions:

  • Family member nodes: large circles, navy fill/stroke
  • Contact/institution nodes: small circles, grey
  • Family edges: solid navy lines
  • Social edges: dashed mint lines
  • Force parameters: strong attraction between family members (they cluster in the center)

Toggle state is not persisted — always opens in Mode A.

5.4 Top bar controls

[Stammbaum | Alle Verbindungen]    [] [100%] [+] [⊡ Reset]

Pan and zoom: native SVG viewBox manipulation on drag + wheel events (no external library).


6. Relationship Editor (Person Edit Page)

Add a new card to /persons/{id}/edit below the existing form cards.

6.1 "Stammbaum" card

Toggle: "Als Familienmitglied markieren" — calls PATCH /api/persons/{id}/family-member on change.

Direct relationships list: Each row shows [pill: type] [person name] [year range if set] [✕ delete]. Delete calls DELETE /api/persons/{id}/relationships/{relId}.

Add relationship form (inline, always visible):

[Dropdown: relation type] [Typeahead: person name] [+ Hinzufügen]

The person typeahead uses the existing PersonTypeahead component. On submit, calls POST /api/persons/{id}/relationships.

Derived relationships section (read-only): Collapsed by default, expandable. Shows the automatically computed relationships as a comma-separated list. Calls GET /api/persons/{id}/inferred-relationships (separate from the stored-relationships endpoint; computed server-side by RelationshipInferenceService).


7. Document Detail Page — Relationship Badge

Trigger: Both sender and receiver exist and both have family_member = true.

Implementation: The +page.server.ts for the document detail page calls GET /api/persons/{senderId}/relationship-to/{receiverId} when both conditions are met. The result is passed to the page as data.inferredRelationship.

Badge rendering (in the existing document topbar area, below sender/receiver metadata):

{#if data.inferredRelationship}
  <div class="rel-badge">
    <!-- icon -->
    <span>{data.document.sender.displayName}</span>
    <span class="rel-computed">{data.inferredRelationship.labelFromA}</span>
    <span class="rel-sep">·</span>
    <span class="rel-computed">{data.inferredRelationship.labelFromB}</span>
    <span>{data.document.receivers[0].displayName}</span>
  </div>
{/if}

If no path is found (404 from the API), no badge is shown — no error state, just silent omission.


8. Person Detail Page Changes

The existing "Korrespondenzen" / co-correspondents section is extended:

  • New "Beziehungen" card above the document list: shows direct relationships (from GET /api/persons/{id}/relationships) plus top computed relationships.
  • The bilateral correspondence view currently accessible only via /briefwechsel is surfaced here as a "Briefwechsel mit [person]" link per co-correspondent entry — clicking navigates to /briefwechsel?senderId={current}&receiverId={other}. The /briefwechsel route itself remains in the codebase but is removed from the nav.

9. Nav Change

frontend/src/routes/AppNav.svelte:

  • Replace /briefwechsel link with /stammbaum
  • Label: {m.nav_stammbaum()} — new Paraglide key in de.json / en.json / es.json
  • /briefwechsel route remains accessible via direct URL (linked from Person detail page); it is not deleted

10. Non-Functional Requirements

Category Requirement
Performance RelationshipInferenceService.infer() must complete in < 50 ms for any pair (graph is ≤ 30 family nodes)
Performance GET /api/network?family=true must complete in < 200 ms
Performance D3-Force simulation with 180 nodes must reach stable layout in < 2 s on a mid-range laptop
Accessibility Stammbaum SVG nodes must have role="button" and aria-label="{name}, {birth}{death}"
Accessibility Side panel must be keyboard-reachable (focus moves to panel on node activation via keyboard)
Responsive Stammbaum page: minimum supported width 768 px (tablet). Mobile is out of scope for this authoring surface — consistent with the transcriber persona device split
Security Relationship CRUD requires WRITE_ALL permission. Reading relationships requires READ_ALL
i18n Relationship type labels (Elternteil, Ehegatte, etc.) are translation keys, not hardcoded strings

11. Out of Scope

  • Gender-specific labels (Vater/Mutter, Onkel/Tante split by gender) — Person has no gender field; gender-neutral labels are used throughout
  • Drag-to-create relationships on the graph canvas
  • Print / export of the tree as image or PDF
  • Pan/zoom beyond browser native (can be added later as an enhancement)
  • Public/embeddable tree view (archive is family-only; no public sharing)
  • GEDCOM import/export

12. Open Questions

ID Question Impact
OQ-01 Should SIBLING_OF be auto-created when two PARENT_OF edges share the same parent, or only stored explicitly? Resolved: Never auto-stored. Siblings derived from shared parents by BFS. SIBLING_OF stored only when parents are absent from the archive (see §4.4).
OQ-02 What is the display label when the path is longer than 4 hops (e.g., second cousin once removed)? Suggest: "Verwandte" as fallback beyond 4 hops Badge UX
OQ-03 Should the relationship badge appear for social relationships too (e.g., sender is COLLEAGUE of receiver)? Badge scope
OQ-04 Does the D3-Force (Mode B) need to be in the initial release, or can it ship as a follow-up? Scope, effort

13. Acceptance Criteria

Stammbaum page:

  • Given family members with PARENT_OF and SPOUSE_OF relationships exist, when I navigate to /stammbaum, then I see a generational SVG tree with at least 2 generations rendered correctly
  • Given I click a node, then a side panel opens showing name, document count, and direct relationships
  • Given I click "Zur Personenseite →" in the side panel, then I navigate to /persons/{id}
  • Given I toggle to "Alle Verbindungen", then all 180 persons appear as a force-directed graph

Relationship badge:

  • Given a document with a family-member sender and a family-member receiver who are related, when I open the document detail page, then I see the relationship badge with both labels
  • Given the sender or receiver is not a family member, then no badge appears
  • Given both are family members but no relationship path exists, then no badge appears

Relationship editor:

  • Given I open a person's edit page and toggle "Als Familienmitglied markieren", then the person appears on the Stammbaum on next load
  • Given I add a PARENT_OF relationship between Heinrich and Karl, then GET /api/persons/{karl}/relationship-to/{heinrichsSibling} returns the inferred uncle/niece label

Nav:

  • Given any logged-in user, when I look at the top nav, then I see "Stammbaum" and not "Briefwechsel"
  • Given I navigate to /briefwechsel directly, then the page still works (route not deleted)