feat(stammbaum): show inferred relationship in the document drawer

- New presentational RelationshipBadge component (labelFromA → arrow →
  labelFromB) wired into DocumentMetadataDrawer's Personen column,
  rendered after the receivers block when both endpoints are family
  members.
- DocumentTopBar gains an optional inferredRelationship prop and
  passes it through.
- documents/[id]/+page.server.ts loads the badge: only when sender is
  a family member, exactly one receiver, and that receiver is also a
  family member; 404 (no path) → null.
- relationshipLabels.ts maps the backend label keys (parent/child/...)
  to localised strings, so the server load returns badge-ready strings.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 14:45:38 +02:00
committed by marcel
parent 6bed617959
commit b658a13247
6 changed files with 132 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
import RelationshipBadge from '$lib/components/RelationshipBadge.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
@@ -14,9 +15,18 @@ type Props = {
sender: Person | null;
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
};
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
let {
documentDate,
location,
status,
sender,
receivers,
tags,
inferredRelationship = null
}: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
@@ -112,6 +122,12 @@ function getFullName(person: Person): string {
{/if}
</div>
{/if}
{#if inferredRelationship}
<RelationshipBadge
labelFromA={inferredRelationship.labelFromA}
labelFromB={inferredRelationship.labelFromB}
/>
{/if}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>

View File

@@ -30,9 +30,16 @@ type Props = {
canWrite: boolean;
fileUrl: string;
transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
};
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
let {
doc,
canWrite,
fileUrl,
transcribeMode = $bindable(),
inferredRelationship = null
}: Props = $props();
let detailsOpen = $state(false);
@@ -275,6 +282,7 @@ let mobileMenuOpen = $state(false);
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship}
/>
</div>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
type Props = { labelFromA: string; labelFromB: string };
let { labelFromA, labelFromB }: Props = $props();
</script>
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_relationship()}
</p>
<div class="flex items-center gap-1.5 px-2 font-serif text-sm text-ink">
<span class="font-semibold">{labelFromA}</span>
<svg
class="h-3 w-3 shrink-0 text-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12" />
</svg>
<span class="font-semibold">{labelFromB}</span>
</div>
</div>

View File

@@ -0,0 +1,44 @@
import * as m from '$lib/paraglide/messages.js';
/**
* Maps a backend inferred-label key (parent, uncle_aunt, ...) to its
* localised string. Unknown keys fall back to "distant".
*/
export function inferredRelationshipLabel(key: string): string {
switch (key) {
case 'parent':
return m.relation_inferred_parent();
case 'child':
return m.relation_inferred_child();
case 'spouse':
return m.relation_inferred_spouse();
case 'sibling':
return m.relation_inferred_sibling();
case 'grandparent':
return m.relation_inferred_grandparent();
case 'grandchild':
return m.relation_inferred_grandchild();
case 'great_grandparent':
return m.relation_inferred_great_grandparent();
case 'great_grandchild':
return m.relation_inferred_great_grandchild();
case 'uncle_aunt':
return m.relation_inferred_uncle_aunt();
case 'niece_nephew':
return m.relation_inferred_niece_nephew();
case 'great_uncle_aunt':
return m.relation_inferred_great_uncle_aunt();
case 'great_niece_nephew':
return m.relation_inferred_great_niece_nephew();
case 'inlaw_parent':
return m.relation_inferred_inlaw_parent();
case 'inlaw_child':
return m.relation_inferred_inlaw_child();
case 'sibling_inlaw':
return m.relation_inferred_sibling_inlaw();
case 'cousin_1':
return m.relation_inferred_cousin_1();
default:
return m.relation_inferred_distant();
}
}

View File

@@ -1,6 +1,7 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
export async function load({ params, fetch }) {
const { id } = params;
@@ -15,5 +16,38 @@ export async function load({ params, fetch }) {
throw error(docResult.response.status, getErrorMessage(code));
}
return { document: docResult.data! };
const document = docResult.data!;
const inferredRelationship = await loadInferredRelationship(api, document);
return { document, inferredRelationship };
}
async function loadInferredRelationship(
api: ReturnType<typeof createApiClient>,
document: {
sender?: { id: string; familyMember?: boolean } | null;
receivers?: { id: string; familyMember?: boolean }[];
}
): Promise<{ labelFromA: string; labelFromB: string } | null> {
const sender = document.sender;
const receivers = document.receivers ?? [];
// The badge is shown only when both endpoints are family members and the
// document has exactly one receiver.
if (!sender?.familyMember) return null;
if (receivers.length !== 1) return null;
const receiver = receivers[0];
if (!receiver?.familyMember) return null;
const result = await api.GET('/api/persons/{aId}/relationship-to/{bId}', {
params: { path: { aId: sender.id, bId: receiver.id } }
});
if (result.response.status === 404) return null;
if (!result.response.ok || !result.data) return null;
return {
labelFromA: inferredRelationshipLabel(result.data.labelFromA),
labelFromB: inferredRelationshipLabel(result.data.labelFromB)
};
}

View File

@@ -395,6 +395,7 @@ onMount(() => {
canWrite={canWrite}
fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode}
inferredRelationship={data.inferredRelationship}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">