From 371d92f52af870d0207af8db0d849da4e786792a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 21:19:36 +0100 Subject: [PATCH 1/4] feat(person): add conversations quick-link (#20) Add a "Konversationen anzeigen" link to the person detail page header that navigates to /conversations?senderId={id}, pre-filling the person as Person A. Includes i18n in de/en/es and an E2E test. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/persons.spec.ts | 13 +++++++++++++ frontend/messages/en.json | 3 ++- frontend/messages/es.json | 3 ++- frontend/src/routes/persons/[id]/+page.svelte | 14 ++++++++++---- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index c6072347..071d2af4 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -72,6 +72,19 @@ test.describe('New person', () => { }); }); +test.describe('Person detail — conversations link', () => { + test('has a conversations link that pre-fills the person', async ({ page }) => { + await page.goto('/persons'); + const firstLink = page.locator('a[href^="/persons/"]').first(); + const href = await firstLink.getAttribute('href'); + const personId = href!.split('/persons/')[1]; + await firstLink.click(); + const convLink = page.getByRole('link', { name: /Konversationen/i }); + await expect(convLink).toBeVisible(); + await expect(convLink).toHaveAttribute('href', `/conversations?senderId=${personId}`); + }); +}); + test.describe('Conversations', () => { test('shows the empty state when no persons are selected', async ({ page }) => { await page.goto('/conversations'); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cb4ec322..eb49ce53 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -175,5 +175,6 @@ "comp_taginput_placeholder_create": "Add tags...", "comp_taginput_placeholder_filter": "Filter by tags...", "comp_taginput_remove": "Remove tag", - "comp_taginput_create_hint": "Press Enter to create tag." + "comp_taginput_create_hint": "Press Enter to create tag.", + "person_btn_conversations": "View conversations" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 90ce4108..627e7391 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -175,5 +175,6 @@ "comp_taginput_placeholder_create": "Añadir etiquetas...", "comp_taginput_placeholder_filter": "Filtrar por etiquetas...", "comp_taginput_remove": "Eliminar etiqueta", - "comp_taginput_create_hint": "Pulse Enter para crear etiqueta." + "comp_taginput_create_hint": "Pulse Enter para crear etiqueta.", + "person_btn_conversations": "Ver conversaciones" } diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 3b7b8f3e..e29e227f 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -108,10 +108,16 @@

{person.firstName} {person.lastName}

- +
+ + + {m.person_btn_conversations()} + + +
From fb08eb30a47184d8b50489031c57b36a84e76da3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 21:24:46 +0100 Subject: [PATCH 2/4] feat(persons): add sort toggle to person document list (issue #24) Extracted sortDocumentsByDate utility with full Vitest coverage (6 tests), wired it into the person detail page with a DESC/ASC toggle button, and added an E2E smoke test for the toggle interaction. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/persons.spec.ts | 19 ++++++++ frontend/src/lib/utils/sort.spec.ts | 44 +++++++++++++++++++ frontend/src/lib/utils/sort.ts | 19 ++++++++ frontend/src/routes/persons/[id]/+page.svelte | 25 ++++++++--- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/utils/sort.spec.ts create mode 100644 frontend/src/lib/utils/sort.ts diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index c6072347..7dcabc67 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -72,6 +72,25 @@ test.describe('New person', () => { }); }); +test.describe('Person detail — sort toggle', () => { + test('sort toggle changes the button label when person has documents', async ({ page }) => { + await page.goto('/persons'); + const firstPerson = page.locator('a[href^="/persons/"]').first(); + await firstPerson.click(); + await page.waitForSelector('[data-hydrated]'); + + const sortBtn = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i }); + if (await sortBtn.isVisible()) { + await expect(sortBtn).toContainText('Neueste zuerst'); + await sortBtn.click(); + await expect(sortBtn).toContainText('Älteste zuerst'); + await sortBtn.click(); + await expect(sortBtn).toContainText('Neueste zuerst'); + await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' }); + } + }); +}); + test.describe('Conversations', () => { test('shows the empty state when no persons are selected', async ({ page }) => { await page.goto('/conversations'); diff --git a/frontend/src/lib/utils/sort.spec.ts b/frontend/src/lib/utils/sort.spec.ts new file mode 100644 index 00000000..488fd8d8 --- /dev/null +++ b/frontend/src/lib/utils/sort.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { sortDocumentsByDate } from './sort'; + +const doc = (id: string, documentDate: string | null) => + ({ id, documentDate } as { id: string; documentDate: string | null }); + +describe('sortDocumentsByDate', () => { + it('sorts DESC by default — newest first', () => { + const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')]; + const result = sortDocumentsByDate(docs, 'DESC'); + expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']); + }); + + it('sorts ASC — oldest first', () => { + const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')]; + const result = sortDocumentsByDate(docs, 'ASC'); + expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']); + }); + + it('places documents without a date last in DESC', () => { + const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)]; + const result = sortDocumentsByDate(docs, 'DESC'); + expect(result[0].id).toBe('b'); + expect(result.slice(1).map((d) => d.id)).toContain('a'); + expect(result.slice(1).map((d) => d.id)).toContain('c'); + }); + + it('places documents without a date last in ASC', () => { + const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)]; + const result = sortDocumentsByDate(docs, 'ASC'); + expect(result[0].id).toBe('b'); + }); + + it('does not mutate the original array', () => { + const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')]; + const original = [...docs]; + sortDocumentsByDate(docs, 'ASC'); + expect(docs).toEqual(original); + }); + + it('returns an empty array unchanged', () => { + expect(sortDocumentsByDate([], 'DESC')).toEqual([]); + }); +}); diff --git a/frontend/src/lib/utils/sort.ts b/frontend/src/lib/utils/sort.ts new file mode 100644 index 00000000..eccd573a --- /dev/null +++ b/frontend/src/lib/utils/sort.ts @@ -0,0 +1,19 @@ +export type SortDir = 'ASC' | 'DESC'; + +/** + * Returns a new array of documents sorted by documentDate. + * Documents without a date are always placed last, regardless of direction. + */ +export function sortDocumentsByDate( + docs: T[], + dir: SortDir +): T[] { + return [...docs].sort((a, b) => { + const da = a.documentDate ?? ''; + const db = b.documentDate ?? ''; + if (!da && !db) return 0; + if (!da) return 1; + if (!db) return -1; + return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db); + }); +} diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 3b7b8f3e..b171c6a6 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -2,12 +2,16 @@ import { enhance } from '$app/forms'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import { m } from '$lib/paraglide/messages.js'; + import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort'; let { data, form } = $props(); const person = $derived(data.person); const documents = $derived(data.documents); + let sortDir = $state('DESC'); + const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir)); + let editMode = $state(false); let mergeTargetId = $state(''); let showMergeConfirm = $state(false); @@ -200,10 +204,21 @@
-

{m.person_docs_heading()}

- - {documents.length} - +
+

{m.person_docs_heading()}

+ + {documents.length} + +
+ {#if documents.length > 0} + + {/if}
{#if documents.length === 0} @@ -212,7 +227,7 @@
{:else}
@@ -136,6 +146,13 @@ "{person.alias}"
{/if} + + {#if person.notes} +
+ {m.person_label_notes()} +

{person.notes}

+
+ {/if}