feat(stammbaum): person edit Stammbaum & Beziehungen card

New StammbaumCard rendered below the Namensverlauf card on
/persons/{id}/edit:
- Header with "Als Familienmitglied" toggle (form action
  toggleFamilyMember → PATCH /api/persons/{id}/family-member).
- "Erscheint im Stammbaum" banner with deep-link to
  /stammbaum?focus={id} when familyMember is true.
- Direct relationships list grouped by type, then year. Chip text is
  direction-aware: storage subject reads "Elternteil von", storage
  object reads "Kind von" (new relation_child_of i18n key in all 3
  locales). Symmetric and non-family types use their own keys.
- + Beziehung hinzufügen reveals an inline form with type select
  (grouped Familie / Sozial), a PersonTypeahead with the new
  excludePersonId prop (self-rel prevention, Elicit blocker 1), and
  Von / Bis year fields.
- Year validation lives client-side via $derived: empty/empty is OK,
  Bis < Von shows a red text-red-700 error wired with aria-describedby
  and disables submit (Sara blocker 3).
- Self-rel inline error mirrors the typeahead exclusion in case the
  user submits the personId regardless.
- Abgeleitete Beziehungen section (top 5) collapsed by default.

+page.server.ts loads relationships + inferred relationships in the
existing parallel fetch and adds three actions: toggleFamilyMember,
addRelationship (with year-range guard), deleteRelationship.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 14:51:54 +02:00
committed by marcel
parent b658a13247
commit aaf885cafd
7 changed files with 476 additions and 5 deletions

View File

@@ -916,6 +916,7 @@
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
"relation_parent_of": "Elternteil von",
"relation_child_of": "Kind von",
"relation_spouse_of": "Ehegatte",
"relation_sibling_of": "Geschwister",
"relation_friend": "Freund",

View File

@@ -916,6 +916,7 @@
"error_duplicate_relationship": "This relationship already exists.",
"relation_parent_of": "Parent of",
"relation_child_of": "Child of",
"relation_spouse_of": "Spouse",
"relation_sibling_of": "Sibling",
"relation_friend": "Friend",

View File

@@ -916,6 +916,7 @@
"error_duplicate_relationship": "Esta relación ya existe.",
"relation_parent_of": "Progenitor de",
"relation_child_of": "Hijo/a de",
"relation_spouse_of": "Cónyuge",
"relation_sibling_of": "Hermano/a",
"relation_friend": "Amigo/a",

View File

