From fb08eb30a47184d8b50489031c57b36a84e76da3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 21:24:46 +0100 Subject: [PATCH 1/2] 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}