Compare commits

...

10 Commits

Author SHA1 Message Date
Marcel
9d6c7b8605 test(DateInput): add Vitest specs for DateInput component and date utils
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) Failing after 4m3s
CI / Backend Unit Tests (pull_request) Failing after 2m24s
CI / E2E Tests (pull_request) Failing after 1h46m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:20:07 +02:00
Marcel
56d79c919e fix(PersonTypeahead): sync searchTerm when initialName prop changes
After a person swap the parent navigates to a new URL and the server
returns swapped names. The component's searchTerm was only set once from
initialName at mount time ($state(initialName) captures the initial value
only). Adding a reactive $effect ensures the displayed name updates
whenever initialName changes — fixing the swap button showing stale names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:53:45 +02:00
Marcel
3318b5f1c6 fix(korrespondenz): larger empty state text, hide divider+chips when no history
- Icon: 36px (was 24px), heading text-xl font-black (was text-sm)
- Subtext: text-base (was text-xs), max-w-sm (was max-w-[280px])
- Search button: h-10, text-sm, full max-w-sm width (was fixed 260px)
- Recent persons divider and chip block moved inside the {#if recentPersons.length > 0}
  block so no blank "oder" section renders when localStorage is empty
- Chips: text-sm px-4 py-2 (was text-xs), avatar h-5 w-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:43:55 +02:00
Marcel
71eaca9495 fix(korrespondenz): flush strips to top, larger year divider and row text
- Wrap strips in -mt-6 to negate main's py-6 top padding; strips now flush at top
- Year divider: text-2xl font-black for the year number (was text-[15px])
- Year count and all log row meta text: text-sm minimum (was text-xs)
- Asymmetry bar counts: text-sm (was text-[10px])
- No-results box: replace hardcoded hex with theme tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:39:56 +02:00
Marcel
a3a7af123d fix(korrespondenz): scale up strip rows to readable sizes
Spec measurements (7px/8px/9px text) were spec-document zoom artifacts — not
viable at real browser scale. Updated to readable compact sizes:
- Row 1 compact label: text-xs (12px) uppercase instead of 7px
- Row 1 input: h-9 text-sm (36px/14px) instead of 30px/9px
- Row 1 swap button: h-9 w-9 to align with taller inputs
- Row 2 date inputs: h-8 text-xs (32px/12px) instead of 22px/8px
- Row 2 label/count/sort: text-xs instead of 7px/8px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:35:47 +02:00
Marcel
5fd7e41492 fix(korrespondenz): dark theme, compact strip labels, year divider size, chevron alignment
- Add compact prop to PersonTypeahead: 7px uppercase label, 30px h input (matches spec FL/FI)
- Replace all hardcoded hex in 6 korrespondenz components with theme tokens (bg-surface,
  bg-muted, bg-canvas, border-line, text-ink, text-primary, text-accent, etc.)
- Fix year divider: text-[15px] font-black (spec: 15px/900)
- Fix log row chevron: items-center instead of items-start for vertical centering
- Fix recent-persons persistence: move persistRecentPerson to post-navigation $effect so
  senderName is resolved from server before stored in localStorage
- Add metadataComplete field to makeDoc() fixture to satisfy updated Document type
- Restore opacity-0 on swap button when only one person is set (matches spec + test)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:33:23 +02:00
Marcel
0387e9f428 fix(korrespondenz): address 10 visual and functional regressions
- Strip full-bleed: remove max-w container, put strips at page level
- Remove page heading/subtitle above strip (not in spec)
- Swap button always visible (drop opacity-0, keep pointer-events-none)
- Korrespondent placeholder "Alle Korrespondenten" + label "— optional"
- Add placeholder prop to PersonTypeahead; add onfocused callback prop
- "Person suchen" button now focuses #senderId-search instead of no-op navigate
- Wire CorrespondentSuggestionsDropdown on correspondent field focus
- Hint bar: bold name via <strong>, year-only dates (no ISO strings)
- Asymmetry bar: use first name only to prevent label overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:57:00 +02:00
Marcel
49f6b0a8c7 test(korrespondenz): add Playwright E2E happy-path journey
Cover: empty state loads with search heading, nav link goes to /korrespondenz,
single-person mode shows hint bar, sort toggle updates dir param, bilateral mode
skips gracefully when no co-correspondents exist, swap button reflects swapped IDs
in URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:06:14 +02:00
Marcel
1b95d9472b test(korrespondenz): update and expand Vitest component specs
Update empty-state, swap-button, and new-doc-link tests to match redesigned
components. Add new tests for: single-person hint bar visibility, recent-persons
chips from localStorage, corrupt localStorage graceful handling, Row 2
aria-disabled state, and strip letter count in single-person and bilateral modes.

Fix CorrespondenzEmptyState to use {id, name} storage format matching
persistRecentPerson in +page.svelte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:04:27 +02:00
Marcel
4f5f8255a1 feat(korrespondenz): wire up +page.svelte orchestrator with new components
Compose CorrespondenzPersonBar, CorrespondenzFilterControls, SinglePersonHintBar,
CorrespondenzEmptyState, and updated ConversationTimeline. Add localStorage
recent-persons persistence on applyFilters, single-person mode gate, and
canWrite derived from user groups in load function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:59:33 +02:00
13 changed files with 770 additions and 183 deletions

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test';
test.describe('Korrespondenz empty state', () => {
test('shows the search heading when no person is selected', async ({ page }) => {
await page.goto('/korrespondenz');
await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });
});
test('nav link goes to /korrespondenz', async ({ page }) => {
await page.goto('/');
// Click the nav link (desktop text or mobile icon)
const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first();
await navLink.click();
await expect(page).toHaveURL(/\/korrespondenz/);
});
});
test.describe('Korrespondenz single-person mode', () => {
test('shows hint bar and documents when navigated with senderId', async ({ page }) => {
// Get a real person ID from the persons list
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
// Extract the person ID from the URL
const personId = page.url().split('/persons/')[1].split('?')[0];
// Navigate to korrespondenz in single-person mode
await page.goto(`/korrespondenz?senderId=${personId}`);
// Hint bar should be visible
await expect(page.getByText(/Alle Briefe von/i)).toBeVisible();
// Filter controls should be active (not dimmed)
const filterStrip = page.locator('[aria-disabled="false"]').first();
await expect(filterStrip).toBeAttached();
await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' });
});
test('sort toggle changes URL direction param', async ({ page }) => {
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
const personId = page.url().split('/persons/')[1].split('?')[0];
await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`);
await page.getByTestId('conv-sort-btn').click();
await expect(page).toHaveURL(/dir=ASC/);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' });
});
});
test.describe('Korrespondenz bilateral mode', () => {
test('shows asymmetry bar when both persons have shared documents', async ({ page }) => {
// Navigate to a person then follow a co-correspondent suggestion if available
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
const senderId = page.url().split('/persons/')[1].split('?')[0];
// Try to find a co-correspondent link from the person detail page
const corrLink = page
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
.first();
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await corrLink.click();
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
// Hint bar should NOT be shown in bilateral mode
await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' });
} else {
// No bilateral data available for this person — skip with a note
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
}
});
test('swap button swaps sender and receiver in URL', async ({ page }) => {
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
const senderId = page.url().split('/persons/')[1].split('?')[0];
const corrLink = page
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
.first();
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
const href = await corrLink.getAttribute('href');
await corrLink.click();
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
// Extract original receiverId from the href
const url = new URL(href!, 'http://x');
const originalReceiverId = url.searchParams.get('receiverId')!;
// Click swap
await page.getByTestId('conv-swap-btn').click();
// After swap the former receiver is now senderId
await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`));
await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' });
} else {
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
}
});
});

