Compare commits

..

3 Commits

Author SHA1 Message Date
Marcel
9f887f12f5 feat(i18n): add person_meta_doc_count + person_meta_rel_count keys (de/en/es) (§7)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m59s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m52s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
Refs #859
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:16:49 +02:00
Marcel
33a1db5d77 refactor(persons): adopt MetaLine primitive on PersonDetail (§7)
Refs #859
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:16:22 +02:00
Marcel
649b6b447c feat(shared): add MetaLine primitive — · -separated meta, optional icon (§7)
Refs #859
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:15:48 +02:00
6 changed files with 122 additions and 0 deletions

View File

@@ -193,6 +193,8 @@
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
"person_received_docs_heading": "Empfangene Dokumente",
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
"person_meta_doc_count": "{count} Dokumente",
"person_meta_rel_count": "{count} Beziehungen",
"person_role_sender": "Gesendet",
"person_role_receiver": "Empfangen",
"person_co_correspondents_heading": "Häufige Korrespondenten",

View File

@@ -193,6 +193,8 @@
"person_no_docs": "This person has not yet been linked as a sender.",
"person_received_docs_heading": "Received documents",
"person_no_received_docs": "This person has not yet been linked as a receiver.",
"person_meta_doc_count": "{count} documents",
"person_meta_rel_count": "{count} relationships",
"person_role_sender": "Sent",
"person_role_receiver": "Received",
"person_co_correspondents_heading": "Frequent correspondents",

View File

@@ -193,6 +193,8 @@
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
"person_received_docs_heading": "Documentos recibidos",
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
"person_meta_doc_count": "{count} documentos",
"person_meta_rel_count": "{count} relaciones",
"person_role_sender": "Enviado",
"person_role_receiver": "Recibido",
"person_co_correspondents_heading": "Corresponsales frecuentes",

View File

@@ -0,0 +1,26 @@
<script lang="ts">
let {
items,
iconSrc
}: {
items: string[];
iconSrc?: string;
} = $props();
</script>
{#if items.length > 0}
<div
data-testid="meta-line"
style="display:flex; align-items:center; flex-wrap:wrap; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2);"
>
{#if iconSrc}
<img src={iconSrc} alt="" style="width:14px; height:14px; opacity:0.5; flex-shrink:0;" />
{/if}
{#each items as item, i (i)}
{#if i > 0}
<span data-testid="meta-sep" aria-hidden="true">·</span>
{/if}
<span data-testid="meta-item">{item}</span>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import MetaLine from './MetaLine.svelte';
afterEach(() => cleanup());
describe('MetaLine', () => {
it('renders N item spans when given N items', async () => {
render(MetaLine, { items: ['14. März 1923', '14 Dokumente', '4 Personen'] });
const spans = document.querySelectorAll('[data-testid="meta-item"]');
expect(spans).toHaveLength(3);
expect(spans[0].textContent).toBe('14. März 1923');
expect(spans[1].textContent).toBe('14 Dokumente');
expect(spans[2].textContent).toBe('4 Personen');
});
it('renders separator spans between items', async () => {
render(MetaLine, { items: ['A', 'B', 'C'] });
const seps = document.querySelectorAll('[data-testid="meta-sep"]');
// N items → N-1 separators
expect(seps).toHaveLength(2);
expect(seps[0].textContent).toBe('·');
});
it('renders nothing when items is empty', async () => {
const { container } = render(MetaLine, { items: [] });
// No element children — Svelte may leave an empty comment node but no DOM elements
expect(container.querySelectorAll('[data-testid]')).toHaveLength(0);
expect(container.querySelectorAll('div, span, img')).toHaveLength(0);
});
it('renders nothing when items has one element (no separator)', async () => {
render(MetaLine, { items: ['Nur eines'] });
const seps = document.querySelectorAll('[data-testid="meta-sep"]');
expect(seps).toHaveLength(0);
const spans = document.querySelectorAll('[data-testid="meta-item"]');
expect(spans).toHaveLength(1);
});
it('shows the leading img when iconSrc is supplied', async () => {
render(MetaLine, {
items: ['Datum'],
iconSrc: '/degruyter-icons/Simple/Small-16px/SVG/Action/Calendar-Add-SM.svg'
});
const img = document.querySelector('img');
expect(img).not.toBeNull();
});
it('does NOT render an img when iconSrc is omitted', async () => {
render(MetaLine, { items: ['Datum'] });
const img = document.querySelector('img');
expect(img).toBeNull();
});
it('icon has width 14px, height 14px, opacity 0.5, and alt=""', async () => {
render(MetaLine, {
items: ['Datum'],
iconSrc: '/degruyter-icons/Simple/Small-16px/SVG/Action/Calendar-Add-SM.svg'
});
const img = document.querySelector('img') as HTMLImageElement;
expect(img.alt).toBe('');
// Inline style values (set directly on the element, not via getComputedStyle)
expect(img.style.width).toBe('14px');
expect(img.style.height).toBe('14px');
expect(img.style.opacity).toBe('0.5');
});
it('applies font-size 12px to the wrapper', async () => {
render(MetaLine, { items: ['Test'] });
const wrapper = document.querySelector('[data-testid="meta-line"]') as HTMLElement;
expect(wrapper).not.toBeNull();
expect(wrapper.style.fontSize).toBe('12px');
});
});

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import PersonCard from './PersonCard.svelte';
import MetaLine from '$lib/shared/primitives/MetaLine.svelte';
import NameHistoryCard from './NameHistoryCard.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte';
@@ -15,6 +16,16 @@ const person = $derived(data.person);
const sentDocuments = $derived(data.sentDocuments);
const receivedDocuments = $derived(data.receivedDocuments);
const totalDocCount = $derived(sentDocuments.length + receivedDocuments.length);
const relCount = $derived(data.relationships.length + data.inferredRelationships.length);
const personMetaItems = $derived.by(() => {
const items: string[] = [];
if (totalDocCount > 0) items.push(m.person_meta_doc_count({ count: totalDocCount }));
if (relCount > 0) items.push(m.person_meta_rel_count({ count: relCount }));
return items;
});
const coCorrespondents = $derived.by(() => {
const freq = new SvelteMap<string, { id: string; name: string; count: number }>();
@@ -61,6 +72,11 @@ const coCorrespondents = $derived.by(() => {
<!-- Left column: Person card + name history -->
<div>
<PersonCard person={person} canWrite={data.canWrite} />
{#if personMetaItems.length > 0}
<div class="mt-3">
<MetaLine items={personMetaItems} />
</div>
{/if}
<div class="mt-6">
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
</div>