diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 1c2d4843..52a3f977 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -140,6 +140,9 @@
"conv_empty_text": "Die Korrespondenz wird hier angezeigt.",
"conv_no_results_heading": "Keine Dokumente gefunden.",
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
+ "conv_swap_btn": "Personen tauschen",
+ "conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
+ "conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 1a340e11..75fa54f9 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -140,6 +140,9 @@
"conv_empty_text": "The correspondence will be shown here.",
"conv_no_results_heading": "No documents found.",
"conv_no_results_text": "Try adjusting the time period.",
+ "conv_swap_btn": "Swap persons",
+ "conv_summary": "{count} documents · {yearFrom}–{yearTo}",
+ "conv_new_doc_link": "New document in this correspondence",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 2ca24a72..58be11ed 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -140,6 +140,9 @@
"conv_empty_text": "La correspondencia se mostrará aquí.",
"conv_no_results_heading": "No se encontraron documentos.",
"conv_no_results_text": "Intente ajustar el período de tiempo.",
+ "conv_swap_btn": "Intercambiar personas",
+ "conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
+ "conv_new_doc_link": "Nuevo documento en esta correspondencia",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte
index 854b45c4..8467a3b2 100644
--- a/frontend/src/routes/conversations/+page.svelte
+++ b/frontend/src/routes/conversations/+page.svelte
@@ -14,6 +14,14 @@ let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
+const documentYears = $derived(
+ data.documents
+ .map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
+ .filter((y): y is number => y !== null)
+);
+const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
+const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
+
// Sync with server data after navigation
$effect(() => {
senderId = data.filters.senderId;
@@ -37,6 +45,24 @@ function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
+
+function swapPersons() {
+ const tmp = senderId;
+ senderId = receiverId;
+ receiverId = tmp;
+ applyFilters();
+}
+
+const enrichedDocuments = $derived(
+ data.documents.map((doc, i) => {
+ const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
+ const prevYear =
+ i > 0 && data.documents[i - 1].documentDate
+ ? new Date(data.documents[i - 1].documentDate!).getFullYear()
+ : null;
+ return { doc, year, showYearDivider: year !== null && year !== prevYear };
+ })
+);
@@ -50,7 +76,7 @@ function toggleSort() {
-
+
+
+
+
+
+
{m.conv_no_results_text()}
{:else}
+
+
+ {#if yearFrom !== null && yearTo !== null}
+
+ {m.conv_summary({ count: data.documents.length, yearFrom, yearTo })}
+
+ {:else}
+
+ {data.documents.length}
+
+ {/if}
+ {#if data.canWrite}
+
+
+ {m.conv_new_doc_link()}
+
+ {/if}
+
+
@@ -172,7 +254,17 @@ function toggleSort() {
- {#each data.documents as doc (doc.id)}
+ {#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
+ {#if showYearDivider}
+
+ {/if}
{@const isRight = doc.sender?.id === senderId}
diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts
new file mode 100644
index 00000000..c7cb41fc
--- /dev/null
+++ b/frontend/src/routes/conversations/page.svelte.spec.ts
@@ -0,0 +1,162 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import Page from './+page.svelte';
+
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
+
+afterEach(cleanup);
+
+// ─── Test data ────────────────────────────────────────────────────────────────
+
+const baseData = {
+ canWrite: true,
+ documents: [],
+ initialValues: { senderName: '', receiverName: '' },
+ filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
+};
+
+const withPersons = {
+ ...baseData,
+ filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
+};
+
+const makeDoc = (overrides: Record
= {}) => ({
+ id: 'd1',
+ title: 'Testbrief',
+ originalFilename: 'testbrief.pdf',
+ status: 'UPLOADED' as const,
+ documentDate: '1923-04-12',
+ location: 'Berlin',
+ sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
+ receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
+ tags: [],
+ transcription: null,
+ filePath: null,
+ createdAt: '1923-04-12T00:00:00Z',
+ updatedAt: '1923-04-12T00:00:00Z',
+ ...overrides
+});
+
+const withDocs = {
+ ...withPersons,
+ documents: [makeDoc()]
+};
+
+// ─── Empty state ──────────────────────────────────────────────────────────────
+
+describe('Conversations page – empty state', () => {
+ it('shows the "select two persons" prompt when no persons are selected', async () => {
+ render(Page, { data: baseData });
+ await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
+ });
+
+ it('hides the swap button when no persons are selected', async () => {
+ render(Page, { data: baseData });
+ // Button is always in the DOM (holds grid column width on desktop) but made invisible
+ await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
+ });
+
+ it('does not show the new document link when no persons are selected', async () => {
+ render(Page, { data: baseData });
+ await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
+ });
+});
+
+// ─── No results ───────────────────────────────────────────────────────────────
+
+describe('Conversations page – no results', () => {
+ it('shows "no documents found" when both persons are selected but there are no documents', async () => {
+ render(Page, { data: withPersons });
+ await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
+ });
+});
+
+// ─── Swap button ──────────────────────────────────────────────────────────────
+
+describe('Conversations page – swap button', () => {
+ it('shows the swap button when both persons are selected', async () => {
+ render(Page, { data: withPersons });
+ await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
+ });
+
+ it('calls goto with swapped sender and receiver when clicked', async () => {
+ const { goto } = await import('$app/navigation');
+ vi.mocked(goto).mockClear();
+ render(Page, { data: withPersons });
+ await page.getByTestId('conv-swap-btn').click();
+ expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
+ expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
+ });
+});
+
+// ─── Summary ──────────────────────────────────────────────────────────────────
+
+describe('Conversations page – summary', () => {
+ it('shows document count and year range when documents are loaded', async () => {
+ const data = {
+ ...withPersons,
+ documents: [
+ makeDoc({ documentDate: '1923-04-12' }),
+ makeDoc({ id: 'd2', documentDate: '1965-08-03' })
+ ]
+ };
+ render(Page, { data });
+ const summary = page.getByTestId('conv-summary');
+ await expect.element(summary).toHaveTextContent('2');
+ await expect.element(summary).toHaveTextContent('1923');
+ await expect.element(summary).toHaveTextContent('1965');
+ });
+});
+
+// ─── Year dividers ────────────────────────────────────────────────────────────
+
+describe('Conversations page – year dividers', () => {
+ it('renders a year divider for the first document', async () => {
+ render(Page, { data: withDocs });
+ await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
+ });
+
+ it('renders a divider for each new year in the document list', async () => {
+ const data = {
+ ...withPersons,
+ documents: [
+ makeDoc({ documentDate: '1923-04-12' }),
+ makeDoc({ id: 'd2', documentDate: '1965-08-03' })
+ ]
+ };
+ render(Page, { data });
+ await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
+ await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965');
+ });
+
+ it('does not render a second divider for documents from the same year', async () => {
+ const data = {
+ ...withPersons,
+ documents: [
+ makeDoc({ documentDate: '1923-04-12' }),
+ makeDoc({ id: 'd2', documentDate: '1923-09-01' })
+ ]
+ };
+ render(Page, { data });
+ // Only one divider for 1923; 1965 divider should not appear
+ await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
+ await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
+ });
+});
+
+// ─── New document link ────────────────────────────────────────────────────────
+
+describe('Conversations page – new document link', () => {
+ it('shows the link with correct href for a write user', async () => {
+ render(Page, { data: { ...withDocs, canWrite: true } });
+ const link = page.getByTestId('conv-new-doc-link');
+ await expect.element(link).toBeInTheDocument();
+ await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
+ });
+
+ it('hides the link for a read-only user', async () => {
+ render(Page, { data: { ...withDocs, canWrite: false } });
+ await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/routes/documents/new/+page.server.ts b/frontend/src/routes/documents/new/+page.server.ts
index 4cfe77e7..6ef92f14 100644
--- a/frontend/src/routes/documents/new/+page.server.ts
+++ b/frontend/src/routes/documents/new/+page.server.ts
@@ -5,10 +5,12 @@ import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({
fetch,
- locals
+ locals,
+ url
}: {
fetch: typeof globalThis.fetch;
locals: App.Locals;
+ url: URL;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
@@ -16,14 +18,41 @@ export async function load({
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
- const api = createApiClient(fetch);
- const personsResult = await api.GET('/api/persons');
+ const senderId = url.searchParams.get('senderId') || '';
+ const receiverId = url.searchParams.get('receiverId') || '';
- if (!personsResult.response.ok) {
- return { persons: [] };
+ const api = createApiClient(fetch);
+
+ let initialSenderName = '';
+ let initialReceivers: { id: string; firstName: string; lastName: string }[] = [];
+
+ const requests: Promise[] = [];
+
+ if (senderId) {
+ requests.push(
+ api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
+ if (data) initialSenderName = `${data.firstName} ${data.lastName}`;
+ })
+ );
}
- return { persons: personsResult.data };
+ if (receiverId) {
+ requests.push(
+ api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
+ if (data)
+ initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }];
+ })
+ );
+ }
+
+ const [personsResult] = await Promise.all([api.GET('/api/persons'), ...requests]);
+
+ return {
+ persons: personsResult.response.ok ? personsResult.data : [],
+ initialSenderId: senderId,
+ initialSenderName,
+ initialReceivers
+ };
}
export const actions = {
diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte
index e1b1d2d4..f6808de6 100644
--- a/frontend/src/routes/documents/new/+page.svelte
+++ b/frontend/src/routes/documents/new/+page.svelte
@@ -3,13 +3,16 @@ import { enhance } from '$app/forms';
import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
+import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
-let { form } = $props();
+let { data, form } = $props();
let tags: string[] = $state([]);
-let senderId = $state('');
-let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]);
+let senderId = $state(untrack(() => data.initialSenderId));
+let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
+ untrack(() => data.initialReceivers)
+);
let dateDisplay = $state('');
let dateIso = $state('');
@@ -120,7 +123,12 @@ function handleDateInput(e: Event) {
diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts
new file mode 100644
index 00000000..7968fe30
--- /dev/null
+++ b/frontend/src/routes/documents/new/page.svelte.spec.ts
@@ -0,0 +1,71 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import Page from './+page.svelte';
+
+afterEach(cleanup);
+
+// ─── Test data ────────────────────────────────────────────────────────────────
+
+const baseData = {
+ persons: [],
+ initialSenderId: '',
+ initialSenderName: '',
+ initialReceivers: []
+};
+
+// ─── Prefill – sender ─────────────────────────────────────────────────────────
+
+describe('New document page – sender prefill', () => {
+ it('shows an empty sender input when no senderId is in the URL', async () => {
+ render(Page, { data: baseData });
+ const input = document.querySelector('#senderId-search');
+ expect(input?.value).toBe('');
+ });
+
+ it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
+ render(Page, {
+ data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
+ });
+ const input = document.querySelector('#senderId-search');
+ expect(input?.value).toBe('Hans Müller');
+ });
+
+ it('sets the hidden senderId input to the prefilled ID', async () => {
+ render(Page, {
+ data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
+ });
+ const hidden = document.querySelector(
+ 'input[type="hidden"][name="senderId"]'
+ );
+ expect(hidden?.value).toBe('p1');
+ });
+});
+
+// ─── Prefill – receiver ───────────────────────────────────────────────────────
+
+describe('New document page – receiver prefill', () => {
+ it('shows no receiver chips when initialReceivers is empty', async () => {
+ render(Page, { data: baseData });
+ await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
+ });
+
+ it('shows a receiver chip when initialReceivers has a person', async () => {
+ const data = {
+ ...baseData,
+ initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
+ };
+ render(Page, { data });
+ await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
+ });
+
+ it('renders a hidden receiverIds input for the prefilled receiver', async () => {
+ const data = {
+ ...baseData,
+ initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
+ };
+ render(Page, { data });
+ const hidden = document.querySelector('input[name="receiverIds"]');
+ expect(hidden?.value).toBe('p2');
+ });
+});