View File

@@ -0,0 +1,206 @@
import { describe, expect, it, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DateInput from './DateInput.svelte';
/** Wait one macrotask so Svelte can flush reactive DOM updates. */
const domFlush = () => new Promise<void>((r) => setTimeout(r, 50));
afterEach(() => cleanup());
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('DateInput rendering', () => {
it('renders a text input with inputmode=numeric and maxlength=10', async () => {
render(DateInput, {});
const input = page.getByRole('textbox');
await expect.element(input).toBeInTheDocument();
await expect.element(input).toHaveAttribute('inputmode', 'numeric');
await expect.element(input).toHaveAttribute('maxlength', '10');
});
it('has default placeholder from paraglide', async () => {
render(DateInput, {});
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', 'TT.MM.JJJJ');
});
it('accepts a custom placeholder', async () => {
render(DateInput, { placeholder: 'Geburtsdatum' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', 'Geburtsdatum');
});
it('passes id prop to the input', async () => {
render(DateInput, { id: 'my-date' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('id', 'my-date');
});
});
// ─── Init from value ──────────────────────────────────────────────────────────
describe('DateInput init from value', () => {
it('displays ISO value in German format on mount', async () => {
render(DateInput, { value: '2024-12-20' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('20.12.2024');
});
it('starts empty and error-free when no value is given', async () => {
let errorMessage: string | null = null;
render(DateInput, {
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('');
expect(errorMessage).toBeNull();
});
});
// ─── Typing valid date ────────────────────────────────────────────────────────
describe('DateInput typing a valid date', () => {
it('auto-formats to DD.MM.YYYY and updates value to ISO', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await input.fill('20122024');
await expect.element(input).toHaveValue('20.12.2024');
expect(value).toBe('2024-12-20');
expect(errorMessage).toBeNull();
});
});
// ─── Typing invalid month ─────────────────────────────────────────────────────
describe('DateInput typing a date with invalid month', () => {
it('sets errorMessage and clears value when month > 12', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await input.fill('22222222');
await expect.element(input).toHaveValue('22.22.2222');
expect(value).toBe('');
expect(errorMessage).not.toBeNull();
});
});
// ─── Typing partial date ──────────────────────────────────────────────────────
describe('DateInput typing a partial date', () => {
it('sets errorMessage and clears value when date is incomplete', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
await input.fill('2212');
await expect.element(input).toHaveValue('22.12');
expect(value).toBe('');
expect(errorMessage).not.toBeNull();
});
});
// ─── Clearing date ────────────────────────────────────────────────────────────
describe('DateInput clearing the date', () => {
it('resets value and errorMessage to null when cleared', async () => {
let value = '';
let errorMessage: string | null = null;
render(DateInput, {
get value() {
return value;
},
set value(v) {
value = v;
},
get errorMessage() {
return errorMessage;
},
set errorMessage(v) {
errorMessage = v;
}
});
const input = page.getByRole('textbox');
// Type a valid date first
await input.fill('20122024');
expect(value).toBe('2024-12-20');
// Now clear
await input.fill('');
expect(value).toBe('');
expect(errorMessage).toBeNull();
});
});
// ─── Hidden input ─────────────────────────────────────────────────────────────
describe('DateInput hidden input for form submission', () => {
it('renders a hidden input with the given name when name prop is set', async () => {
render(DateInput, { name: 'documentDate' });
const hidden = document.querySelector('input[type="hidden"][name="documentDate"]');
expect(hidden).not.toBeNull();
});
it('does not render a hidden input when name prop is absent', async () => {
render(DateInput, {});
const hidden = document.querySelector('input[type="hidden"]');
expect(hidden).toBeNull();
});
it('hidden input value reflects the ISO value', async () => {
render(DateInput, { name: 'documentDate', value: '' });
const input = page.getByRole('textbox');
await input.fill('20122024');
await domFlush();
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="documentDate"]'
);
expect(hidden?.value).toBe('2024-12-20');
});
});

View File

@@ -10,8 +10,11 @@ interface Props {
value?: string;
initialName?: string;
suggestedName?: string;
placeholder?: string;
compact?: boolean;
restrictToCorrespondentsOf?: string;
onchange?: (value: string) => void;
onfocused?: () => void;
}
let {
@@ -20,12 +23,23 @@ let {
value = $bindable(''),
initialName = '',
suggestedName = '',
placeholder,
compact = false,
restrictToCorrespondentsOf,
onchange
onchange,
onfocused
}: Props = $props();
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
// $effect is the correct pattern here — writable $derived is read-only and won't work.
// eslint-disable-next-line svelte/prefer-writable-derived
let searchTerm = $state(initialName);
// Sync display text when the selected person changes externally (e.g. swap, navigation).
$effect(() => {
searchTerm = initialName;
});
$effect(() => {
const suggested = suggestedName;
if (suggested && !untrack(() => value)) {
@@ -79,6 +93,7 @@ function handleInput() {
}
function handleFocus() {
onfocused?.();
showDropdown = true;
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
@@ -120,7 +135,13 @@ function clickOutside(node: HTMLElement) {
</script>
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-ink-2">{label}</label>
<label
for={name}
class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'}
>{label}</label
>
<input type="hidden" name={name} bind:value={value} />
@@ -131,8 +152,10 @@ function clickOutside(node: HTMLElement) {
bind:value={searchTerm}
oninput={handleInput}
onfocus={handleFocus}
placeholder={m.comp_typeahead_placeholder()}
class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent"
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
class={compact
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
/>
{#if showDropdown && (results.length > 0 || loading)}

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { formatGermanDateInput, isoToGerman, germanToIso } from './date';
// ─── isoToGerman ─────────────────────────────────────────────────────────────
describe('isoToGerman', () => {
it('converts a valid ISO date to DD.MM.YYYY', () => {
expect(isoToGerman('2024-12-20')).toBe('20.12.2024');
});
it('returns empty string for empty input', () => {
expect(isoToGerman('')).toBe('');
});
it('returns empty string for invalid format', () => {
expect(isoToGerman('not-a-date')).toBe('');
});
});
// ─── germanToIso ─────────────────────────────────────────────────────────────
describe('germanToIso', () => {
it('converts DD.MM.YYYY to ISO', () => {
expect(germanToIso('20.12.2024')).toBe('2024-12-20');
});
it('returns empty string for partial input', () => {
expect(germanToIso('20.12')).toBe('');
});
it('returns empty string for empty input', () => {
expect(germanToIso('')).toBe('');
});
});
// ─── formatGermanDateInput ────────────────────────────────────────────────────
describe('formatGermanDateInput digit stream (no dots typed)', () => {
it('leaves 12 digits as-is', () => {
expect(formatGermanDateInput('2')).toBe('2');
expect(formatGermanDateInput('20')).toBe('20');
});
it('auto-inserts dot after 2 digits for 34 digit input', () => {
expect(formatGermanDateInput('201')).toBe('20.1');
expect(formatGermanDateInput('2012')).toBe('20.12');
});
it('auto-inserts two dots for 58 digit input', () => {
expect(formatGermanDateInput('20121')).toBe('20.12.1');
expect(formatGermanDateInput('20122024')).toBe('20.12.2024');
});
it('ignores digits beyond 8', () => {
expect(formatGermanDateInput('201220249')).toBe('20.12.2024');
});
});
describe('formatGermanDateInput manual dot entry with padding', () => {
it('pads single-digit day to 2 digits when dot is typed after it', () => {
expect(formatGermanDateInput('3.')).toBe('03.');
});
it('does not pad a 2-digit day', () => {
expect(formatGermanDateInput('03.')).toBe('03.');
expect(formatGermanDateInput('20.')).toBe('20.');
});
it('pads single-digit month to 2 digits when dot is typed after it', () => {
expect(formatGermanDateInput('03.3.')).toBe('03.03.');
});
it('does not pad a 2-digit month', () => {
expect(formatGermanDateInput('03.12.')).toBe('03.12.');
});
it('pads both day and month in a fully typed date', () => {
expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012');
});
it('pads only day when month is already 2 digits', () => {
expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024');
});
it('pads only month when day is already 2 digits', () => {
expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024');
});
it('handles a complete date entered with manual dots and no padding needed', () => {
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
});
it('overflows excess day digits into month when dot follows', () => {
expect(formatGermanDateInput('123.')).toBe('12.3');
});
it('caps year digits at 4', () => {
expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024');
});
it('overflows excess month digits into year (digit stream then continue typing)', () => {
// User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122"
expect(formatGermanDateInput('20.122')).toBe('20.12.2');
});
it('continues building year after overflow', () => {
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
});
});

View File

@@ -1,13 +1,18 @@
import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server';
export async function load({ url, fetch }) {
export async function load({ url, fetch, locals }) {
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 canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = [];
@@ -56,6 +61,7 @@ export async function load({ url, fetch }) {
return {
documents,
canWrite,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};

View File

@@ -2,9 +2,12 @@
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 CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
import CorrespondenzEmptyState from './CorrespondenzEmptyState.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -14,15 +17,42 @@ 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
// Derived name states — kept as reactive copies so ConversationTimeline always has current names
let senderName = $state(untrack(() => data.initialValues.senderName));
let receiverName = $state(untrack(() => data.initialValues.receiverName));
// Sync with server data after navigation; persist recent persons once the name is resolved
$effect(() => {
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
senderName = data.initialValues.senderName;
receiverName = data.initialValues.receiverName;
if (data.filters.senderId && data.initialValues.senderName) {
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
}
});
const isSinglePerson = $derived(!!senderId && !receiverId);
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
const MAX_RECENT = 5;
function persistRecentPerson(id: string, name: string) {
if (!id) return;
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
const existing: { id: string; name: string }[] = raw ? JSON.parse(raw) : [];
const filtered = existing.filter((p) => p.id !== id);
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
} catch {
// localStorage unavailable — silently ignore
}
}
function applyFilters() {
const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId);
@@ -44,51 +74,59 @@ function swapPersons() {
receiverId = tmp;
applyFilters();
}
function selectPerson(id: string) {
if (!id) {
document.querySelector<HTMLInputElement>('#senderId-search')?.focus();
return;
}
senderId = id;
receiverId = '';
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
<!-- Strips — pulled up to negate main's py-6 top padding so they sit flush -->
<div class="-mt-6">
<!-- Strip: Row 1 — full width, no container -->
<CorrespondenzPersonBar
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>
<!-- Strip: Row 2 — full width -->
<CorrespondenzFilterControls
senderId={senderId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
documentCount={data.documents.length}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
/>
<!-- Single-person hint bar -->
{#if isSinglePerson}
<SinglePersonHintBar
senderName={senderName}
fromDate={fromDate || undefined}
toDate={toDate || undefined}
sortDir={sortDir}
/>
{/if}
</div>
<!-- Content area with padding -->
<div class="px-[18px] py-[14px]">
{#if !senderId}
<CorrespondenzEmptyState onSelectPerson={selectPerson} />
{: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"
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted 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>
@@ -99,6 +137,8 @@ function swapPersons() {
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
senderName={senderName}
receiverName={receiverName}
/>
{/if}
</div>

View File

@@ -51,6 +51,9 @@ const outPct = $derived(documents.length > 0 ? (outCount / documents.length) * 1
const isBilateral = $derived(!!senderId && !!receiverId);
const shortSenderName = $derived(senderName?.split(' ')[0] ?? senderName ?? '');
const shortReceiverName = $derived(receiverName?.split(' ')[0] ?? receiverName ?? '');
function statusDotClass(status: string): string {
const map: Record<string, string> = {
PLACEHOLDER: 'bg-yellow-400',
@@ -77,30 +80,30 @@ const newDocUrl = $derived(
{#if isBilateral && documents.length > 0}
<div
class="flex flex-col gap-1 border-b border-[#E8E4DF] bg-[#F7F5F2] px-[18px] py-2"
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
role="img"
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
>
<div class="flex justify-between text-[10px] font-bold">
<span class="text-[#002850]">{outCount} von {senderName}</span>
<span class="text-[#0F5755]">{inCount} von {receiverName}</span>
<div class="flex justify-between text-sm font-bold">
<span class="text-primary">{outCount} von {shortSenderName}</span>
<span class="text-accent">{inCount} von {shortReceiverName}</span>
</div>
<div class="flex h-[5px] overflow-hidden rounded-full bg-[#E0DDD6]">
<div class="h-full bg-[#002850] transition-all" style="width: {outPct}%"></div>
<div class="h-full bg-[#A6DAD8] transition-all" style="width: {100 - outPct}%"></div>
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
<div class="h-full bg-accent transition-all" style="width: {100 - outPct}%"></div>
</div>
</div>
{/if}
<div class="overflow-hidden rounded-sm border border-[#E0DDD6] bg-white">
<div class="overflow-hidden rounded-sm border border-line bg-surface">
{#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
{#if showYearDivider && year !== null}
<div
data-testid="year-divider"
class="flex items-baseline gap-2 border-t-2 border-b border-[#C8C4BE] border-[#D8D4CE] bg-[#F0EDE6] px-[14px] py-[6px]"
class="flex items-baseline gap-3 border-t-2 border-b border-line bg-muted px-[14px] py-[8px]"
>
<span class="text-base font-black tracking-tight text-[#002850]">{year}</span>
<span class="text-xs font-bold text-[#AAA]">{countsByYear.get(year) ?? 0} Briefe</span>
<span class="text-2xl font-black tracking-tight text-primary">{year}</span>
<span class="text-sm font-bold text-ink-3">{countsByYear.get(year) ?? 0} Briefe</span>
</div>
{/if}
@@ -109,31 +112,31 @@ const newDocUrl = $derived(
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
? formatDate(doc.documentDate)
: ''}"
class="group flex min-h-[44px] cursor-pointer items-start gap-[9px] border-b border-l-[3px] border-[#EDEBE4] px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-[#F7F5F2]"
class:border-l-[#002850]={isOut}
class:border-l-[#A6DAD8]={!isOut}
class="group flex min-h-[44px] cursor-pointer items-center gap-[9px] border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-muted"
class:border-l-primary={isOut}
class:border-l-accent={!isOut}
>
<span
class="w-[14px] shrink-0 pt-[1px] text-xs font-black"
class:text-[#002850]={isOut}
class:text-[#0F5755]={!isOut}
class="w-[16px] shrink-0 text-sm font-black"
class:text-primary={isOut}
class:text-accent={!isOut}
aria-hidden="true"
>
{isOut ? '→' : '←'}
</span>
<div class="min-w-0 flex-1">
<div class="mb-[2px] truncate text-sm font-bold text-[#0D2240]">
<div class="mb-[2px] truncate text-sm font-bold text-ink">
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center gap-[5px] text-xs text-[#888]">
<div class="flex items-center gap-[5px] text-sm text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
{#if doc.location}
<span class="text-[#D1CCC8]">·</span>
<span class="text-line">·</span>
<span>{doc.location}</span>
{/if}
{#if !receiverId}
<span class="text-[#D1CCC8]">·</span>
<span class="text-line">·</span>
<span>{otherPartyName(doc)}</span>
{/if}
<span
@@ -144,18 +147,18 @@ const newDocUrl = $derived(
</div>
<span
class="shrink-0 text-[#888] opacity-0 transition-opacity group-hover:opacity-100"
class="shrink-0 text-sm text-ink-3 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"></span
>
</a>
{/each}
{#if canWrite}
<div class="flex justify-end border-t border-[#E8E4DF] px-[14px] py-[6px]">
<div class="flex justify-end border-t border-line px-[14px] py-[6px]">
<a
href={newDocUrl}
data-testid="conv-new-doc-link"
class="inline-flex items-center gap-1 text-xs font-bold text-[#002850]/50 transition-colors hover:text-[#002850]"
class="inline-flex items-center gap-1 text-xs font-bold text-primary/50 transition-colors hover:text-primary"
>
<svg
class="h-3 w-3"

View File

@@ -79,11 +79,11 @@ function getInitials(person: Correspondent): string {
role="listbox"
tabindex="-1"
aria-label={conv_suggestions_heading()}
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-[#E0DDD6] bg-white shadow-lg"
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-line bg-surface shadow-lg"
onkeydown={(e) => handleKeydown(e, e.currentTarget as HTMLElement)}
>
<!-- Heading -->
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-[#888] uppercase">
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{conv_suggestions_heading()}
</div>
@@ -94,33 +94,32 @@ function getInitials(person: Correspondent): string {
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[#333] hover:bg-[#F7F5F2] focus:bg-[#F7F5F2] focus:outline-none"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
onclick={() => onselect(person.id)}
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
>
<!-- Avatar with initials -->
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#002850] text-[10px] font-bold text-white"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
aria-hidden="true"
>
{getInitials(person)}
</span>
<!-- Svelte auto-escapes — do not use {@html} here. -->
{person.lastName}, {person.firstName}
<!-- TODO: show proportional letter-count bar when counts are available from the API -->
</div>
{/each}
{/if}
<!-- Separator -->
<div class="mt-1 border-t border-[#E0DDD6]"></div>
<div class="mt-1 border-t border-line"></div>
<!-- "Alle Korrespondenten" row -->
<div
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-[#333] hover:bg-[#F7F5F2] focus:bg-[#F7F5F2] focus:outline-none"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
onclick={() => onselect('')}
onkeydown={(e) => e.key === 'Enter' && onselect('')}
>

View File

@@ -4,8 +4,7 @@ import { conv_empty_search_placeholder, conv_empty_recent_label } from '$lib/mes
interface RecentPerson {
id: string;
firstName: string;
lastName: string;
name: string;
}
interface Props {
@@ -29,20 +28,20 @@ onMount(() => {
});
</script>
<div class="mx-auto flex max-w-sm flex-col items-center gap-4 py-16 text-center">
<div class="mx-auto flex max-w-lg flex-col items-center gap-5 py-12 text-center">
<!-- Icon circle -->
<div class="rounded-full bg-[#F0EDE6] p-4">
<div class="rounded-full bg-muted p-5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="text-[#002850]"
class="text-primary"
aria-hidden="true"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
@@ -51,10 +50,10 @@ onMount(() => {
</div>
<!-- Heading -->
<h2 class="font-serif text-sm font-black text-[#0D2240]">Korrespondenz durchsuchen</h2>
<h2 class="font-serif text-xl font-black text-ink">Korrespondenz durchsuchen</h2>
<!-- Subtext -->
<p class="max-w-[280px] text-xs text-[#888]">
<p class="max-w-sm text-base text-ink-3">
Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.
</p>
@@ -64,19 +63,19 @@ onMount(() => {
data-testid="conv-empty-search"
aria-label={conv_empty_search_placeholder()}
onclick={() => onSelectPerson('')}
class="flex h-[28px] w-[260px] items-center rounded-sm border border-[#D1D5DB] bg-[#F9F8F6] px-3 text-xs text-[#AAA] italic transition-colors hover:border-[#002850]"
class="flex h-10 w-full max-w-sm items-center rounded border border-line bg-muted px-4 text-sm text-ink-3 italic transition-colors hover:border-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-1.5 shrink-0"
class="mr-2 shrink-0"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
@@ -85,17 +84,17 @@ onMount(() => {
Person suchen…
</button>
<!-- Divider -->
<div class="flex w-full items-center gap-2">
<div class="flex-1 border-t border-[#E0DDD6]"></div>
<span class="text-[10px] font-bold tracking-wider text-[#AAA] uppercase">oder</span>
<div class="flex-1 border-t border-[#E0DDD6]"></div>
</div>
<!-- Recent persons -->
<!-- Recent persons — only shown when localStorage has entries -->
{#if recentPersons.length > 0}
<div class="flex w-full flex-col items-center gap-2">
<span class="text-[10px] font-bold tracking-widest text-[#888] uppercase">
<!-- Divider -->
<div class="flex w-full max-w-sm items-center gap-2">
<div class="flex-1 border-t border-line"></div>
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase">oder</span>
<div class="flex-1 border-t border-line"></div>
</div>
<div class="flex w-full max-w-sm flex-col items-center gap-3">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{conv_empty_recent_label()}
</span>
<div class="flex flex-wrap justify-center gap-2">
@@ -104,15 +103,15 @@ onMount(() => {
<button
type="button"
onclick={() => onSelectPerson(person.id)}
class="flex items-center gap-1.5 rounded-full border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-bold text-[#333] transition-colors hover:border-[#002850] hover:text-[#002850]"
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
>
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#002850] text-[10px] text-white"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
aria-hidden="true"
>
{person.firstName[0]}{person.lastName[0]}
{person.name.charAt(0).toUpperCase()}
</span>
<span class="hidden sm:inline">{person.lastName}, </span>{person.firstName}
{person.name}
</button>
{/each}
</div>

View File

@@ -33,13 +33,13 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
</script>
<div
class="flex items-center gap-[10px] border-b border-[#E0DDD6] bg-[#F7F5F2] px-4 py-[5px] transition-opacity sm:px-[18px]"
class="flex items-center gap-[10px] border-b border-line bg-muted px-4 py-[5px] transition-opacity sm:px-[18px]"
class:opacity-40={!senderId}
class:pointer-events-none={!senderId}
aria-disabled={!senderId}
>
<!-- Period label -->
<span class="hidden text-xs font-bold text-[#888] sm:block">
<span class="hidden text-xs font-bold tracking-wide text-ink-3 uppercase sm:block">
{conv_strip_period()}
</span>
@@ -50,15 +50,12 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
onchange={() => onapplyFilters()}
placeholder={conv_strip_from_placeholder()}
aria-label="Von"
class="h-[22px] min-h-[44px] w-[80px] rounded-[3px] border px-1 text-xs focus:outline-none sm:min-h-0"
class:border-[#002850]={!!fromDate}
class:text-[#333]={!!fromDate}
class:border-[#D1D5DB]={!fromDate}
class:text-[#AAA]={!fromDate}
class:italic={!fromDate}
class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none sm:min-h-0"
class:border-primary={!!fromDate}
class:border-line={!fromDate}
/>
<span class="text-xs text-[#AAA]"></span>
<span class="text-xs text-ink-3"></span>
<!-- To date -->
<input
@@ -67,20 +64,17 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
onchange={() => onapplyFilters()}
placeholder={conv_strip_to_placeholder()}
aria-label="Bis"
class="h-[22px] min-h-[44px] w-[80px] rounded-[3px] border px-1 text-xs focus:outline-none sm:min-h-0"
class:border-[#002850]={!!toDate}
class:text-[#333]={!!toDate}
class:border-[#D1D5DB]={!toDate}
class:text-[#AAA]={!toDate}
class:italic={!toDate}
class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none sm:min-h-0"
class:border-primary={!!toDate}
class:border-line={!toDate}
/>
<!-- Document count -->
<span
data-testid="conv-strip-count"
class="ml-auto text-xs font-bold"
class:text-[#002850]={hasDateFilter}
class:text-[#888]={!hasDateFilter}
class:text-primary={hasDateFilter}
class:text-ink-3={!hasDateFilter}
>
{conv_letters_count({ count: documentCount ?? 0 })}
</span>
@@ -92,11 +86,11 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
aria-label="Sortierung umkehren"
aria-pressed={sortDir === 'ASC'}
onclick={ontoggleSort}
class="flex h-[22px] min-h-[44px] items-center gap-1 rounded-[3px] border px-2 text-xs font-bold sm:min-h-0"
class:border-[#002850]={isActive}
class:text-[#002850]={isActive}
class:border-[#D1D5DB]={!isActive}
class:text-[#888]={!isActive}
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold sm:min-h-0"
class:border-primary={isActive}
class:text-primary={isActive}
class:border-line={!isActive}
class:text-ink-3={!isActive}
>
{#if sortDir === 'ASC'}
{conv_strip_sort_oldest()}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
interface Props {
senderId?: string;
@@ -20,9 +21,21 @@ let {
}: Props = $props();
let swapVisible = $derived(!!(senderId && receiverId));
let showSuggestions = $state(false);
function handleCorrespondentFocused() {
if (senderId) showSuggestions = true;
}
function handleSuggestionSelect(id: string) {
receiverId = id;
showSuggestions = false;
onapplyFilters();
}
</script>
<div class="flex items-end gap-[9px] border-b border-[#EAE7E0] bg-white px-4 py-[9px] sm:px-[18px]">
<div class="flex items-end gap-[9px] border-b border-line bg-surface px-4 py-[9px] sm:px-[18px]">
<!-- Person A -->
<div class="min-w-0 flex-1">
<PersonTypeahead
@@ -30,6 +43,7 @@ let swapVisible = $derived(!!(senderId && receiverId));
label="Person"
bind:value={senderId}
initialName={initialSenderName}
compact={true}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
@@ -41,7 +55,7 @@ let swapVisible = $derived(!!(senderId && receiverId));
type="button"
aria-label="Personen tauschen"
onclick={onswapPersons}
class="mb-1 flex h-7 w-7 shrink-0 items-center justify-center rounded border border-[#D1D5DB] bg-white text-[#888] transition-colors hover:border-[#002850] hover:text-[#002850]"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded border border-line bg-surface text-ink-3 transition-colors hover:border-primary hover:text-primary"
class:opacity-0={!swapVisible}
class:pointer-events-none={!swapVisible}
tabindex={swapVisible ? 0 : -1}
@@ -65,23 +79,32 @@ let swapVisible = $derived(!!(senderId && receiverId));
<!-- Korrespondent field -->
<div
class="min-w-0 flex-1"
class="relative min-w-0 flex-1"
class:[&_input]:border-dashed={!receiverId}
class:[&_input]:border-solid={!!receiverId}
class:[&_input]:bg-[#F9F8F6]={!receiverId}
class:[&_input]:bg-canvas={!receiverId}
>
<PersonTypeahead
name="receiverId"
label={receiverId ? 'Korrespondent' : 'Korrespondent'}
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
bind:value={receiverId}
initialName={initialReceiverName}
compact={true}
placeholder="Alle Korrespondenten"
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => onapplyFilters()}
onchange={() => {
showSuggestions = false;
onapplyFilters();
}}
onfocused={handleCorrespondentFocused}
/>
{#if !receiverId}
<span class="pointer-events-none absolute -mt-[1px] text-[11px] text-[#AAA] italic">
— optional
</span>
{#if showSuggestions && senderId && !receiverId}
<CorrespondentSuggestionsDropdown
senderId={senderId}
senderName=""
onselect={handleSuggestionSelect}
onclose={() => (showSuggestions = false)}
/>
{/if}
</div>
</div>

View File

@@ -1,10 +1,5 @@
<script lang="ts">
import {
conv_hint_single_person,
conv_hint_single_person_filtered,
conv_strip_sort_newest,
conv_strip_sort_oldest
} from '$lib/messages-extra';
import { conv_strip_sort_newest, conv_strip_sort_oldest } from '$lib/messages-extra';
interface Props {
senderName: string;
@@ -16,8 +11,9 @@ interface Props {
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
let hasDateFilter = $derived(!!(fromDate || toDate));
let sortLabel = $derived(sortDir === 'ASC' ? conv_strip_sort_oldest() : conv_strip_sort_newest());
let fromYear = $derived(fromDate ? fromDate.substring(0, 4) : '');
let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
</script>
<div
@@ -26,13 +22,12 @@ let sortLabel = $derived(sortDir === 'ASC' ? conv_strip_sort_oldest() : conv_str
<span class="text-sm" aria-hidden="true">📋</span>
{#if hasDateFilter}
{conv_hint_single_person_filtered({
name: senderName,
from: fromDate ?? '',
to: toDate ?? '',
sortLabel
})}
<strong>{senderName}</strong>
<span>·</span>
<span>{fromYear}{toYear}</span>
<span>·</span>
<span>{sortLabel}</span>
{:else}
{conv_hint_single_person({ name: senderName })}
Alle Briefe von <strong>{senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen
{/if}
</div>

View File

@@ -18,8 +18,15 @@ const baseData = {
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
};
const withSender = {
...baseData,
initialValues: { senderName: 'Hans Müller', receiverName: '' },
filters: { ...baseData.filters, senderId: 'p1' }
};
const withPersons = {
...baseData,
initialValues: { senderName: 'Hans Müller', receiverName: 'Anna Schmidt' },
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
};
@@ -30,6 +37,7 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
status: 'UPLOADED' as const,
documentDate: '1923-04-12',
location: 'Berlin',
metadataComplete: false,
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],
@@ -45,41 +53,120 @@ const withDocs = {
documents: [makeDoc()]
};
// ─── Empty state ──────────────────────────────────────────────────────────────
// ─── Empty state (no senderId) ────────────────────────────────────────────────
describe('Conversations page empty state', () => {
it('shows the "select two persons" prompt when no persons are selected', async () => {
describe('Korrespondenz page empty state', () => {
it('shows the search heading when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
});
it('hides the swap button when no persons are selected', async () => {
it('shows the empty-search button', 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');
await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument();
});
it('does not show the new document link when no persons are selected', async () => {
it('does not show the new document link when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
it('does not show a year divider when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('year-divider')).not.toBeInTheDocument();
});
});
// ─── Recent persons chips ─────────────────────────────────────────────────────
describe('Korrespondenz page recent persons', () => {
it('shows recent person chips from localStorage', async () => {
localStorage.setItem(
'korrespondenz_recent_persons',
JSON.stringify([{ id: 'r1', name: 'Clara Braun' }])
);
render(Page, { data: baseData });
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
localStorage.removeItem('korrespondenz_recent_persons');
});
it('does not crash when localStorage contains corrupt JSON', async () => {
localStorage.setItem('korrespondenz_recent_persons', '}{not valid json');
render(Page, { data: baseData });
// Empty state heading is still shown — no chip list crash
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
localStorage.removeItem('korrespondenz_recent_persons');
});
});
// ─── Single-person hint bar ───────────────────────────────────────────────────
describe('Korrespondenz page single-person hint bar', () => {
it('shows hint bar when only senderId is set', async () => {
render(Page, { data: withSender });
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument();
});
it('does not show hint bar when both persons are set', async () => {
render(Page, { data: { ...withPersons, documents: [makeDoc()] } });
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).not.toBeInTheDocument();
});
it('does not show hint bar when no person is set', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Alle Briefe von/i)).not.toBeInTheDocument();
});
});
// ─── Filter controls disabled state ──────────────────────────────────────────
describe('Korrespondenz page filter strip Row 2 disabled state', () => {
it('renders filter controls with aria-disabled when no senderId', async () => {
render(Page, { data: baseData });
const strip = document.querySelector('[aria-disabled="true"]');
expect(strip).not.toBeNull();
});
});
// ─── Strip letter count ───────────────────────────────────────────────────────
describe('Korrespondenz page strip letter count', () => {
it('shows 0 Briefe when senderId is set but no documents', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe');
});
it('shows correct count when documents are loaded', async () => {
render(Page, { data: { ...withPersons, documents: [makeDoc()] } });
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('1 Briefe');
});
});
// ─── 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 });
describe('Korrespondenz page no results', () => {
it('shows "no documents found" when a person is selected but there are no documents', async () => {
render(Page, { data: withSender });
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 () => {
describe('Korrespondenz page swap button', () => {
it('swap button is invisible when only one person is set', async () => {
render(Page, { data: withSender });
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
expect(btn).not.toBeNull();
// opacity-0 is applied via class when swapVisible is false
expect(btn!.className).toMatch(/opacity-0/);
});
it('swap button is visible when both persons are set', async () => {
render(Page, { data: withPersons });
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
expect(btn).not.toBeNull();
expect(btn!.className).not.toMatch(/opacity-0/);
});
it('calls goto with swapped sender and receiver when clicked', async () => {
@@ -92,28 +179,9 @@ describe('Conversations page swap button', () => {
});
});
// ─── 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', () => {
describe('Korrespondenz 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');
@@ -141,7 +209,6 @@ describe('Conversations page year dividers', () => {
]
};
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();
});
@@ -149,12 +216,21 @@ describe('Conversations page year dividers', () => {
// ─── New document link ────────────────────────────────────────────────────────
describe('Conversations page new document link', () => {
it('shows the link with correct href for a write user', async () => {
describe('Korrespondenz page new document link', () => {
it('shows the link with correct href for a write user (bilateral)', 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');
await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1'));
await expect.element(link).toHaveAttribute('href', expect.stringContaining('receiverId=p2'));
});
it('shows the link with correct href for single-person mode', async () => {
render(Page, { data: { ...withSender, documents: [makeDoc()], canWrite: true } });
const link = page.getByTestId('conv-new-doc-link');
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1'));
await expect.element(link).not.toHaveAttribute('href', expect.stringContaining('receiverId'));
});
it('hides the link for a read-only user', async () => {