feat(persons): add sort toggle to person document list (issue #24)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 1m55s
CI / Backend Unit Tests (pull_request) Successful in 2m9s
CI / E2E Tests (pull_request) Successful in 18m8s

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-19 21:24:46 +01:00
parent fe9b4a9569
commit fb08eb30a4
4 changed files with 102 additions and 5 deletions

View File

@@ -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');

View File

@@ -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([]);
});
});

View File

@@ -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<T extends { documentDate?: string | null }>(
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);
});
}

View File

@@ -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<SortDir>('DESC');
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
let editMode = $state(false);
let mergeTargetId = $state('');
let showMergeConfirm = $state(false);
@@ -200,10 +204,21 @@
<!-- Linked Documents Section -->
<div>
<div class="flex items-center justify-between mb-6 border-b border-brand-navy/10 pb-2">
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
{documents.length}
</span>
<div class="flex items-center gap-3">
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
{documents.length}
</span>
</div>
{#if documents.length > 0}
<button
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
class="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-brand-navy transition-colors"
aria-label={m.conv_sort_label()}
>
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
{/if}
</div>
{#if documents.length === 0}
@@ -212,7 +227,7 @@
</div>
{:else}
<ul class="space-y-3">
{#each documents as doc}
{#each sortedDocuments as doc}
<li class="group">
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
<div class="flex items-center justify-between">