refactor(frontend): delete dead conversations/ route (#193)

Remove the old conversations page that was superseded by briefwechsel/.
No navigation link pointed to /conversations; it was unreachable through
the UI. Deletes 5 files, removes 14 orphaned i18n keys from de/en/es
message bundles, and removes E2E tests that navigated to /conversations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 12:43:40 +02:00
parent 4b8e0637ce
commit 731cdc75ab
10 changed files with 1 additions and 806 deletions

View File

@@ -24,7 +24,7 @@ test.describe('Authentication', () => {
});
test('protected routes redirect to /login without session', async ({ page }) => {
for (const url of ['/documents/new', '/persons', '/conversations']) {
for (const url of ['/documents/new', '/persons', '/briefwechsel']) {
await page.goto(url);
await expect(page).toHaveURL(/\/login/);
}

View File

@@ -181,132 +181,3 @@ test.describe('Person detail — sent and received documents', () => {
// If no person has dated documents, the test is a no-op (year range is optional)
});
});
test.describe('Person detail — conversations link', () => {
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
page
}) => {
await page.goto('/persons');
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
const href = await firstLink.getAttribute('href');
const personId = href!.split('/persons/')[1];
await firstLink.click();
await page.waitForSelector('[data-hydrated]');
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
if ((await chip.count()) > 0) {
const chipHref = await chip.getAttribute('href');
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
}
});
});
test.describe('Conversations', () => {
test('shows the empty state when no persons are selected', async ({ page }) => {
await page.goto('/conversations');
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
});
test('nav link is active on the conversations page', async ({ page }) => {
await page.goto('/conversations');
const navLink = page.getByRole('link', { name: 'Konversationen' });
await expect(navLink).toHaveClass(/bg-nav-active/);
});
test('sort toggle changes the button label', async ({ page }) => {
await page.goto('/conversations');
await page.waitForSelector('[data-hydrated]');
const btn = page.getByRole('button', { name: /Sortierung/i });
await expect(btn).toContainText('Neueste zuerst');
await btn.click();
await expect(page).toHaveURL(/dir=ASC/);
await expect(btn).toContainText('Älteste zuerst');
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});
test.describe('Conversations — enhancements', () => {
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
// Navigate directly by URL so the test doesn't rely on typeahead interaction
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
// Resolve person IDs from the persons list
await page.goto('/persons');
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
const hansHref = await hansLink.getAttribute('href');
const hansId = hansHref!.split('/').pop()!;
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
const annaHref = await annaLink.getAttribute('href');
const annaId = annaHref!.split('/').pop()!;
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
await page.waitForURL(/senderId=/);
}
test('shows document count and year range summary when both persons are selected', async ({
page
}) => {
await loadHansAnnaConversation(page);
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 19231965
await expect(page.getByTestId('conv-summary')).toContainText('2');
await expect(page.getByTestId('conv-summary')).toContainText('1923');
await expect(page.getByTestId('conv-summary')).toContainText('1965');
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
});
test('shows year dividers between documents from different years', async ({ page }) => {
await loadHansAnnaConversation(page);
// Expect at least two year dividers (1923 and 1965)
await expect(page.getByTestId('year-divider').first()).toBeVisible();
const dividers = page.getByTestId('year-divider');
const texts = await dividers.allTextContents();
expect(texts.some((t) => t.includes('1923'))).toBe(true);
expect(texts.some((t) => t.includes('1965'))).toBe(true);
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
});
test('swap button switches sender and receiver and reloads', async ({ page }) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const originalSenderId = url.searchParams.get('senderId')!;
const originalReceiverId = url.searchParams.get('receiverId')!;
await page.getByTestId('conv-swap-btn').click();
// Wait for the URL to reflect the swapped IDs (not just any URL with senderId=)
await page.waitForURL(
(url) => new URL(url).searchParams.get('senderId') === originalReceiverId
);
const swappedUrl = new URL(page.url());
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
});
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
page
}) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const senderId = url.searchParams.get('senderId')!;
const receiverId = url.searchParams.get('receiverId')!;
const link = page.getByTestId('conv-new-doc-link');
await expect(link).toBeVisible();
const href = await link.getAttribute('href');
expect(href).toContain(`senderId=${senderId}`);
expect(href).toContain(`receiverId=${receiverId}`);
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
});
test('does not show swap button or new document link when only one person is selected', async ({
page
}) => {
await page.goto('/conversations');
await page.waitForURL('/conversations');
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
});
});

