From 06ba11a7430a1525530614b62af01e21b3c091e6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 09:44:58 +0200 Subject: [PATCH 1/8] feat(i18n): add unknown sender/receiver fallback labels for document grouping Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 ++ frontend/messages/en.json | 2 ++ frontend/messages/es.json | 2 ++ 3 files changed, 6 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 31a922f5..bcf31c72 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -80,6 +80,8 @@ "docs_empty_heading": "Keine Dokumente gefunden", "docs_empty_text": "Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.", "docs_empty_btn_clear": "Alle Filter löschen", + "docs_group_unknown_sender": "Unbekannter Absender", + "docs_group_unknown_receiver": "Unbekannter Empfänger", "docs_list_from": "Von", "docs_list_to": "An", "docs_list_content": "Inhalt", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c594b6e0..ae06a435 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -80,6 +80,8 @@ "docs_empty_heading": "No documents found", "docs_empty_text": "Try adjusting the filters or changing the search term.", "docs_empty_btn_clear": "Clear all filters", + "docs_group_unknown_sender": "Unknown sender", + "docs_group_unknown_receiver": "Unknown recipient", "docs_list_from": "From", "docs_list_to": "To", "docs_list_content": "Content", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3195c2a9..57af4ab6 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -80,6 +80,8 @@ "docs_empty_heading": "No se encontraron documentos", "docs_empty_text": "Intente ajustar los filtros o cambiar el término de búsqueda.", "docs_empty_btn_clear": "Borrar todos los filtros", + "docs_group_unknown_sender": "Remitente desconocido", + "docs_group_unknown_receiver": "Destinatario desconocido", "docs_list_from": "De", "docs_list_to": "Para", "docs_list_content": "Contenido", -- 2.49.1 From ca3d8098d34e84478f07c1058cacf20684d0c86a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 09:45:46 +0200 Subject: [PATCH 2/8] feat(documents): restore sender/receiver grouping in document list When sort=SENDER, documents group under the sender's display name card. When sort=RECEIVER, a document appears under each receiver's card (with multi-receiver duplication). Falls back to i18n labels for unknown sender/receiver. Passes sort prop from /documents page to DocumentList. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte | 67 +++++++--- .../src/routes/DocumentList.svelte.spec.ts | 118 +++++++++++++++++- frontend/src/routes/documents/+page.svelte | 1 + 3 files changed, 169 insertions(+), 17 deletions(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 6db4c8c4..3cab1866 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -12,28 +12,61 @@ let { canWrite, error, total = 0, - q = '' + q = '', + sort = 'DATE' }: { items: DocumentSearchItem[]; canWrite: boolean; error?: string | null; total?: number; q?: string; + sort?: string; } = $props(); -const yearGroups = $derived.by(() => { +const groups = $derived.by(() => { + if (sort === 'SENDER') return groupBySender(items); + if (sort === 'RECEIVER') return groupByReceiver(items); + return groupByYear(items); +}); + +function groupByYear(docItems: DocumentSearchItem[]) { const map = new SvelteMap(); - for (const item of items) { - const year = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum'; - const group = map.get(year); - if (group) { - group.push(item); - } else { - map.set(year, [item]); + for (const item of docItems) { + const label = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum'; + const bucket = map.get(label); + if (bucket) bucket.push(item); + else map.set(label, [item]); + } + return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); +} + +function groupBySender(docItems: DocumentSearchItem[]) { + const map = new SvelteMap(); + for (const item of docItems) { + const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender(); + const bucket = map.get(label); + if (bucket) bucket.push(item); + else map.set(label, [item]); + } + return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); +} + +function groupByReceiver(docItems: DocumentSearchItem[]) { + const map = new SvelteMap(); + for (const item of docItems) { + const receivers = item.document.receivers ?? []; + const labels = + receivers.length > 0 + ? receivers.map((r) => r.displayName) + : [m.docs_group_unknown_receiver()]; + for (const label of labels) { + const bucket = map.get(label); + if (bucket) bucket.push(item); + else map.set(label, [item]); } } - return Array.from(map.entries()).map(([year, groupItems]) => ({ year, items: groupItems })); -}); + return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); +} @@ -67,19 +100,21 @@ const yearGroups = $derived.by(() => { {:else if items.length > 0} - - {#each yearGroups as group (group.year)} + + {#each groups as group (group.label)}
- {group.year}{group.label}
    - {#each group.items as item (item.document.id)} + {#each group.items as item (group.label + '-' + item.document.id)} {/each}
diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index f90882fa..e787e176 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -18,7 +18,7 @@ function makeItem(overrides: Partial = {}): DocumentSearchIt originalFilename: 'testbrief.pdf', status: 'UPLOADED', documentDate: '2024-03-15', - sender: null, + sender: undefined, receivers: [], tags: [], createdAt: '2024-01-01T00:00:00Z', @@ -105,6 +105,122 @@ describe('DocumentList – year grouping', () => { }); }); +// ─── Sender grouping ───────────────────────────────────────────────────────── + +describe('DocumentList – sender grouping', () => { + it('groups by sender displayName when sort is SENDER', async () => { + const items = [ + makeItem({ + document: { + ...makeItem().document, + id: '1', + sender: { + id: 's1', + lastName: 'Mustermann', + displayName: 'Max Mustermann', + personType: 'PERSON' + } + } + }), + makeItem({ + document: { + ...makeItem().document, + id: '2', + sender: { + id: 's2', + lastName: 'Musterfrau', + displayName: 'Anna Musterfrau', + personType: 'PERSON' + } + } + }) + ]; + render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Max Mustermann' })) + .toBeInTheDocument(); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Anna Musterfrau' })) + .toBeInTheDocument(); + }); + + it('groups documents with the same sender into one card', async () => { + const sender = { + id: 's1', + lastName: 'Mustermann', + displayName: 'Max Mustermann', + personType: 'PERSON' as const + }; + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', sender } }), + makeItem({ document: { ...makeItem().document, id: '2', sender } }) + ]; + render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); + const cards = page.getByTestId('year-card'); + await expect.element(cards.first()).toBeInTheDocument(); + await expect.element(cards.nth(1)).not.toBeInTheDocument(); + }); + + it('places items with no sender under fallback label', async () => { + const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' }); + await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument(); + }); +}); + +// ─── Receiver grouping ──────────────────────────────────────────────────────── + +describe('DocumentList – receiver grouping', () => { + it('groups by receiver displayName when sort is RECEIVER', async () => { + const items = [ + makeItem({ + document: { + ...makeItem().document, + id: '1', + receivers: [ + { id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' } + ] + } + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Felix Brandt' })) + .toBeInTheDocument(); + }); + + it('duplicates a document into each receiver group', async () => { + const items = [ + makeItem({ + document: { + ...makeItem().document, + id: '1', + title: 'Rundbriefchen', + receivers: [ + { id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' }, + { id: 'r2', lastName: 'Meier', displayName: 'Hans Meier', personType: 'PERSON' } + ] + } + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Felix Brandt' })) + .toBeInTheDocument(); + await expect + .element(page.getByTestId('group-header').filter({ hasText: 'Hans Meier' })) + .toBeInTheDocument(); + const cards = page.getByTestId('year-card'); + await expect.element(cards.nth(1)).toBeInTheDocument(); + }); + + it('places items with no receivers under fallback label', async () => { + const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); + await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument(); + }); +}); + // ─── DocumentRow rendering (delegated) ─────────────────────────────────────── describe('DocumentList – DocumentRow delegation', () => { diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 588fc998..57054ff6 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -119,5 +119,6 @@ $effect(() => { q={data.q} canWrite={data.canWrite} error={data.error} + sort={sort} /> -- 2.49.1 From ac50b353b838241308493651e6f94cbebb7340df Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 09:55:50 +0200 Subject: [PATCH 3/8] fix(document-row): align contributor circles with progress ring The ProgressRing renders SVG + percentage label as a flex column (~52px total). With items-center, the contributor circles aligned to the middle of the full block, placing them 8px below the ring center. Changed to items-start on the container and wrapped ContributorStack in h-9 (36px = SVG height) flex items-center so both circles center at the same 18px. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/DocumentRow.svelte | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/DocumentRow.svelte b/frontend/src/lib/components/DocumentRow.svelte index bee37ac5..18766320 100644 --- a/frontend/src/lib/components/DocumentRow.svelte +++ b/frontend/src/lib/components/DocumentRow.svelte @@ -140,9 +140,11 @@ function safeTagColor(color: string | null | undefined): string {
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
-
+
- +
+ +
@@ -172,9 +174,11 @@ function safeTagColor(color: string | null | undefined): string { {/if} -
+
- +
+ +
-- 2.49.1 From 5c2378a6fe5abe749f0f35816f32aa1610bdd9bf Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 11:12:27 +0200 Subject: [PATCH 4/8] refactor(documents): rename year-card testid to group-card Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte | 2 +- .../src/routes/DocumentList.svelte.spec.ts | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 3cab1866..825136b2 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -103,7 +103,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) { {#each groups as group (group.label)}
diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index e787e176..9aba3bb6 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -79,9 +79,9 @@ describe('DocumentList – year grouping', () => { makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } }) ]; render(DocumentList, { ...baseProps, items, total: 2 }); - const yearCards = page.getByTestId('year-card'); - await expect.element(yearCards.first()).toBeInTheDocument(); - await expect.element(yearCards.nth(1)).toBeInTheDocument(); + const groupCards = page.getByTestId('group-card'); + await expect.element(groupCards.first()).toBeInTheDocument(); + await expect.element(groupCards.nth(1)).toBeInTheDocument(); }); it('uses Ohne Datum for items with no documentDate', async () => { @@ -92,16 +92,15 @@ describe('DocumentList – year grouping', () => { await expect.element(page.getByText('Ohne Datum')).toBeInTheDocument(); }); - it('single year renders one year-card', async () => { + it('single year renders one group-card', async () => { const items = [ makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }), makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } }) ]; render(DocumentList, { ...baseProps, items, total: 2 }); - const yearCards = page.getByTestId('year-card'); - // Only one card for 1938 - await expect.element(yearCards.first()).toBeInTheDocument(); - await expect.element(yearCards.nth(1)).not.toBeInTheDocument(); + const groupCards = page.getByTestId('group-card'); + await expect.element(groupCards.first()).toBeInTheDocument(); + await expect.element(groupCards.nth(1)).not.toBeInTheDocument(); }); }); @@ -156,7 +155,7 @@ describe('DocumentList – sender grouping', () => { makeItem({ document: { ...makeItem().document, id: '2', sender } }) ]; render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); - const cards = page.getByTestId('year-card'); + const cards = page.getByTestId('group-card'); await expect.element(cards.first()).toBeInTheDocument(); await expect.element(cards.nth(1)).not.toBeInTheDocument(); }); @@ -210,7 +209,7 @@ describe('DocumentList – receiver grouping', () => { await expect .element(page.getByTestId('group-header').filter({ hasText: 'Hans Meier' })) .toBeInTheDocument(); - const cards = page.getByTestId('year-card'); + const cards = page.getByTestId('group-card'); await expect.element(cards.nth(1)).toBeInTheDocument(); }); -- 2.49.1 From a62ccd428b66aca116da19db60b213d6abb675c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 11:15:40 +0200 Subject: [PATCH 5/8] fix(documents): use i18n key for undated group label instead of hardcoded German string Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte | 2 +- frontend/src/routes/DocumentList.svelte.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 825136b2..76f8195a 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -32,7 +32,7 @@ const groups = $derived.by(() => { function groupByYear(docItems: DocumentSearchItem[]) { const map = new SvelteMap(); for (const item of docItems) { - const label = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum'; + const label = item.document.documentDate?.substring(0, 4) ?? m.docs_group_undated(); const bucket = map.get(label); if (bucket) bucket.push(item); else map.set(label, [item]); diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index 9aba3bb6..4633d457 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -84,12 +84,12 @@ describe('DocumentList – year grouping', () => { await expect.element(groupCards.nth(1)).toBeInTheDocument(); }); - it('uses Ohne Datum for items with no documentDate', async () => { + it('uses undated label for items with no documentDate', async () => { const items = [ makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } }) ]; render(DocumentList, { ...baseProps, items, total: 1 }); - await expect.element(page.getByText('Ohne Datum')).toBeInTheDocument(); + await expect.element(page.getByText('Undatiert')).toBeInTheDocument(); }); it('single year renders one group-card', async () => { -- 2.49.1 From 4cdec7ec71b18c1918107c8771f303f6aa399b2d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 11:19:40 +0200 Subject: [PATCH 6/8] refactor(documents): narrow sort prop type to full SortMode union Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 76f8195a..dad41528 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -7,6 +7,8 @@ import type { components } from '$lib/generated/api'; type DocumentSearchItem = components['schemas']['DocumentSearchItem']; +type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE'; + let { items, canWrite, @@ -20,7 +22,7 @@ let { error?: string | null; total?: number; q?: string; - sort?: string; + sort?: SortMode; } = $props(); const groups = $derived.by(() => { -- 2.49.1 From 506a220ad28478ef6e516c5e3f499033ac11bd46 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 11:21:45 +0200 Subject: [PATCH 7/8] test(documents): add regression test for sort fallback to year grouping Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index 4633d457..a41c92b9 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -104,6 +104,20 @@ describe('DocumentList – year grouping', () => { }); }); +// ─── Sort fallback ──────────────────────────────────────────────────────────── + +describe('DocumentList – sort fallback', () => { + it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: '2024-03-15' } }) + ]; + render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' }); + await expect + .element(page.getByTestId('group-header').filter({ hasText: '2024' })) + .toBeInTheDocument(); + }); +}); + // ─── Sender grouping ───────────────────────────────────────────────────────── describe('DocumentList – sender grouping', () => { -- 2.49.1 From 1ac2918835d4e11919e41df92e893590a12c3bbf Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 11:24:04 +0200 Subject: [PATCH 8/8] fix(documents): suppress uppercase on person name group headers for SENDER/RECEIVER sort Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index dad41528..74478c66 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -111,8 +111,10 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
{group.label}{group.label}
    -- 2.49.1