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>
This commit is contained in:
433
docs/superpowers/specs/2026-04-27-stammbaum-design.md
Normal file
433
docs/superpowers/specs/2026-04-27-stammbaum-design.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# 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`
|
||||
|
||||
```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`
|
||||
|
||||
```java
|
||||
@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
|
||||
|
||||
```java
|
||||
// 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:
|
||||
```java
|
||||
// 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.
|
||||
|
||||
```java
|
||||
@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):**
|
||||
|
||||
```svelte
|
||||
{#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)
|
||||
Reference in New Issue
Block a user