View File

@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Häufige Korrespondenten",
"person_correspondents_hint": "klicken für Konversation",
"person_show_more": "+ {count} weitere anzeigen",
"conv_heading": "Briefwechsel",
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
"conv_label_person_a": "Person A (Absender)",
"conv_label_person_b": "Korrespondent",
"conv_label_from": "Zeitraum von",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Neueste zuerst",
"conv_sort_oldest": "Älteste zuerst",
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
"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 diesem Briefwechsel",
"conv_label_correspondent_optional": "Korrespondent",
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Zeitraum",
"conv_strip_from_placeholder": "Von…",
"conv_strip_to_placeholder": "Bis…",
"conv_strip_all_correspondents": "Alle Korrespondenten",
"conv_strip_sort_newest": "Neueste",
"conv_strip_sort_oldest": "Älteste",
"conv_suggestions_heading": "Häufigste Korrespondenten",
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
"conv_letters_count": "{count} Briefe",
"conv_empty_search_placeholder": "Person suchen…",
"conv_hero_divider": "oder",
"conv_empty_recent_label": "Zuletzt geöffnet",
"conv_asym_sent": "{count} von {name} →",
"conv_asym_received": "{count} von {name} ←",
"conv_no_party": "—",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",

View File

@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Frequent correspondents",
"person_correspondents_hint": "click to view conversation",
"person_show_more": "+ {count} more",
"conv_heading": "Letters",
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
"conv_label_person_a": "Person A (Sender)",
"conv_label_person_b": "Correspondent",
"conv_label_from": "Period from",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Newest first",
"conv_sort_oldest": "Oldest first",
"conv_empty_heading": "Whose letters would you like to read?",
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
"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 exchange",
"conv_label_correspondent_optional": "Correspondent",
"conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
"conv_hint_single_person_filtered": "All letters from {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Period",
"conv_strip_from_placeholder": "From…",
"conv_strip_to_placeholder": "To…",
"conv_strip_all_correspondents": "All correspondents",
"conv_strip_sort_newest": "Newest",
"conv_strip_sort_oldest": "Oldest",
"conv_suggestions_heading": "Top correspondents",
"conv_suggestions_all_label": "All correspondents of {name}",
"conv_letters_count": "{count} letters",
"conv_empty_search_placeholder": "Search person…",
"conv_hero_divider": "or",
"conv_empty_recent_label": "Recently opened",
"conv_asym_sent": "{count} from {name} →",
"conv_asym_received": "{count} from {name} ←",
"conv_no_party": "—",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",

View File

@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Corresponsales frecuentes",
"person_correspondents_hint": "clic para ver conversación",
"person_show_more": "+ {count} más",
"conv_heading": "Cartas",
"conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
"conv_label_person_a": "Persona A (Remitente)",
"conv_label_person_b": "Corresponsal",
"conv_label_from": "Período desde",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Más reciente primero",
"conv_sort_oldest": "Más antiguo primero",
"conv_empty_heading": "¿De quién desea leer las cartas?",
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
"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 este intercambio",
"conv_label_correspondent_optional": "Corresponsal",
"conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
"conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Período",
"conv_strip_from_placeholder": "Desde…",
"conv_strip_to_placeholder": "Hasta…",
"conv_strip_all_correspondents": "Todos los corresponsales",
"conv_strip_sort_newest": "Más reciente",
"conv_strip_sort_oldest": "Más antiguo",
"conv_suggestions_heading": "Corresponsales frecuentes",
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
"conv_letters_count": "{count} cartas",
"conv_empty_search_placeholder": "Buscar persona…",
"conv_hero_divider": "o",
"conv_empty_recent_label": "Recientemente abiertos",
"conv_asym_sent": "{count} de {name} →",
"conv_asym_received": "{count} de {name} ←",
"conv_no_party": "—",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",

View File