@@ -19,6 +19,7 @@ interface Props {
autofocus?: boolean;
required?: boolean;
restrictToCorrespondentsOf?: string;
excludePersonId?: string;
badge?: 'additive' | 'replace';
onchange?: (value: string) => void;
onfocused?: () => void;
@@ -36,6 +37,7 @@ let {
autofocus = false,
required = false,
restrictToCorrespondentsOf,
excludePersonId,
badge,
onchange,
onfocused
@@ -61,17 +63,20 @@ $effect(() => {
const typeahead = createTypeahead<Person>({
fetchUrl: async (term) => {
const personId = restrictToCorrespondentsOf;
const excludeId = excludePersonId;
const filter = (results: Person[]) =>
excludeId ? results.filter((p) => p.id !== excludeId) : results;
if (personId) {
const url =
term.length >= 1
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
: `/api/persons/${personId}/correspondents`;
const res = await fetch(url);
return res.ok ? await res.json() : [];
return res.ok ? filter(await res.json()) : [];
}
if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
return res.ok ? await res.json() : [];
return res.ok ? filter(await res.json()) : [];
},
debounceMs: 300
});

View File

@@ -0,0 +1,365 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
personId: string;
familyMember: boolean;
relationships: RelationshipDTO[];
inferredRelationships: InferredRelationshipWithPersonDTO[];
canWrite: boolean;
relationshipError?: string | null;
}
let {
personId,
familyMember,
relationships,
inferredRelationships,
canWrite,
relationshipError = null
}: Props = $props();
let addFormOpen = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let addFromYear = $state('');
let addToYear = $state('');
type RelationType = NonNullable<RelationshipDTO['relationType']>;
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
const topDerived = $derived(inferredRelationships.slice(0, 5));
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order;
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
}
function relationTypeOrder(t: RelationType | undefined): number {
const order: Record<string, number> = {
PARENT_OF: 1,
SPOUSE_OF: 2,
SIBLING_OF: 3,
FRIEND: 4,
COLLEAGUE: 5,
EMPLOYER: 6,
DOCTOR: 7,
NEIGHBOR: 8,
OTHER: 9
};
return order[t ?? 'OTHER'] ?? 99;
}
function chipLabel(rel: RelationshipDTO): string {
const viewpointIsSubject = rel.personId === personId;
switch (rel.relationType) {
case 'PARENT_OF':
return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of();
case 'SPOUSE_OF':
return m.relation_spouse_of();
case 'SIBLING_OF':
return m.relation_sibling_of();
case 'FRIEND':
return m.relation_friend();
case 'COLLEAGUE':
return m.relation_colleague();
case 'EMPLOYER':
return m.relation_employer();
case 'DOCTOR':
return m.relation_doctor();
case 'NEIGHBOR':
return m.relation_neighbor();
default:
return m.relation_other();
}
}
function otherName(rel: RelationshipDTO): string {
return rel.personId === personId ? rel.relatedPersonDisplayName : rel.personDisplayName;
}
function yearRange(rel: RelationshipDTO): string {
const from = rel.fromYear;
const to = rel.toYear;
if (from && to) return `${from}${to}`;
if (from) return `ab ${from}`;
if (to) return `bis ${to}`;
return '';
}
function resetAddForm() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
addFromYear = '';
addToYear = '';
}
</script>
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
<!-- Header row: heading + family-member toggle -->
<div class="mb-5 flex items-start justify-between gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
Stammbaum &amp; Beziehungen
</h2>
{#if canWrite}
<form method="POST" action="?/toggleFamilyMember" use:enhance>
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
<button
type="submit"
role="switch"
aria-checked={familyMember}
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
<span
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
? 'bg-primary'
: 'bg-line'}"
>
<span
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
? 'translate-x-3'
: ''}"
></span>
</span>
{m.relation_label_family_member()}
</button>
</form>
{/if}
</div>
{#if relationshipError}
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
{/if}
<!-- In-tree banner -->
{#if familyMember}
<div
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
>
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
</div>
<a
href="/stammbaum?focus={personId}"
class="font-sans text-xs font-medium text-primary hover:underline"
>
{m.relation_label_view_in_tree()}
</a>
</div>
{/if}
<!-- Direkte Beziehungen -->
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.relation_label_direct()}
</h3>
{#if sortedDirect.length === 0}
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="mb-2 divide-y divide-line">
{#each sortedDirect as rel (rel.id)}
<li class="flex items-center gap-2 py-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-[10px] font-bold tracking-widest text-ink uppercase"
>
{chipLabel(rel)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName(rel)}
</span>
{#if yearRange(rel)}
<span class="shrink-0 font-sans text-xs text-ink-3">{yearRange(rel)}</span>
{/if}
{#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
<input type="hidden" name="relId" value={rel.id} />
<button
type="submit"
aria-label="{m.btn_delete()}{otherName(rel)}"
class="inline-flex h-8 w-8 items-center justify-center text-ink-3 transition-colors hover:text-red-600"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
<!-- Add-rel button + inline form -->
{#if canWrite}
{#if !addFormOpen}
<button
type="button"
onclick={() => (addFormOpen = true)}
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.stammbaum_panel_add_rel()}
</button>
{:else}
<form
method="POST"
action="?/addRelationship"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
addFormOpen = false;
resetAddForm();
}
};
}}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
>
<div class="grid gap-3 md:grid-cols-2">
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">Typ</span>
<select
name="relationType"
bind:value={addType}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
>
<optgroup label="Familie">
<option value="PARENT_OF">{m.relation_parent_of()}</option>
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
</optgroup>
<optgroup label="Sozial">
<option value="FRIEND">{m.relation_friend()}</option>
<option value="COLLEAGUE">{m.relation_colleague()}</option>
<option value="EMPLOYER">{m.relation_employer()}</option>
<option value="DOCTOR">{m.relation_doctor()}</option>
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
<option value="OTHER">{m.relation_other()}</option>
</optgroup>
</select>
</label>
<div>
<PersonTypeahead
name="relatedPersonId"
label="Person"
bind:value={addRelatedPersonId}
initialName={addRelatedPersonName}
excludePersonId={personId}
compact
/>
</div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">Von Jahr</span>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder="z.B. 1920"
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">Bis Jahr</span>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
<div class="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onclick={() => {
addFormOpen = false;
resetAddForm();
}}
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
>
{m.relation_btn_cancel()}
</button>
<button
type="submit"
disabled={submitDisabled}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
>
{m.relation_btn_add()}
</button>
</div>
</form>
{/if}
{/if}
<!-- Abgeleitete Beziehungen -->
{#if topDerived.length > 0}
<details class="mt-6">
<summary
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
>
{m.relation_label_derived()}
</summary>
<ul class="mt-2 space-y-2">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-[10px] font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2">
{derived.person.displayName}
</span>
</li>
{/each}
</ul>
</details>
{/if}
</div>

View File

@@ -17,9 +17,11 @@ export async function load({ params, fetch, locals }) {
const { id } = params;
const api = createApiClient(fetch);
const [result, aliasesResult] = await Promise.all([
const [result, aliasesResult, relsResult, inferredResult] = await Promise.all([
api.GET('/api/persons/{id}', { params: { path: { id } } }),
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } })
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
]);
if (!result.response.ok) {
@@ -29,7 +31,12 @@ export async function load({ params, fetch, locals }) {
const person = result.data!;
const personType = normalizePersonType(person.personType);
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
return {
person: { ...person, personType },
aliases: aliasesResult.data ?? [],
relationships: relsResult.data ?? [],
inferredRelationships: inferredResult.data ?? []
};
}
export const actions = {
@@ -146,5 +153,86 @@ export const actions = {
}
return { aliasSuccess: true };
},
toggleFamilyMember: async ({ request, params, fetch }) => {
const formData = await request.formData();
const value = formData.get('familyMember')?.toString() === 'true';
const api = createApiClient(fetch);
const result = await api.PATCH('/api/persons/{id}/family-member', {
params: { path: { id: params.id } },
body: { familyMember: value }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
}
return { relationshipSuccess: true };
},
addRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData();
const relatedPersonId = formData.get('relatedPersonId')?.toString();
const relationType = formData.get('relationType')?.toString();
const fromYearRaw = formData.get('fromYear')?.toString().trim();
const toYearRaw = formData.get('toYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
if (!relatedPersonId || !relationType) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
if (relatedPersonId === params.id) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined;
const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined;
if (
fromYear !== undefined &&
toYear !== undefined &&
!Number.isNaN(fromYear) &&
!Number.isNaN(toYear) &&
toYear < fromYear
) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const api = createApiClient(fetch);
const result = await api.POST('/api/persons/{id}/relationships', {
params: { path: { id: params.id } },
body: {
relatedPersonId,
relationType,
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}),
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}),
...(notes ? { notes } : {})
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
}
return { relationshipSuccess: true };
},
deleteRelationship: async ({ request, params, fetch }) => {
const formData = await request.formData();
const relId = formData.get('relId')?.toString();
if (!relId) {
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
}
const api = createApiClient(fetch);
const result = await api.DELETE('/api/persons/{id}/relationships/{relId}', {
params: { path: { id: params.id, relId } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
}
return { relationshipSuccess: true };
}
};

View File

@@ -6,6 +6,7 @@ import PersonEditForm from './PersonEditForm.svelte';
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
import PersonMergePanel from '../PersonMergePanel.svelte';
import StammbaumCard from '$lib/components/StammbaumCard.svelte';
let { data, form } = $props();
const person = $derived(data.person);
@@ -35,6 +36,15 @@ const person = $derived(data.person);
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
<StammbaumCard
personId={person.id}
familyMember={person.familyMember ?? false}
relationships={data.relationships}
inferredRelationships={data.inferredRelationships}
canWrite={true}
relationshipError={form?.relationshipError}
/>
{#key person.id}
<PersonMergePanel person={person} form={form} />
{/key}