@@ -1,64 +0,0 @@
import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server';
export async function load({ url, fetch }) {
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC';
const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
const requests: Promise<void>[] = [];
if (senderId && receiverId) {
requests.push(
api
.GET('/api/documents/conversation', {
params: {
query: {
senderId,
receiverId,
dir,
from: from || undefined,
to: to || undefined
}
}
})
.then(({ data }) => {
documents = data ?? [];
})
);
}
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
const p = data as { displayName: string } | undefined;
if (p) senderName = p.displayName;
})
);
}
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
const p = data as { displayName: string } | undefined;
if (p) receiverName = p.displayName;
})
);
}
await Promise.all(requests);
return {
documents,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
}

View File

@@ -1,104 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import ConversationFilterBar from './ConversationFilterBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
let { data } = $props();
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Sync with server data after navigation
$effect(() => {
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
});
function applyFilters() {
const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`/conversations?${params.toString()}`, { keepFocus: true });
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function swapPersons() {
const tmp = senderId;
senderId = receiverId;
receiverId = tmp;
applyFilters();
}
</script>
<div class="mx-auto max-w-5xl px-4 py-10">
<!-- Page Header -->
<div class="mb-8 border-b border-ink/10 pb-4">
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
<p class="mt-2 font-sans text-sm text-ink-2">
{m.conv_subtitle()}
</p>
</div>
<ConversationFilterBar
bind:senderId={senderId}
bind:receiverId={receiverId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
onswapPersons={swapPersons}
/>
<!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId}
<div
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
>
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/></svg
>
</div>
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
</div>
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm"
>
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<ConversationTimeline
documents={data.documents}
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
/>
{/if}
</div>

View File

@@ -1,142 +0,0 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
senderId = $bindable(''),
receiverId = $bindable(''),
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
initialSenderName = '',
initialReceiverName = '',
onapplyFilters,
ontoggleSort,
onswapPersons
}: {
senderId?: string;
receiverId?: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
initialSenderName?: string;
initialReceiverName?: string;
onapplyFilters: () => void;
ontoggleSort: () => void;
onswapPersons: () => void;
} = $props();
</script>
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={initialSenderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
<!-- Swap button -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={onswapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={initialReceiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- Date To -->
<div>
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={ontoggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>

View File

@@ -1,160 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import GroupDivider from '$lib/components/GroupDivider.svelte';
import { groupDocuments } from '$lib/utils/groupDocuments';
let {
documents,
senderId,
receiverId,
canWrite
}: {
documents: {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
status: string;
sender?: {
id: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
}[];
senderId: string;
receiverId: string;
canWrite: boolean;
} = $props();
const documentYears = $derived(
documents
.map((doc) =>
doc.documentDate ? new Date(doc.documentDate + 'T12:00:00').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);
const documentGroups = $derived.by(() => groupDocuments(documents, 'DATE', ''));
</script>
<!-- Summary bar -->
<div class="mb-4 flex items-center justify-between">
{#if yearFrom !== null && yearTo !== null}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
</p>
{:else}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{documents.length}
</p>
{/if}
{#if canWrite}
<a
data-testid="conv-new-doc-link"
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
></path>
</svg>
{m.conv_new_doc_link()}
</a>
{/if}
</div>
<!-- CHAT CONTAINER -->
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
></div>
<div class="p-6 md:p-8">
<div class="relative z-10 flex flex-col gap-4">
{#each documentGroups as group (group.label)}
{#if group.label}
<GroupDivider label={group.label} />
{/if}
{#each group.documents as doc (doc.id)}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR -->
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink'}"
>
{#if doc.sender}
{doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
</div>
</div>
<!-- BUBBLE CARD -->
<a
href="/documents/{doc.id}"
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'rounded-br-none border-primary bg-primary text-primary-fg'
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
>
<!-- Header -->
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-primary-fg'
: 'text-ink'}"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Dot -->
<span
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
title={doc.status}
>
</span>
</div>
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-primary-fg/70'
: 'text-ink-2'}"
>
<span class="flex items-center">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">
{doc.location}
</span>
{/if}
</div>
</a>
</div>
</div>
{/each}
{/each}
</div>
</div>
</div>

View File

@@ -1,164 +0,0 @@
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 = {
user: undefined,
canWrite: true,
canAnnotate: false,
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<string, unknown> = {}) => ({
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: undefined,
filePath: undefined,
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 empty-state heading when no persons are selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/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 });
document.querySelector<HTMLElement>('[data-testid="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('group-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('group-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('group-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('group-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('group-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();
});
});